Графический интерфейс генератора. Часть 2

Тот, кто внимательно смотрел видео из первой части, должен был обратить внимание на сильное мерцание при нажатии на кнопки или поворот энкодера. А еще я сознательно не показал работу цифровой клавиатуры, т.к. были определенные проблемы. После проведения серьезной оптимизации работа интерфейса теперь выглядит так:


Оптимизация коснулась в основном объектов screen->tab->control. В процессе реализации обработчиков элементов управления возникла паутина связей между объектами. Приходилось много держать в голове, и вся наглядность кода внезапно пропала. Поэтому я решил убрать объекты tab, зачастую выполняющие роль промежуточного звена, разделив функции между screen и control. Название control мне сразу не очень нравилось, но почему-то только после публикации первой статьи в голову пришло логичное название – field, поле. Таким образом, получилось два объекта: screen->field. И структурная схема слегка упростилась

Чтобы не было путаницы в обработчиках, каждый объект полностью контролирует только определенные элементы управления.

Начнем с объектов screen. Их придется пересмотреть, т.к. было слишком много изменений.

Screen (экран)

Т.к. теперь нет объектов tab, нужно сформировать вкладки в структуре экрана. Делается это с помощью двумерного массива. Также добавились переменные текущей вкладки и поля.

typedef struct
{
    const char *name;           // заголовок экрана
    Field_Type  fields[4][2];   // 4 вкладки по 2 поля
    uint8_t     selectedTab;    // выделенная вкладка
    uint8_t     selectedField;  // выделенное поле
} Screen_Struct;

Создаются объекты как и раньше, макросом.

#define CREATE_SCREEN(screen,name,field11,          \
                                  field12,          \
                                  field21,          \
                                  field22,          \
                                  field31,          \
                                  field32,          \
                                  field41,          \
                                  field42)          \
                                                    \
Screen_Struct screen =  {(const char *) name,       \
                       {{(Field_Type  ) field11,    \
                         (Field_Type  ) field12},   \
                        {(Field_Type  ) field21,    \
                         (Field_Type  ) field22},   \
                        {(Field_Type  ) field31,    \
                         (Field_Type  ) field32},   \
                        {(Field_Type  ) field41,    \
                         (Field_Type  ) field42}}};

//---------------------------------------------------------------------------------
CREATE_SCREEN(screenSine      , "Signal - Sine",     FIELD_FREQ,
                                                     FIELD_PERIOD,

                                                     FIELD_AMPL,
                                                     FIELD_OFFSET,

                                                     FIELD_VMAX,
                                                     FIELD_VMIN,

                                                     FIELD_NULL,
                                                     FIELD_NULL);

Для удобного обращения к вкладкам и полям есть набор макросов

#define CURRENT_TAB_ID   (currentScreen->selectedTab)
#define CURRENT_FIELD_ID (currentScreen->selectedField)
#define CURRENT_FIELD    (currentScreen->fields[CURRENT_TAB_ID][CURRENT_FIELD_ID])
#define UNSELECTED_FIELD (currentScreen->fields[CURRENT_TAB_ID][(CURRENT_FIELD_ID+1)&1])
#define TOP_FIELD        (currentScreen->fields[CURRENT_TAB_ID][0])
#define BOTTOM_FIELD     (currentScreen->fields[CURRENT_TAB_ID][1])

Также есть указатели на текущий экран и экран текущей формы сигнала.

static volatile Screen_Struct *currentScreen;   // указатель на текущий экран
static volatile Screen_Struct *currentWave;     // указатель на экран текущей формы сигнала

Инициализация почти без изменений. Настраиваем поля, которые в свою очередь настроят железо, и обнуляем текущий указатель. Первый экран вызовем в main'e имитацией нажатия нужной кнопки.

void SCREEN_Init(void)
{
    // инициализируем все поля
    FIELD_Init();

    // обнуляем текущий экран, установка первого экрана в main'e
    currentScreen = 0;
}

Оптимизировал графику настолько, что редкий пиксель лишний раз перерисуется без необходимости. Кроме того, низкоуровневое рисование оформил в виде отдельных маленьких функций:

void SCREEN_DrawHeadLine(void);
void SCREEN_UpdateHead(void);

void SCREEN_DrawTabLine (uint8_t tabPos, uint16_t color);
void SCREEN_DrawOnlyTabs(void);
void SCREEN_RepaintTabs(void);
void SCREEN_UpdateTabs(void);

void SCREEN_RepaintFields(void);
void SCREEN_UpdateFields(void);
void SCREEN_DrawInputField(void);

Теперь функция отрисовки экрана выглядит более чем понятно.

void SCREEN_Paint(void)
{
    SCREEN_DrawHeadLine();  // рисуем границу заголовка, она не будет перерисовываться
    SCREEN_UpdateHead();    // рисуем текст заголовка
    SCREEN_UpdateTabs();    // рисуем вкладки
    SCREEN_UpdateFields();  // и поля
}

