Японские кроссворды на QtQuick

    Череп и кости, КДПВ


    Люблю в свободное время что-нибудь прототипировать. Это позволяет поизучать что-то новое. Данный прототип является клиентом для ресурса http://www.nonograms.ru/, разработчиком которого является Чугунный К.А/ KyberPrizrak /. Весь код доступен на GiHub. На стороне C++ работа с HTML, модель галереи. На стороне QtQuick визуализация.


    В этот раз решил поковырять:


    • Q_GADGET и его использование в Qml;
    • есть ли жизнь без Qt WebKit;
    • поковырять Qt Labs Controls.


      Что сделано:


    • галерея кроссвордов;
    • разгадывание кроссворда.

    Под катом будет рассмотрено:


    • скриншоты;
    • как получить HTML без Qt WebKit;
    • как сделать кроссворд без Canvas.

    Скриншоты

    галлерея
    Меню


    Обходимся без Qt WebKit


    Сайт отдает кроссворд в виде матрицы:


    var d=[[571,955,325,492],
           [6,53,49,55],
           [47,18,55,65],
           ...]]

    Дальше JS скрипы создают html код кроссворда. Модуль WebKit был помечен как deprecated. В замен него предлагается использовать модуль Web Engine основанный на проекте Chrome.


    Тут сразу ждет небольшое разочарование. Web Engine не имеет API для работы с DOM на странице. Для разбора HTML кода пришлось воспользоваться сторонними средствами(Парсим HTML на C++ и Gumbo).
    А вот загрузить страницу, отрендерить и получить нужный HTML мы можем.


    QString getHtml(const QUrl& url)
    {
        QWebEnginePage page;
    
        QEventLoop loop;
        QObject::connect(&page, &QWebEnginePage::loadFinished,
                         &loop, &QEventLoop::quit);
        page.load(url);
        loop.exec();
    
        QTimer::singleShot(1000, &loop, &QEventLoop::quit);
        QString html;
    
        page.toHtml([&html, &loop](const QString& data){
            html = data;
            loop.quit();
        });
    
        loop.exec();
    
        return html;
    }

    QTimer::singleShot здесь используется для ожидания когда страница достроится. Метод toHtml асинхронный и принимает в качестве входного параметра функцию обратного вызова, для получения результата.


    Построение кроссворда


    Представление кроссворда
    Кроссворд решил представить как множество столбцов и строчек. Наверху красным обведено 10 столбцов, каждый размера 3. Слева обведены 10 строк, каждая размером 3. Далее код будет оперировать этими величинами.


    Кроссворд можно сделать несколькими способами:


    • рисовать на C++;
    • рисовать на JS и Canvas;
    • построить из базовых элементов(Item, Rectangle, MouseArea и т.д.)

    Я выбрал последний вариант.


    Полный код
    import QtQuick 2.5
    import Qt.labs.controls 1.0
    
    Item {
        clip:true
        property int margin: 20
        property int fontSize:  12
        property int ceilSize:  20;
        property int incCeilSize: ceilSize + 1
        property color borderColor: "#424242"
    
        property int rows:       0;
        property int rowSize:    0;
    
        property int column:     0;
        property int columnSize: 0;
    
        implicitHeight : crossGrid.height+margin*2
        implicitWidth : crossGrid.width+margin*2
    
        function loadFromNonogramsOrg(url) {
            console.log("Load:"+url);
            crossword.formNanogramsOrg(url);
        }
    
        function showOnlyNaturalNumber(val)
        {
            return val > 0 ? val: " ";
        }
    
        function drawCrossword(){
            var csize = crossword.size;
            if(csize.column() === 0 || csize.rows() === 0){
                return;
            }
            console.log(csize.column() + "x" + csize.rows());
            hRepeater.model = 0;
            rRepeater.model = 0;
    
            rowSize = crossword.rowSize();
            columnSize = crossword.columnSize();
    
            rows = csize.rows();
            column = csize.column();
    
            hRepeater.model = crossword.columnSize()*csize.column();
            rRepeater.model = crossword.rowSize()*csize.rows();
            bgImg.visible = true;
        }
    
        Image{
            id: bgImg
            asynchronous: true
            visible: false
            height: parent.height
            width: parent.width
            source:"qrc:/wall-paper.jpg"
        }
    
        Grid {
            id: crossGrid
            anchors.centerIn: parent
            columns: 2
            spacing: 2
            rowSpacing: 0
            columnSpacing: 0
    
            Rectangle{
                id:topLeftItm
                width: rowSize * ceilSize
                height:columnSize * ceilSize
                border.width: 1
                border.color: borderColor
                color: "transparent"
            }
    
            Grid {
                id: cGrid
                rows: columnSize
                columns: column
    
                Repeater {
                    id: hRepeater
                    model: 0
                    Item {
                        width: ceilSize; height: ceilSize
                        property int rw : Math.floor(index/column)
                        property int cn : Math.floor(index%column)
                        property int prw: rw+1
                        property int pcm: cn+1
    
                        Rectangle{
                            height: (prw % 5 == 0) || (prw == columnSize) ? ceilSize : incCeilSize
                            width:  (pcm % 5 == 0)  ? ceilSize : incCeilSize
                            color: "transparent"
                            border.width: 1
                            border.color: borderColor
    
                            Text {
                                anchors.centerIn: parent
                                text:showOnlyNaturalNumber(
                                         crossword.columnValue(cn,rw));
                                font{
                                    family: mandarinFont.name
                                    pixelSize: fontSize
                                }
                            }
    
                        }
                    }
                }
            }
    
            Grid {
                id: rGrid
                rows: rows
                columns: rowSize
    
                Repeater {
                    id: rRepeater
                    model: 0
                    Item {
                        width: ceilSize; height: ceilSize
                        property int rw : Math.floor(index/rowSize)
                        property int cn : Math.floor(index%rowSize)
                        property int prw: rw+1
                        property int pcn: cn+1
    
                        Rectangle{
                            height: prw % 5 == 0 ? ceilSize : incCeilSize
                            width:  (pcn % 5 == 0) || (pcn == rowSize)
                                    ? ceilSize : incCeilSize
                            color: "transparent"
                            border.width: 1
                            border.color: borderColor
    
                            Text {
                                anchors.centerIn: parent
                                text:showOnlyNaturalNumber(
                                         crossword.rowValue(rw,cn));
                                font{
                                    family: mandarinFont.name
                                    pixelSize: fontSize
                                }
                            }
                        }
                    }
                }
            }
    
            Rectangle{
                id: playingField
                width: column * ceilSize
                height:rows   * ceilSize
                border.width: 1
                border.color: borderColor
                color: "transparent"
    
                Grid{
                    rows: rows
                    columns:column
                    Repeater {
                        id: bRepeater
                        model: rows * column
                        Item {
                            id: ceilItm
                            width: ceilSize; height: ceilSize
                            property int rw : Math.floor(index/column)
                            property int cn : Math.floor(index%column)
                            state: "default"
    
                            Rectangle{
                                id: itmRec
                                height: (rw+1) % 5 == 0 ? ceilSize : incCeilSize
                                width: (cn+1) % 5 == 0  ? ceilSize : incCeilSize
                                color: "transparent"
                                border.width: 1
                                border.color: borderColor
                            }
    
                            Text{
                                id: itmTxt
                                visible:false
                                height: parent.height
                                width: parent.width
                                font.pixelSize: ceilSize
                                horizontalAlignment: Text.AlignHCenter
                                verticalAlignment:   Text.AlignVCenter
                                text:"+"
                                rotation:45
                            }
    
                            MouseArea {
                                anchors.fill: parent
                                onClicked: {
                                    if(parent.state == "default"){
                                        parent.state = "SHADED";
                                    }else if(parent.state == "SHADED"){
                                        parent.state = "CLEAR";
                                    }else{
                                        parent.state = "default";
                                    }
    
                                }
                            }
    
                            states: [
                                State{
                                    name:"SHADED"
                                    PropertyChanges {
                                        target: itmRec; color: "black";
                                    }
                                    PropertyChanges {
                                        target: itmTxt; visible: false;
                                    }
                                },
                                State{
                                    name:"CLEAR"
                                    PropertyChanges {
                                        target: itmRec; color: "transparent";
                                    }
                                    PropertyChanges {
                                        target: itmTxt; visible: true;
                                    }
                                }
                            ]
                        }
                    }
                }
            }
        }
    
        Text{
            visible: bgImg.visible
            anchors{
                right: parent.right
                rightMargin: 10
                bottom: parent.bottom
            }
            text:qsTr("Source: ")+"www.nonograms.ru"
    
            font{
                family: hanZiFont.name
                pixelSize: 12
            }
        }
    
        Connections {
            target: crossword
            onLoaded: {
                drawCrossword();
            }
        }
    }

    Основа представлена Item, размер которого вычисляется из размера crossGrid и размера отступа(margin)


    Item {
        clip:true
        implicitHeight : crossGrid.height+margin*2
        implicitWidth : crossGrid.width+margin*2
    
        /* ... */
    
        Image{
            id: bgImg
            asynchronous: true
            visible: false
            height: parent.height
            width: parent.width
            source:"qrc:/wall-paper.jpg"
        }
    
        Grid {
            id: crossGrid
            anchors.centerIn: parent
            columns: 2
            spacing: 2
    
            /* ... */
        }
    }

    Элемент crossGrid


    crossGrid


    Grid {
        id: crossGrid
        anchors.centerIn: parent
        columns: 2
        spacing: 2
        rowSpacing: 16
        columnSpacing: 16
    
        Rectangle{
            id:topLeftItm
            color: "transparent"
            border.width: 1
            border.color: borderColor
            /* ... */
        }
    
        Grid {
            id: cGrid
            /* ... */
        }
    
        Grid {
            id: rGrid
            /* ... */
        }
    
        Rectangle{
            id: playingField
            /* ... */
        }
    }

    topLeftItm прямоугольник заполняющий пространство. cGrid и rGrid описывают сетку с числами. playingField поле для решения кроссворда.


    Построение сетки


    Если написать так:


    Grid {
        id: cGrid
        rows: columnSize
        columns: column
    
        Repeater {
            id: hRepeater
            /* ... */
            Item {
                width: ceilSize; height: ceilSize
                Rectangle{
                    height: ceilSize
                    width: ceilSize
                    color: "transparent"
                    border.width: 1
                    border.color: borderColor
    
                    Text {
                        anchors.centerIn: parent
                        text: index
                            font{
                                family: mandarinFont.name
                                pixelSize: fontSize
                            }
                    }
                }
            }
        }
    }

    то получим удвоение линии


    удваение линии


    Что бы убрать удвоение линии используем трюк с размерами Item и Rectangle. Размер Item фиксирован, для того что бы в повторителе(Repeater) все элементы располагались ровно. Rectangle шире и выше на единицу, в зависимости от необходимости двойной линии.


    Repeater {
        id: hRepeater
        model: 0
        Item {
            width: ceilSize; height: ceilSize
            property int rw : Math.floor(index/column)
            property int cn : Math.floor(index%column)
            property int prw: rw+1
            property int pcm: cn+1
    
            Rectangle{
                height: (prw % 5 == 0) || (prw == columnSize) ? ceilSize : incCeilSize
                width:  (pcm % 5 == 0)  ? ceilSize : incCeilSize
                color: "transparent"
                border.width: 1
                border.color: borderColor
    
                Text {
                    anchors.centerIn: parent
                    text:showOnlyNaturalNumber(
                             crossword.columnValue(cn,rw));
                    font{
                        family: mandarinFont.name
                        pixelSize: fontSize
                    }
                }
    
            }
        }
    }

    Тут на основе индекса вычисляется строка(rw) и колонка(cn), увеличиваются на единицу, берется остаток от деления на 5. Т.е. через каждые 5 клеток ширина или высота Rectangle и Item совпадают, что дает удвоение линии.


    Поле кроссворда


    От поля нам нужна сетка и обработка щелчка мыши. Введем состояние ячейки сетки:


    • неактивная(default);
    • закрашенная(SHADED);
    • помеченная пустой(CLEAR).

    Начинать будем c неактивного состояния и менять по клику мыши в следующей последовательности
    Граф состояний


    Код рисования ячейки:


    Item {
        id: ceilItm
        width: ceilSize; height: ceilSize
        property int rw : Math.floor(index/column)
        property int cn : Math.floor(index%column)
        state: "default"
    
        Rectangle{
            id: itmRec
            height: (rw+1) % 5 == 0 ? ceilSize : incCeilSize
            width: (cn+1) % 5 == 0  ? ceilSize : incCeilSize
            color: "transparent"
            border.width: 1
            border.color: borderColor
        }
    
        Text{
            id: itmTxt
            visible:false
            height: parent.height
            width: parent.width
            font.pixelSize: ceilSize
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment:   Text.AlignVCenter
            text:"+"
            rotation:45
        }
    
        MouseArea {
            anchors.fill: parent
            onClicked: {
                if(parent.state == "default"){
                    parent.state = "SHADED";
                }else if(parent.state == "SHADED"){
                    parent.state = "CLEAR";
                }else{
                    parent.state = "default";
                }
    
            }
        }
    
        states: [
            State{
                name:"SHADED"
                PropertyChanges {
                    target: itmRec; color: "black";
                }
                PropertyChanges {
                    target: itmTxt; visible: false;
                }
            },
            State{
                name:"CLEAR"
                PropertyChanges {
                    target: itmRec; color: "transparent";
                }
                PropertyChanges {
                    target: itmTxt; visible: true;
                }
            }
        ]
    }

    itmTxt элемент добавляющий крестик на ячейку, отображая её как помеченную пустой. Тут вовсю используется возможность описывать различные состояния через states.
    MouseArea осуществляет переход. То из-за чего все затевалось. Никаких расчетов(преобразования координаты мыши в ячейку сетки), никаких ручных перерисовок.

    Метки:
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 7
    • 0
      я обычно когда вижу такое в проекте сразу ищу время переписать через модель+делегат.
      • 0
        У меня возникли небольшие трудности с предметной областью, если пользоваться определением из вики
        Изображения зашифрованы числами, расположенными слева от строк, а также сверху над столбцами.

        Но выкрутится в принципе можно.
        • 0
          так ведь ничего же не мешает сделать три модели (для поля, а также левой и верхней шапок), которые будут обращаться к одним и тем же данным. При этом логика моделей шапок будет одинакова с точностью до транспонирования.
          • 0

            Сейчас понимаю, что можно сделать все через одну модель. Если буду делать не прототип, то сделаю через модель.

      • +1
        Добрый день, несколько замечаний по статье:
        1. Обычно в статье присутствует вывод, общие впечатления и т.д.
        2. Не очень эффективно делать так:
        height: parent.height
        width: parent.width
        
        В данном случае это байндинги (кстати, оптимизируемые QV4 из-за простоты), anchors быстрее — для повторяющихся элементов и делегатов может быть важно.
        3. Когда модель большая, очень полезно писать не просто имя свойства в модели (роли), а добавлять слово model, например «model.index» вместо «index». Так явно показывается, что данные берутся из модели, а не какой-то одноименной переменной в сущности.
        4. Контрол подобный
        Text {
                                text: qsTr("Author")
                                font.family: hanZiFont.name
                                font.pixelSize: view.labelFontPixelSize
                            }
        
        Используется во многих местах, например 7 раз в Nonogram.qml. Было бы целесообразно выделить его в отдельный компонент, чтобы было легче поддерживать и не нарушать старый добрый DRY.

        В целом получилось неплохо.
        • 0

          Полностью с вами согласен, но пока производительность не беспокоит(если есть аппаратное ускорение OpenGL в драйверах видеокарты), но стал изучать QML Profiler. Это прототип и код еще не раз поменяется. Сейчас ставлю цели:


          • завести на своём планшете с Arndoid;
          • поиграться с версткой под ретиной;
          • поковырять пресловутый material design в связке с Qt Labs Controls.
          • +1
            2. Не очень эффективно делать так:

            height: parent.height
            width: parent.width

            В данном случае это байндинги (кстати, оптимизируемые QV4 из-за простоты), anchors быстрее — для повторяющихся элементов и делегатов может быть важно.

            Кстати, не всегда. Если размеры должны часто меняться, то да. Например, для фона окна хорошо подходит (если размер окна можно менять, конечно). А вот размер itmTxt зависит от ceilSize и не меняется в процессе работы, так что тут позиционировать про помощи width, height, x и y совершенно нормально. Использование якорей создает дополнительные объекты, поэтому стоит их использовать тогда, когда размер элемента должен часто меняться. Документация.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.