Самое сложное – это обработчик. Функция очень большая, но я как мог визуально разделил функциональные блоки, которых 5 штук:
— нажатие кнопки ОUT;
— нажатие функциональной кнопки (из тех, что сразу под экраном);
— нажатие кнопки выбора формы сигнала (под функциональными);
— нажатие кнопки выбора режима работы (следующий ряд);
— все остальные.
Маленькие функции объектов field делают полезное дело, код обработчика теперь читается значительно лучше, чем в первой части.
Блок обработки кнопки выхода самый простой. Читая состояние светодиода OUT, определяем состояние выхода и инвертируем его.

// НАЖАТА КНОПКА ВЫХОДА---------------------------------------------------------------
if(event == EVENT_OUT)
{
    if(HW_IsLedOut())
    {
        HW_SetSignalParam(FIELD_OUT, OUT_OFF);
        HW_LedOff(LED_OUT);
    }
    else
    {
        HW_SetSignalParam(FIELD_OUT, OUT_ON);
        HW_LedOn(LED_OUT);
    }
    return;
}

Блок обработки функциональной кнопки сначала выясняет, начат ли ввод с цифровой клавиатуры, если да, то во вкладках соответственно отображаются единицы измерения. Значит надо применить к активному полю введенное число с указанной единицей измерения. Если же ввод не начат, то переходим на нажатую вкладку, либо (если вкладка та же) делаем активным второе поле.

// НАЖАТА ФУНКЦИОНАЛЬНАЯ КНОПКА-------------------------------------------------------
if(IS_EVENT_FUNC_BUTTON(event))
{
    // узнаем номер нажатой вкладки
    uint8_t numFunc = 0;

    switch(event)
    {
        case EVENT_FUNC1: numFunc = 0; break;
        case EVENT_FUNC2: numFunc = 1; break;
        case EVENT_FUNC3: numFunc = 2; break;
        case EVENT_FUNC4: numFunc = 3; break;
        default: break;
    }

    // если включен режим ввода с цифровой клавиатуры
    if(FIELD_IsInputing(CURRENT_FIELD))
    {
        // то на вкладках единицы измерения, проверяем на корректность выбранную
        if(FIELD_IsCorrectUnit(CURRENT_FIELD, numFunc))
        {
            FIELD_ApplyInput(CURRENT_FIELD, numFunc);   // принимаем введенное значение
            SCREEN_UpdateTabs();                        // обновляем вкладки
            SCREEN_RepaintFields();                     // просто перерисовываем поля
        }
    }

    // если не включен
    else
    {
        // и та же вкладка, то меняем активные контролы в ней
        if(currentScreen->selectedTab == numFunc)
        {
            // но только если вторая вкладка не нулевая
            if(UNSELECTED_FIELD != FIELD_NULL)
            {
                FIELD_SetSelected(CURRENT_FIELD, 0);
                currentScreen->selectedField = (CURRENT_FIELD_ID == 0) ? 1 : 0;
                FIELD_SetSelected(CURRENT_FIELD, 1);
            }
        }
        // если другая вкладка и первое поле не равно NULL, то переходим на нее
        else if(currentScreen->fields[numFunc][0] != FIELD_NULL)
        {
            currentScreen->selectedTab = numFunc;
            // после перехода надо проверить текущее поле на нулевое
            if(CURRENT_FIELD == FIELD_NULL)
            {
                // при необходимости сделать активным первое. Оно точно не нулевое
                currentScreen->selectedField = 0;
            }
            SCREEN_RepaintTabs();
            SCREEN_UpdateFields();
        }
    }
}

Блок выбора формы сигнала также отменяет ввод данных, если таковой был начат. После этого открывает соответствующий экран и переключает светодиод. Этот блок сохраняет два указателя: на текущий экран и текущую форму сигнала. На второй произойдет переход при выключении режима работы, такого как ГКЧ, пакет или модуляция.

// НАЖАТА КНОПКА ВЫБОРА ФОРМЫ СИГНАЛА-------------------------------------------------
else if(IS_EVENT_WAVE_BUTTON(event))
{
    // если был ввод данных, отменяем его
    if(FIELD_IsInputing(CURRENT_FIELD))
    {
        FIELD_CancelInput(CURRENT_FIELD);
    }

    // задаем форму сигнала
    switch (event)
    {
        case EVENT_WAVE_SINE:
             if(currentScreen != &screenSine)
             {
                 HW_LedOff(LED_WAVE_ALL);
                 HW_LedOn(LED_WAVE_SINE);
                 currentScreen = &screenSine;
                 currentWave   = &screenSine;
                 HW_SetSignalParam(FIELD_WAVEFORM, WAVEFORM_SINE);
                 SCREEN_Paint();
             }
             break;

        case EVENT_WAVE_SQUARE:
             if(currentScreen != &screenSquare)
             {
                 HW_LedOff(LED_WAVE_ALL);
                 HW_LedOn(LED_WAVE_RECT);
                 currentScreen = &screenSquare;
                 currentWave   = &screenSquare;
                 HW_SetSignalParam(FIELD_WAVEFORM, WAVEFORM_SQUARE);
                 SCREEN_Paint();
             }
             break;

        case EVENT_WAVE_RAMP:
             if(currentScreen != &screenRamp)
             {
                 HW_LedOff(LED_WAVE_ALL);
                 HW_LedOn(LED_WAVE_RAMP);
                 currentScreen = &screenRamp;
                 currentWave   = &screenRamp;
                 HW_SetSignalParam(FIELD_WAVEFORM, WAVEFORM_RAMP);
                 SCREEN_Paint();
             }
             break;

        case EVENT_WAVE_OTHER:
             if(currentScreen != &screenOther)
             {
                 HW_LedOff(LED_WAVE_ALL);
                 HW_LedOn(LED_WAVE_OTHER);
                 currentScreen = &screenOther;
                 currentWave   = &screenOther;
                 HW_SetSignalParam(FIELD_WAVEFORM, WAVEFORM_OTHER);
                 SCREEN_Paint();
             }
             break;

        default: break;
    }
}

Блок выбора режима работы также отменяет ввод данных. Но работает немного похитрее. Если мы переходим на экран желаемой формы с другого экрана, то включаем соответствующий режим и светодиод. А если жмем кнопку экрана, который уже на выведен дисплей, то режим выключится. А на дисплее отобразится экран текущей формы сигнала.

// НАЖАТА КНОПКА ВЫБОРА РЕЖИМА СИГНАЛА-----------------------------------------------
else if(IS_EVENT_MODE_BUTTON(event))
{
    // если был ввод данных, отменяем его
    if(FIELD_IsInputing(CURRENT_FIELD))
    {
        FIELD_CancelInput(CURRENT_FIELD);
    }

    // если хотим другой режим, то устанавливаем его
    switch (event)
    {
        case EVENT_MODE_MOD:
            // включаем режим модуляции
            if(currentScreen != &screenModulation)
            {
                HW_LedOff(LED_MODE_ALL);
                HW_LedOn(LED_MODE_MOD);
                currentScreen = &screenModulation;
                HW_SetSignalParam(FIELD_MODE, MODE_MODULATION);
            }
            // выключаем режим модуляции
            else
            {
                HW_LedOff(LED_MODE_MOD);
                currentScreen = currentWave;
                HW_SetSignalParam(FIELD_MODE, MODE_CONTINUOUS);
            }
            break;

        case EVENT_MODE_SWEEP:
            // включаем режим ГКЧ
            if(currentScreen != &screenSweep)
            {
                HW_LedOff(LED_MODE_ALL);
                HW_LedOn(LED_MODE_SWEEP);
                currentScreen = &screenSweep;
                HW_SetSignalParam(FIELD_MODE, MODE_SWEEP);
            }
            // выключаем режим ГКЧ
            else
            {
                HW_LedOff(LED_MODE_SWEEP);
                currentScreen = currentWave;
                HW_SetSignalParam(FIELD_MODE, MODE_CONTINUOUS);
            }
            break;

        case EVENT_MODE_BURST:
            // включаем режим пакета
            if(currentScreen != &screenBurst)
            {
                HW_LedOff(LED_MODE_ALL);
                HW_LedOn(LED_MODE_BURST);
                currentScreen = &screenBurst;
                HW_SetSignalParam(FIELD_MODE, MODE_BURST);
            }
            // выключаем режим пакета
            else
            {
                HW_LedOff(LED_MODE_BURST);
                currentScreen = currentWave;
                HW_SetSignalParam(FIELD_MODE, MODE_CONTINUOUS);
            }
            break;

        default: break;
    }

    // рисуем новый экран
    SCREEN_Paint();
}

Все остальные события отправляются активному полю. После чего надо проверить, включился ли режим ввода, и при необходимости подготовить экран: удалить второе поле, очистить зону вкладок и вывести на них единицы измерения.

// ВСЕ ОСТАЛЬНЫЕ КОДЫ ОТПРАВЛЯЕМ АКТИВНОЙ ВКЛАДКЕ--------------------------------------
else
{
    FIELD_EventHandler(CURRENT_FIELD, event);

    // если был начат ввод данных
    if(FIELD_IsStartInput(CURRENT_FIELD))
    {
        // рисуем только поле ввода данных
        SCREEN_DrawInputField();
    }
}

Мелкие функции не буду рассматривать. Они очень простые и хорошо прокомментированы.

Field (поле)

Добрались до самого интересного. Полей в интерфейсе достаточно много, чуть меньше, чем общее количество возможных команд генератору. Тут надо пояснить. Дело в том, что не все команды надо отображать в каком-то виде на дисплее. Это касается тех команд, которые вызываются в обработчике экранов: OUT, WAVEFORM, MODE, а также неучтенный пока TRIG. Поэтому чтобы все команды были в одном месте, я сделал перечисление, в котором элемент NULL стоит не последним. Таким образом, поля до нулевого элемента будут иметь структуры для отрисовки на дисплее, а поля после – нет, они будут просто идентификаторами команд. Делать для команд отдельное перечисление смысла нет, т.к. будет совпадать львиная доля строчек.

typedef enum
{
    FIELD_FILTER        ,
    FIELD_BIAS          ,
    FIELD_GAIN          ,

    FIELD_FREQ          ,
    FIELD_PERIOD        ,
    FIELD_AMPL          ,
    FIELD_OFFSET        ,
    FIELD_VMAX          ,
    FIELD_VMIN          ,

    FIELD_RAMP_SYMMETRY ,
    FIELD_DUTY_CYCLE    ,

    FIELD_SWEEP_TYPE    ,
    FIELD_SWEEP_START   ,
    FIELD_SWEEP_STOP    ,
    FIELD_SWEEP_TIME    ,

    FIELD_MOD_FREQ      ,
    FIELD_MOD_PERIOD    ,
    FIELD_MOD_WAVEFORM  ,
    FIELD_MOD_DEPTH     ,

    FIELD_BURST_COUNT   ,
    FIELD_BURST_TIME    ,
    FIELD_BURST_PERIOD  ,
    FIELD_BURST_PAUSE   ,
    FIELD_BURST_PHASE   ,

    FIELD_OTHER_WAVEFORM,

    FIELD_NULL          , // размерность массива структур полей

    FIELD_OUT           , // отсюда начинаются коды команд, для которых нет структур параметров
    FIELD_WAVEFORM      ,
    FIELD_MODE

} Field_Type;

Вобще поле должно содержать огромное количество параметров, которые будут инициализироваться макросом. Не так давно у меня был макрос с 24 параметрами). Таблица инициализации выходила далеко за видимую область монитора. С полосами прокрутки работать можно, но не совсем удобно. Поэтому я разделил структуру поля на две части: структура параметра и структура поля. Первая имеет следующий вид:

typedef struct
{
    const char   *longName;     // полное имя для поля
    const char   *shortName;    // короткое имя для вкладок

    const char   *unit[4];      // строки с единицами измерений
    uint8_t       currentUnit;  // текущая единица измерения (индекс массива)

    int32_t       currentValue; // текущее значение
    int32_t       minValue;     // минимальное значение
    int32_t       maxValue;     // максимальное значение

    const char   *strArray;     // указатель на массив строк

    const char   *symbol;       // иконка
} Param_Struct;

Максимальное количество единиц измерения — 4, по числу вкладок. Если надо преобразовать значение в минимальные единицы измерения (а именно в минимальных оно указывается при передаче сигнальной плате), то значение currentValue умножается на 1000^currentUnit. Массив строк strArray объявляется в таком виде

const char strSweepType[][10] = {{"Linear"   },   
                                 {"Log"      }};  

Макрос для инициализации структуры параметра не маленький, но уже приемлемый. Да и таблица получилась не такая уж и широкая. Почти помещается на экране моего ноута.

#define CREATE_PARAM(paramName,                                     \
                     longNameStr,                                   \
                     shortNameStr,                                  \
                     unit1,                                         \
                     unit2,                                         \
                     unit3,                                         \
                     unit4,                                         \
                     currentUnit,                                   \
                     data,                                          \
                     dataMin,                                       \
                     dataMax,                                       \
                     strArr,                                        \
                     symbol)                                        \
                                                                    \
Param_Struct paramName = {(const char *) longNameStr,               \
                          (const char *) shortNameStr,              \
                         {(const char *) unit1,                     \
                          (const char *) unit2,                     \
                          (const char *) unit3,                     \
                          (const char *) unit4},                    \
                          (uint8_t)      currentUnit,               \
                          (int32_t)      data,                      \
                          (int32_t)      dataMin,                   \
                          (int32_t)      dataMax,                   \
                          (const char *) strArr,                    \
                          (const char *) symbol}

//----------|paramName       |longName         |shortName|unit1|unit2|unit3|unit4|curUnit| data |dataMin|dataMax|strArr      |symbol
CREATE_PARAM(paramBias       ,"Bias"            ,"Bias"  ,""   ,0    ,0    ,0    ,0      ,127   ,0      ,255    ,0           ,0);
CREATE_PARAM(paramGain       ,"Gain"            ,"Gain"  ,""   ,0    ,0    ,0    ,0      ,255   ,0      ,510    ,0           ,0);
CREATE_PARAM(paramFilter     ,"Lowpass filter"  ,"Filter",""   ,0    ,0    ,0    ,0      ,0     ,0      ,17     ,strFilter   ,0);
CREATE_PARAM(paramFreq       ,"Frequency"       ,"Freq"  ,"mHz","Hz" ,"kHz","MHz",1      ,1000  ,1      ,1000000,0           ,0);
CREATE_PARAM(paramPeriod     ,"Period"          ,"Period","us" ,"ms" ,"s"  ,0    ,0      ,1000  ,0      ,1000000,0           ,0);
CREATE_PARAM(paramAmpl       ,"Amplitude"       ,"Ampl"  ,"mV" ,"V"  ,0    ,0    ,0      ,1000  ,-5000  ,5000   ,0           ,0);
...

Структура поля уже значительно меньше:

typedef struct
{
    Param_Struct *param;        // контролируемый параметр

    uint8_t       currentDigit; // номер разряда, изменяемый энкодером
    int32_t       inputValue;   // вводимое значение

    uint8_t       selected;     // флаг выделения
    uint8_t       inputing;     // флаг ввода данных

    uint16_t      pos;          // координаты отрисовки "по вертикали"

    Field_Type    linkedField;  // связанное поле (напр. частота-период)
} Field_Struct;

Так как в структуре нет строковых значение, можно обойтись без макроса. просто объявляем массив.

static Field_Struct fields[FIELD_NULL];

Инициализация полей происходит в функции FIELD_Init(). В этой же функции задаются связанные поля. Это довольно удобная штука, когда на одном экране находятся обратные значения, такие как частота и период. Таким образом, изменение одного приведет к изменению другого.

void FIELD_Init()
{
    uint8_t i;

    // инициализируем интерфейс связи с сигнальной платой
    HW_Init();

    // обнуляем все параметры структур
    for(i=0; i<FIELD_NULL; i++)
    {
        fields[i].currentDigit            = 0;          // текущий разряд
        fields[i].inputing                = 0;          // флаг ввода данных с клавиатуры
        fields[i].inputValue              = 0;          // введенное значение
        fields[i].param                   = 0;          // указатель на параметр
        fields[i].pos                     = 0;          // координата отрисовки по вертикали
        fields[i].selected                = 0;          // флаг выделения
        fields[i].linkedField             = FIELD_NULL; // флаг выделения
    }

    // сопоставляем каждому полю соответствующий параметр
    fields[FIELD_BIAS].param              = ¶mBias; // адрес paramBias; косяк движка сайта
    fields[FIELD_GAIN].param              = ¶mGain; // остальные по аналогии
    fields[FIELD_FILTER].param            = ¶mFilter;
    fields[FIELD_FREQ].param              = ¶mFreq;
    fields[FIELD_PERIOD].param            = ¶mPeriod;
    fields[FIELD_AMPL].param              = ¶mAmpl;
    ...

    // устанавливаем связанные поля
    fields[FIELD_FREQ].linkedField        = FIELD_PERIOD;
    fields[FIELD_PERIOD].linkedField      = FIELD_FREQ;

    fields[FIELD_MOD_FREQ].linkedField    = FIELD_MOD_PERIOD;
    fields[FIELD_MOD_PERIOD].linkedField  = FIELD_MOD_FREQ;
}

Пользовательских функций значительно больше, чем у экранов

void        FIELD_Init            (void);
void        FIELD_SetSelected     (Field_Type FIELD_x, uint8_t value);
uint8_t     FIELD_IsStartInput    (Field_Type FIELD_x);
uint8_t     FIELD_IsInputing      (Field_Type FIELD_x);
uint8_t     FIELD_IsCorrectUnit   (Field_Type FIELD_x, uint8_t unitId);
void        FIELD_ApplyInput      (Field_Type FIELD_x, uint8_t unitId);
void        FIELD_CancelInput     (Field_Type FIELD_x);
void        FIELD_Paint           (Field_Type FIELD_x, uint8_t selected);
void        FIELD_Update          (Field_Type FIELD_x, uint8_t selected, uint8_t fieldPos);
void        FIELD_Clear           (Field_Type FIELD_x);
void        FIELD_EventHandler    (Field_Type FIELD_x, Event_Type event);
const char* FIELD_Name            (Field_Type FIELD_x);

Обратите внимание, есть функции Paint(), есть функции Update(). Отличие в том, что первая просто рисует, не чистит предварительно место. Удобно для перерисовки после выделения (пиксели на тех же местах, только цвет другой). Вторая уже чистит место. Это все для снижения мерцания.
Также есть ряд служебных функций, которые нужны только в этом модуле и предназначены для улучшения читабельности кода в самых важных функциях: FIELD_Paint() и FIELD_EventHandler().

void    FIELD_CalcInverseValue (Field_Type FIELD_x);                 // рассчитать инв. значение поля

void    FIELD_ClearUnit        (Field_Type FIELD_x);                 // очистить зону ед.изм.
void    FIELD_DrawValue        (Field_Type FIELD_x, uint16_t color, int32_t value); // отрисовка числа в поле
void    FIELD_DrawUnit         (Field_Type FIELD_x);                 // нарисовать ед. изм.
void    FIELD_DrawUnderLine    (Field_Type FIELD_x);                 // отрисовка подчеркивания
void    FIELD_DrawContour      (Field_Type FIELD_x, uint16_t color); // отрисовка границы
void    FIELD_DrawCurrentData  (Field_Type FIELD_x, uint16_t color); // отрисовка текущего значения
void    FIELD_DrawInputData    (Field_Type FIELD_x, uint16_t color); // отрисовка вводимого значения

uint8_t FIELD_GetDigitMax      (int32_t value);                      // возвращает разрядность числа

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

void FIELD_Paint(Field_Type FIELD_x, uint8_t selected)
{
    if(FIELD_x >= FIELD_NULL) return;

    //сохраняем состояние выделения
    fields[FIELD_x].selected = selected;

    //задаем цвет в зависимости от выделения
    uint16_t color = (fields[FIELD_x].selected) ? COLOR_SELECTED_ITEMS : COLOR_UNSELECTED_ITEMS;

    //рисуем рамку с названием
    FIELD_DrawContour(FIELD_x, color);

    // рисуем значение
    if(fields[FIELD_x].inputing)                 // если был начат ввод данных с цифр. клавиатуры
        FIELD_DrawInputData(FIELD_x, color);     // рисуем вводимые данные,
    else                                         // в противном случае
        FIELD_DrawCurrentData(FIELD_x, color);   // рисуем текущее значение параметра
}

Функция FIELD_DrawCurrentData() вызывает функцию отрисовки значения поля и добавляет единицу измерения. Функция FIELD_DrawValue() используется также при обработке энкодера и при отрисовке введенного значения.

void FIELD_DrawCurrentData(Field_Type FIELD_x, uint16_t color)
{
    //отрисовка значения
    FIELD_DrawValue(FIELD_x, color, fields[FIELD_x].param->currentValue);

    //отрисовка единицы измерения, если значение числовое
    if(!fields[FIELD_x].param->strArray)
    {
        FIELD_DrawUnit(FIELD_x);

        //черта под цифрой активной секции
        if(fields[FIELD_x].selected)
        {
            FIELD_DrawUnderLine(FIELD_x);
        }
    }
}

Функция FIELD_DrawInputData() пишет введенное с клавиатуры число. Для устранения мерцания при нажатии кнопок надо отслеживать начало ввода и продолжение. Поэтому флаг inputing не битовый:

#define SELECTED           1
#define UNSELECTED         0

#define INPUT_NO           0
#define INPUT_START        1
#define INPUT_CONT         2

#define POSITION_TOP     141
#define POSITION_BOTTOM   67

При начале ввода ставится флаг INPUT_START (в обработчике клавиатуры). Только после отрисовки единиц измерения во вкладках он изменится на INPUT_CONT.

void FIELD_DrawInputData(Field_Type FIELD_x, uint16_t color)
{
    uint8_t i;

    // введенное значение
    FIELD_DrawValue(FIELD_x, color, fields[FIELD_x].inputValue);

    // если ввод только начали
    if(fields[FIELD_x].inputing == INPUT_START)
    {
        // рисуем черту
        fields[FIELD_x].currentDigit = 0;
        FIELD_DrawUnderLine(FIELD_x);

        // стираем единицу измерения
        FIELD_ClearUnit(FIELD_x);

        // пишем во вкладках единицы измерения
        for(i = 0; i<4; i++)
        {
            // задаем шрифт
            ILI9341_SetFontType(FONT_COURIER_NEW_11_18);

            if(fields[FIELD_x].param->unit[i])
            {
                ILI9341_DrawText(10, 80*i+40, fields[FIELD_x].param->unit[i], COLOR_SELECTED_ITEMS, ALIGN_CENTER);
            }
        }

        // ставим флаг, чтобы больше не перерисовывать вкладки
        fields[FIELD_x].inputing = INPUT_CONT;
    }
}

Функция отрисовки значения определяет, какой тип данных у поля, и выводит на дисплей либо числовое, либо текстовое значение.

void FIELD_DrawValue (Field_Type FIELD_x, uint16_t color, int32_t value)
{
    // задаем шрифт
    ILI9341_SetFontType(FONT_COURIER_NEW_21_34);

    // чистим место под значение поля. не затрагиваем иконку
    uint16_t pos = fields[FIELD_x].pos + 10;
    ILI9341_DrawFillRect(pos, 10, 34, 200, COLOR_BACKGROUND);

    // если указатель на строки нулевой, значит это числовые данные
    if(!fields[FIELD_x].param->strArray)
    {
        // отрисовка значения
        ILI9341_DrawNumber(pos, 210, value, color, ALIGN_RIGHT);
    }
    else // если указатель на строку есть, значит это константный параметр
    {
        const char *text = fields[FIELD_x].param->strArray + 10*fields[FIELD_x].param->currentValue;
        ILI9341_SetFontType(FONT_COURIER_NEW_21_34);
        ILI9341_DrawText(fields[FIELD_x].pos + 10, 130, text,color, ALIGN_CENTER);
    }
}

Обработчик событий состоит из трех частей: энкодер, кнопки влево-вправо под ним и клавиатура.
При обработке события от энкодера сначала определяется значение в зависимости от текущего разряда, на которое надо уменьшить/увеличить текущее значение. Затем уменьшаем/увеличиваем текущее значение, проверяя минимальное/максимальное значение. Если значение уменьшаем, то контролируем положение черты, чтоб не выходила за пределы числа. После этого отправляем команду и отрисовываем значение на дисплее. Проверяем на связанное поле, если оно есть, то рассчитываем его значение.

// ОБРАБОТЧИК ЭНКОДЕРА =======================================================================

if(IS_EVENT_ENCODER(event))
{
    int32_t i;
    int32_t dop = 1;
    uint8_t digitMax;


    for(i = fields[FIELD_x].currentDigit; i>0; i--) // определяем число, на которое надо
    {                                               // увеличить или уменьшить значение поля,
        dop *= 10;                                  // если событие вызвано энкодером
    }


    // энкодер вверх--------------------------------------------------------------------------
    if(event == EVENT_ENC_UP)
    {
        // увеличиваем значение на рассчитанное число и сравниваем его с максимальным
        fields[FIELD_x].param->currentValue += dop;
        if(fields[FIELD_x].param->currentValue > fields[FIELD_x].param->maxValue)
           fields[FIELD_x].param->currentValue = fields[FIELD_x].param->maxValue;
    }

    // энкодер вниз----------------------------------------------------------------------------
    else
    {
        // уменьшаем значение на рассчитанное число и сравниваем его с минимальным
        fields[FIELD_x].param->currentValue -= dop;
        if(fields[FIELD_x].param->currentValue < fields[FIELD_x].param->minValue)
           fields[FIELD_x].param->currentValue = fields[FIELD_x].param->minValue;

        // не позволяем черте быть за пределами значения
        digitMax = FIELD_GetDigitMax(fields[FIELD_x].param->currentValue);
        if (fields[FIELD_x].currentDigit > digitMax)
            fields[FIELD_x].currentDigit = digitMax;
    }

    // общие действия--------------------------------------------------------------------------
    
    // отправляем данные в сигнальный контроллер
    HW_SetSignalParam(FIELD_x, fields[FIELD_x].param->currentValue);

    // отрисовываем значение на дисплее
    FIELD_DrawValue(FIELD_x, COLOR_SELECTED_ITEMS, fields[FIELD_x].param->currentValue);
    
    // если поле числовое, рисуем черту
    if(!fields[FIELD_x].param->strArray)
        FIELD_DrawUnderLine(FIELD_x);

    // смотрим, если есть ли связанное поле
    Field_Type inverseField = fields[FIELD_x].linkedField;
    if(inverseField != FIELD_NULL)
    {
        // берем текущую единицу измерения связанного поля,
        // потом рассчитываем и рисуем новое значение связанного поля,
        // затем сравниваем единицы измерения и в зависимости от этого
        // перерисовываем единицу измерения
        uint8_t oldUnit = fields[inverseField].param->currentUnit;
        FIELD_CalcInverseValue(FIELD_x);

        int32_t invData = fields[inverseField].param->currentValue;
        FIELD_DrawValue(inverseField, COLOR_UNSELECTED_ITEMS, invData);

        if(oldUnit != fields[inverseField].param->currentUnit)
        {
            FIELD_ClearUnit(inverseField);
            FIELD_DrawUnit(inverseField);
        }
    }
}

Расчет значения связанного поля осуществляется следующим образом

void FIELD_CalcInverseValue (Field_Type FIELD_x)
{
    if(FIELD_x >= FIELD_NULL) return;

    // смотрим, если есть ли связанное поле
    Field_Type inverseField = fields[FIELD_x].linkedField;

    if(inverseField != FIELD_NULL)
    {
        int32_t value = fields[FIELD_x].param->currentValue;

        // приводим текущее значение к минимальной единице измерения
        switch(fields[FIELD_x].param->currentUnit)
        {
            case 1: value *= 1000; break;
            case 2: value *= 1000000; break;
            case 3: value *= 1000000000; break;
            default: break;
        }

        // вычисляем обратное значение тоже в минимальных единицах
        int32_t inverseValue = 1000000000 / value;

        // выбираем оптимальную ед. изм.
        uint8_t i = 4;
        uint8_t u = 0;
        int32_t d = 1000000000;
        while(i != 0)
        {
            if((fields[inverseField].param->unit[i-1]) &&
               (FIELD_GetDigitMax(inverseValue/d) > 1))
            {
                u = i-1;
                i = 1;
            }
            d /= 1000;
            i--;
        }

        // приводим к полученной единице измерения
        fields[inverseField].param->currentUnit = u;

        switch(fields[inverseField].param->currentUnit)
        {
            case 1: inverseValue /= 1000; break;
            case 2: inverseValue /= 1000000; break;
            case 3: inverseValue /= 1000000000; break;
            default: break;
        }

        // присваиваем значение инверсному полю
        fields[inverseField].param->currentValue = inverseValue;

        // отрисовка здесь не вызывается, т.к. иногда ее вызывает поле, а иногда экран
    }
}

Продолжим рассматривать обработчик событий. Следующий блок – блок обработки кнопок вправо-влево. Он достаточно простой и все должно быть понятно из комментариев. Функция FIELD_GetDigitMax() возвращает разряд числа с учетом знака, начиная от 0, например для -1000 она вернет 3. Тут можно оптимизировать и сделать более логично 4. Но для этого надо скорректировать места, где эта функция фигурирует. Может быть когда-нибудь.

// ОБРАБОТЧИК КНОПОК ВЛЕВО-ВПРАВО============================================================

if(IS_EVENT_LEFT_RIGHT(event))
{
    // нажата кнопка вправо------------------------------------------------------------------
    if(event == EVENT_RIGHT)
    {
        // смещаем черту, если это возможно
        if(fields[FIELD_x].currentDigit > 0)
        {
            fields[FIELD_x].currentDigit--;
            FIELD_DrawUnderLine(FIELD_x);
        }
    }

    // нажата кнопка влево------------------------------------------------------------------
    else
    {
        // смещаем черту, если это возможно
        if(fields[FIELD_x].currentDigit < FIELD_GetDigitMax(fields[FIELD_x].param->currentValue))
        {
            fields[FIELD_x].currentDigit++;
            FIELD_DrawUnderLine(FIELD_x);
        }
    }
}

Ну и, наконец, последний блок – блок обработки событий цифровой клавиатуры.

// ОБРАБОТЧИК ЦИФРОВОЙ КЛАВИАТУРЫ============================================================

// вызывается только для числового поля
else if((IS_EVENT_KEYBOARD(event)) && (!fields[FIELD_x].param->strArray))
{
    int32_t num = -1;

    // если ввод данных только начинается
    if(fields[FIELD_x].inputing == INPUT_NO)
    {
        // ставим флаг
        fields[FIELD_x].inputing = INPUT_START;
    }


    // определяем нажатую кнопку
    switch (event)
    {
        case EVENT_KEY_0: num = 0; break;
        case EVENT_KEY_1: num = 1; break;
        case EVENT_KEY_2: num = 2; break;
        case EVENT_KEY_3: num = 3; break;
        case EVENT_KEY_4: num = 4; break;
        case EVENT_KEY_5: num = 5; break;
        case EVENT_KEY_6: num = 6; break;
        case EVENT_KEY_7: num = 7; break;
        case EVENT_KEY_8: num = 8; break;
        case EVENT_KEY_9: num = 9; break;
        case EVENT_KEY_DEL:   fields[FIELD_x].inputValue /= 10; break;
        case EVENT_KEY_MINUS: fields[FIELD_x].inputValue *= -1; break;
        default: break;
    }

    // если была цифра, добавляем ее в число, учитывая разряд
    if((FIELD_GetDigitMax(fields[FIELD_x].inputValue) < 6) && (num != -1))
    {
        fields[FIELD_x].inputValue *= 10;

        if(fields[FIELD_x].inputValue < 0)
            fields[FIELD_x].inputValue -= num;
        else
            fields[FIELD_x].inputValue += num;
    }

    // рисуем введенное значение
    FIELD_DrawValue(FIELD_x, COLOR_SELECTED_ITEMS, fields[FIELD_x].inputValue);
}

Тоже вопросов быть не должно. Да, в моей реализации есть несколько недостатков. Например вобще не делается проверок на корректные значения. Также не блокируется энкодер при вводе с клавиатуры. Исправлю в следующих версиях, которые буду выкладывать в первом посте форума.
В этих двух статьях я хотел показать один из принципов построения интерфейсов небольшой сложности на примере моего генератора. Ничего нового тут, конечно, нет. Но если кто-то подчерпнет для себя что-то полезное, значит не зря я тут все подробно расписывал.
  • +2
  • 04 декабря 2015, 20:40
  • sva_omsk
  • 1
Файлы в топике: jff-1000_face.zip

Комментарии (15)

RSS свернуть / развернуть
Мне показалось, или куда-то исчез?
0
Сначала ссылку неправильную вставил. Но через минуту поправил. Как сейчас?
0
К сожалению, нет. В новых постах по-прежнему виднеется простыня.
0
блин, кат не поставил. момент… Кстати видео работает?
0
Видео вообще без проблем)
0
Уже действительно хорошо. Мне нравится. Работа довольно кропотливая и результат впечатляет.
Хотел было задать вопрос по поводу мерцания экрана, но потом решил дождаться продолжения.
0
Мерцать может из-за биений частоты обновления дисплея с частотой кадров видео
0
Я думаю вопрос был именно про тормозную отрисовку. В первой части я действительно по каждому чиху перерисовывал значительные области. Потребовалось переписать почти весь код, чтобы получить более-менее приемлемый результат.
+1
1 — Самый лучший способ создания меню — MicroMenu. Чтобы разобраться, что это такое, прочитайте статью на главном сайте. Но проект нужно писать на основе оригинала из статьи. Почему? Меню это самый настоящий конечный автомат. Каждый уровень меню — состояние КА. За пример возьму веб-страницу. Ссылки. И MicroMenu это некая реализация веб-страницы. Пробег по ссылкам.
2 — Модульность программы. Модуль меню. Модуль аппаратной части дисплея. И модуль вывода информации на дисплей. Здесь за пример возьму панель оператора. Графическая панель оператора — полностью самостоятельный модуль со своей операционной системой. ПЛК полностью самостоятельный модуль. И для ПЛК пишется свой проект. И для панели оператора также пишется свой проект. В зависимости от состояния (текуший уровень) панель оператора отправляет соответствующий запрос ПЛК, тот в ответ отправляет значения переменных. Именно такой подход я взял на вооружение и использую в своих проектах. Только панель оператора и ПЛК — аппаратные модули. А у меня программные.
Если вы в своем проекте видите, что МК тяжеловато вывозить графический дисплей, то решение — «панель оператора», «ПЛК».
Плюсы такого решения очевидны. Модули абсолютно самостоятельны. Нет взаимных связей между модулями и путаницы в программе.
+1
Если интересно, могу дать тестовый проект-пример моей реализации.
0
Не интересно. Я знаю, что такое микроменю, использовал уже. В данном случае применять его считаю нецелесообразно. Спорить не хочу, это мое мнение.
Программа и так модульная, но модули несколько другие. И это не говорит о том, что это неправильно. Главное, чтобы было понятно и отслеживалась логика программы. А этого, как мне кажется, я добился.
+2
А генераторная часть уже работает?
0
Принимает и обрабатывает только 2 команды: BIAS, GAIN. Это прямое управление цифровыми потенциометрами. Исправлю наиболее серьезные недостатки интерфейса и потом буду ей заниматься.
0
Грамотный и удобный интерфейс в брендовой метрологической технике — это, конечно, хорошо, но ценят ее не за это…
0
Мой генератор — это всего лишь любительская поделка, я не питаю никаких иллюзий по отношению к нему. О метрологии говорить еще слишком рано. И даже несмотря на то, что удалось получить модулированный сигнал частотой больше 1 МГц (фото на форуме есть), я не уверен, смогу ли этим управлять. Есть много проблем, включая джжиттер.
0
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.