Skip to main content

11. Управление 2

В этой и предыдущей главах мы создадим элементы управления для ручного добавления точек с помощью полей ввода.

w

Пример готового проекта лежит здесь. Для выполнения текущего задания Fork от него делать запрещается.

В ходе работы вам будет нужно сделать несколько коммитов с промежуточными состояниями проекта, поэтому в прямом копировании нет смысла. Готовый проект даётся для того, чтобы было проще разобраться, как выполнить поставленное задание.

Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте Fork готового проекта из предыдущей главы.

Поле ввода

Напишем теперь интерфейс для добавления точки по двум координатам.

Для этого нам понадобится два поля ввода для X и Y координат и кнопка добавления.

Для начала напишем класс поля ввода:

package controls;

import io.github.humbleui.jwm.*;
import io.github.humbleui.skija.*;
import misc.CoordinateSystem2i;
import panels.GridPanel;

import static app.Fonts.FONT18;

/**
* Поле ввода
*/
public class Input extends GridPanel {
/**
* Размер поля ввода
*/
private static final int INPUT_SIZE = 40;
/**
* Текст
*/
String text;
/**
* Смещение для красивого отображения текста
*/
private static final int LOCAL_PADDING = 8;
/**
* Флаг, нужно ли выравнивать текст по центру по вертикали
*/
protected boolean vcentered;
/**
* Цвет текста
*/
private final int textColor;


/**
* Панель на сетке
*
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param backgroundColor цвет подложки
* @param padding отступы
* @param gridWidth кол-во ячеек сетки по ширине
* @param gridHeight кол-во ячеек сетки по высоте
* @param gridX координата в сетке x
* @param gridY координата в сетке y
* @param colspan кол-во колонок, занимаемых панелью
* @param rowspan кол-во строк, занимаемых панелью
* @param text начальный текст
* @param vcentered флаг, нужно ли выравнивать текст по центру по вертикали
* @param textColor цвет текста
*/
public Input(Window window, boolean drawBG, int backgroundColor, int padding,
int gridWidth, int gridHeight, int gridX, int gridY, int colspan,
int rowspan, String text, boolean vcentered, int textColor) {
super(window, drawBG, backgroundColor, padding, gridWidth, gridHeight,
gridX, gridY, colspan, rowspan);
this.text = text;
this.vcentered = vcentered;
this.textColor = textColor;
}


/**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// создаём кисть
try (var paint = new Paint()) {
// сохраняем область рисования
canvas.save();
// задаём цвет рисования
paint.setColor(backgroundColor);
// создаём метрику фона
FontMetrics metrics = FONT18.getMetrics();
// если нужно выровнять по высоте
if (vcentered) {
canvas.translate(0, (windowCS.getSize().y - INPUT_SIZE) / 2.0f);
}
// рисуем скруглённый квадрат
canvas.drawRRect(RRect.makeXYWH(0, 0, windowCS.getSize().x, INPUT_SIZE, 4), paint);
// начальное положение
float y = INPUT_SIZE - LOCAL_PADDING - metrics.getDescent();
// создаём строку для рисования
try (TextLine line = TextLine.make(text, FONT18)) {
// смещаем область рисования
canvas.translate(LOCAL_PADDING, y);
// задаём цвет текста
paint.setColor(textColor);
// рисуем линию текста
canvas.drawTextLine(line, 0, 0, paint);
}
// восстанавливаем область рисования
canvas.restore();

}
}

/**
* Обработчик событий
* При перегрузке обязателен вызов реализации предка
*
* @param e событие
*/
@Override
public void accept(Event e) {
// вызываем обработчик предка
super.accept(e);
}

/**
* Получить вещественное значение из поля ввода
*
* @return возвращает значение, если всё ок, в противном случае вернёт 0
*/
public double doubleValue() {
try {
// для правильной конвертации, если нужно, заменяем плавающую запятую
// на плавающую точку
return Double.parseDouble(text.replace(",", "."));
} catch (NumberFormatException e) {
System.out.println("ошибка преобразования");
}
return 0;
}

/**
* Проверяет, лежит ли в поле ввода правильное вещественное число
*
* @return флаг
*/
public boolean hasValidDoubleValue() {
try {
// для правильной конвертации, если нужно, заменяем плавающую запятую
// на плавающую точку
Double.parseDouble(text.replace(",", "."));
return true;
} catch (NumberFormatException e) {
return false;
}
}

/**
* Получить вещественное значение из поля ввода
*
* @return возвращает значение, если всё ок, в противном случае вернёт 0
*/
public int intValue() {
try {
// для правильной конвертации, если нужно, заменяем плавающую запятую
// на плавающую точку
return Integer.parseInt(text);
} catch (NumberFormatException e) {
System.out.println("ошибка преобразования");
}
return 0;
}

/**
* Проверяет, лежит ли в поле ввода правильное вещественное число
*
* @return флаг
*/
public boolean hasValidIntValue() {
try {
// для правильной конвертации, если нужно, заменяем плавающую запятую
// на плавающую точку
Integer.parseInt(text);
return true;
} catch (NumberFormatException e) {
return false;
}
}

/**
* Задать текст
*
* @param text текст
*/
public void setText(String text) {
this.text = text;
}

/**
* Возвращает текст из поля вввода
*
* @return текст
*/
public String getText() {
return text;
}
}

В дальнейшем нам понадобится переключать фокус с одного поля ввода на другое, поэтому мы сразу напишем фабрику полей ввода, а сами поля будем создавать не с помощью конктруктора, а с помощью фабричного метода Input input = InputFactory.getInput()

Фабрику полей ввода тоже добавим в пакет controls

package controls;

import io.github.humbleui.jwm.Window;

import java.util.ArrayList;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;

/**
* Фабрика полей ввода
*/
public class InputFactory {
/**
* Поля ввода
*/
private static final List<Input> inputs = new ArrayList<>();
/**
* Таймер
*/
private static final Timer timer = new Timer(true);
/**
* флаг, нужно ли рисовать курсоа
*/
private static boolean cursorDraw = true;

static {
// запускаем таймер, срабатывающий каждые 500 мс
// он попеременно включает и выключает рисование курсора
// для имитации мигания
timer.schedule(new TimerTask() {
public void run() {
cursorDraw = !cursorDraw;
}
}, 0, 500);
}

/**
* Получить новое поле ввода
*
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param backgroundColor цвет подложки
* @param padding отступы
* @param gridWidth кол-во ячеек сетки по ширине
* @param gridHeight кол-во ячеек сетки по высоте
* @param gridX координата в сетке x
* @param gridY координата в сетке y
* @param colspan кол-во колонок, занимаемых панелью
* @param rowspan кол-во строк, занимаемых панелью
* @param text начальный текст
* @param vcentered флаг, нужно ли выравнивать текст по центру по вертикали
* @param textColor цвет текста
* @return Новое поле ввода
*/
public static Input getInput(
Window window, boolean drawBG, int backgroundColor, int padding,
int gridWidth, int gridHeight, int gridX, int gridY, int colspan,
int rowspan, String text, boolean vcentered, int textColor
) {
Input input = new Input(
window, drawBG, backgroundColor, padding, gridWidth, gridHeight,
gridX, gridY, colspan, rowspan, text, vcentered, textColor);
inputs.add(input);

return input;
}

/**
* Нужно ли рисовать курсор сейчас
*
* @return нужно ли рисовать курсор
*/
public static boolean cursorDraw() {
return cursorDraw;
}

/**
* Запрещаем вызов конструктора
*/
private InputFactory() {
throw new AssertionError("Вызов этого конструктора запрещён!");
}

}

В этой фабрике мы сразу же создадим список всех созданных полей ввода и при каждом вызове getInput() будем добавлять его в этот список.

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

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

Команда передаётся с помощью создания анонимного класса, наследующего TimerTask. Этот класс в свою очередь реализует интерфейс Runnable. По сути нам нужно просто реализовать метод run().

Мы сразу же напишем тело этого метода. Внутри просто меняется флаг, нужно ли рисовать курсор. Нам это скоро пригодится.

Добавим в класс Colors константы цвета текста поля ввода и его подложки

    /**
* Цвет подложки поля ввода
*/
public static final int FIELD_BACKGROUND_COLOR = Misc.getColor(255, 255, 255, 255);
/**
* Цвет текста
*/
public static final int FIELD_TEXT_COLOR = Misc.getColor(255, 0, 0, 0);

Теперь вернёмся к классу панели PanelControl управления и добавим два поля

    /**
* заголовок для поля ввода x координаты
*/
Label xLabel;
/**
* поле ввода x координаты
*/
Input xField;

Инициализируем их в конструкторе:

        // добавление вручную
xLabel = new Label(window, false, backgroundColor, PANEL_PADDING,
6, 7, 0, 2, 1, 1, "X", true, true);

xField = InputFactory.getInput(window, false, FIELD_BACKGROUND_COLOR, PANEL_PADDING,
6, 7, 1, 2, 2, 1, "0.0", true,
FIELD_TEXT_COLOR);

и добавим их рисование в методе paintImpl()

    /**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
task.paint(canvas, windowCS);
xLabel.paint(canvas, windowCS);
xField.paint(canvas, windowCS);
}

Также сразу же перебросим обработчик поля ввода xField в классе PanelControl

    /**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// вызываем обработчик предка
super.accept(e);
// передаём обработку полю ввода X
xField.accept(e);
}

Запустим программу

w

Поле ввода отображается корректно, но мы всё ещё не можем менять в нём значения.

Добавим сначала отображение курсора. Для этого в рисовании поля ввода добавим строчки:

                ...
// если время рисовать курсор
if (InputFactory.cursorDraw()) {
// смещаем область рисования
canvas.translate(line.getWidth(), 0);
// рисуем его
canvas.drawRect(Rect.makeXYWH(0, metrics.getAscent(), 2, metrics.getHeight()), paint);
}
...

В метод paintImpl() класса Input

    /**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// создаём кисть
try (var paint = new Paint()) {
// сохраняем область рисования
canvas.save();
// задаём цвет рисования
paint.setColor(backgroundColor);
// создаём метрику фона
FontMetrics metrics = FONT18.getMetrics();
// если нужно выровнять по высоте
if (vcentered) {
canvas.translate(0, (windowCS.getSize().y - INPUT_SIZE) / 2.0f);
}
// рисуем скруглённый квадрат
canvas.drawRRect(RRect.makeXYWH(0, 0, windowCS.getSize().x, INPUT_SIZE, 4), paint);
// начальное положение
float y = INPUT_SIZE - LOCAL_PADDING - metrics.getDescent();
// создаём строку для рисования
try (TextLine line = TextLine.make(text, FONT18)) {
// смещаем область рисования
canvas.translate(LOCAL_PADDING, y);
// задаём цвет текста
paint.setColor(textColor);
// рисуем линию текста
canvas.drawTextLine(line, 0, 0, paint);
// если время рисовать курсор
if (InputFactory.cursorDraw()) {
// смещаем область рисования
canvas.translate(line.getWidth(), 0);
// рисуем его
canvas.drawRect(Rect.makeXYWH(0, metrics.getAscent(), 2, metrics.getHeight()), paint);
}
}
// восстанавливаем область рисования
canvas.restore();

}
}

Если запустить программу, то курсор не мигает. Однако, если мы начнём добавлять точки, то курсор начнёт время от времени мигать.

Это происходит из-за того, что мы запускаем перерисовку кадра только когда выполняется клик мышью.

Чтобы постоянно перерисовывать кадр, достаточно добавить обработку события EventFrame в классе приложения Application и в его теле просто запускать перерисовку окна.

   /**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// если событие - это закрытие окна
if (e instanceof EventWindowClose) {
// завершаем работу приложения
App.terminate();
} else if (e instanceof EventWindowCloseRequest) {
window.close();
} else if (e instanceof EventFrame) {
// запускаем рисование кадра
window.requestFrame();
} else if (e instanceof EventFrameSkija ee) {
// получаем поверхность рисования
Surface s = ee.getSurface();
// очищаем её канвас заданным цветом
paint(s.getCanvas(), new CoordinateSystem2i(s.getWidth(), s.getHeight()));
}
panelControl.accept(e);
panelRendering.accept(e);
panelLog.accept(e);
}

Курсор заработал.

о FPS

У движка есть ограничение по частоте отрисовки кадров. Чаще, чем 60 FPS окно перерисовываться не будет, потому что большинство экранов не поддерживают частоту перерисовки выше. FPS расшифровывается как Frame Per Second, т.е. кол-во кадров в секунду.

Теперь нам не нужно перерисовывать кадр по клику мыши, поэтому удалим команду перерисовки из обработчика PanelRendering. Теперь accept() будет выглядеть так:

    /**
* Обработчик событий
* при перегрузке обязателен вызов реализации предка
*
* @param e событие
*/
@Override
public void accept(Event e) {
// вызываем обработчик предка
super.accept(e);
if (e instanceof EventMouseButton ee) {
// если последнее положение мыши сохранено и курсор был внутри
if (lastMove != null && lastInside) {
// если событие - нажатие мыши
if (ee.isPressed())
// обрабатываем клик по задаче
task.click(lastWindowCS.getRelativePos(lastMove), ee.getButton());
}
}
}

Теперь нам нужно прописать обработку нажатия кнопок.

Для начала добавим константу клавиши модификатора в класс Application. На Win и Unix модификатор - это Ctrl, а на Mac - специальный символ Cmd.

Поэтому в зависимости от операционной системы нам по-разному нужно определять модификатор. Проще всего это сделать с помощью лямбда-выражения. Добавим соответсвующую константу в класс Application:

    /**
* кнопка изменений: у мака - это `Command`, у windows - `Ctrl`
*/
public static final KeyModifier MODIFIER = Platform.CURRENT == Platform.MACOS ? KeyModifier.MAC_COMMAND : KeyModifier.CONTROL;

Ещё нам понадобится флаг, развёрнуто ли окно на весь экран. Его тоже добавим в класс Application

    /**
* флаг того, что окно развёрнуто на весь экран
*/
private boolean maximizedWindow;

Изначально окно рисуется не во весь экран, поле boolean по умолчанию равно false, поэтому мы нигде не инициализируем его дополнительно.

Также добавим сразу прототипы функций загрузки и сохранения в панели рисования PanelRendering. Обработчик команд загрузки и сохранения мы поместим там, потому что объект задачи Task task является её полем.

    /**
* Сохранить файл
*/
public static void save() {
PanelLog.info("save");
}

/**
* Загрузить файл
*/
public static void load() {
PanelLog.info("load");
}

Теперь напишем сам обработчик в классе Application:

    /**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// если событие - это закрытие окна
if (e instanceof EventWindowClose) {
// завершаем работу приложения
App.terminate();
} else if (e instanceof EventWindowCloseRequest) {
window.close();
} else if (e instanceof EventFrame) {
// запускаем рисование кадра
window.requestFrame();
} else if (e instanceof EventFrameSkija ee) {
// получаем поверхность рисования
Surface s = ee.getSurface();
// очищаем её канвас заданным цветом
paint(s.getCanvas(), new CoordinateSystem2i(s.getWidth(), s.getHeight()));
}// кнопки клавиатуры
else if (e instanceof EventKey eventKey) {
// кнопка нажата с Ctrl
if (eventKey.isPressed()) {
if (eventKey.isModifierDown(MODIFIER))
// разбираем, какую именно кнопку нажали
switch (eventKey.getKey()) {
case W -> window.close();
case H -> window.minimize();
case S -> PanelRendering.save();
case O -> PanelRendering.load();
case DIGIT1 -> {
if (maximizedWindow)
window.restore();
else
window.maximize();
maximizedWindow = !maximizedWindow;
}
case DIGIT2 -> window.setOpacity(window.getOpacity() == 1f ? 0.5f : 1f);
}
else
switch (eventKey.getKey()) {
case ESCAPE -> {
window.close();
// завершаем обработку, иначе уже разрушенный контекст
// будет передан панелям
return;

}
}
}
}
panelControl.accept(e);
panelRendering.accept(e);
panelLog.accept(e);
}

Мы прописали следующие команды:

  • Ctrl+O - Открыть файл
  • Ctrl+S - Сохранить файл
  • Ctrl+H - Свернуть окно
  • Ctrl+1 - Во весь экран/Обычный размер
  • Ctrl+2 - Полупрозрачное окно/обычное
  • Esc - закрыть окно
Обратите внимание

у клавиатуры два обработчика: один - это нажатие клавиш, а второй - это введённый текст.

Теперь пропишем обработчик клавиатуры в классе Input:

    /**
* Обработчик событий
* При перегрузке обязателен вызов реализации предка
*
* @param e событие
*/
@Override
public void accept(Event e) {
// вызываем обработчик предка
super.accept(e);
// если вводится текст
if (e instanceof EventTextInput ee) {
text += ee.getText()
.replace((char) 9 + "", "") // Tab
.replace((char) 27 + "", ""); // Esc
window.requestFrame();
// если нажимается клавиша клавиатуры(нужно для управляющих команд)
} else if (e instanceof EventKey ee) {
if (ee.isPressed()) {
// получаем код клавиши
Key key = ee.getKey();
// перебираем варианты
switch (key) {
// если бэкспейс
case BACKSPACE -> {
// если текст непустой
if (!text.isEmpty())
// удаляем из него 1 символ
text = text.substring(0, text.length() - 1);

}
// если esc
case ESCAPE -> {
}
}
}
}
}

Теперь поле ввода должно заработать

Создайте новый коммит inputs work и отправьте его на сервер.

Выбор мышью

Но что делать, если нам нужно несколько полей ввода? Нам нужно знать, в какое именно поле ввода мы отправляем данные от клавиатуры. Для этого классу Input добавим поле-флаг, выделено ли сейчас это поле ввода или нет.

    /**
* флаг, помещён ли сейчас фокус на это поле ввода
* (модификатор доступа по умолчанию, чтобы был доступен
* фабрике InputFactory внутри пакета)
*/
boolean focused = false;

Напишем теперь в фабрике полей ввода InputFactory метод для снятия фокуса со всех отслеживаемых объектов

   /**
* Снять фокус со всех полей ввода
*/
public static void defocusAll() {
// снимаем фокусы
for (Input input : inputs)
input.focused = false;
}

Также напишем метод перевода фокуса на текущее поле ввода в классе Input:

    /**
* Установить фокус на это поле ввода
*/
public void setFocus() {
// снимаем фокус со всех полей ввода
InputFactory.defocusAll();
// выделяем текущее поле ввода
this.focused = true;
}

Ещё нам понадобится метод проверки, выделено ли сейчас рассматриваемое поле ввода

    /**
* Возвращает флаг, установлен ли фокус на это поле ввода
*
* @return флаг
*/
public boolean isFocused() {
return focused;
}

Теперь рисование курсора будет выглядеть так

    /**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// создаём кисть
try (var paint = new Paint()) {
// сохраняем область рисования
canvas.save();
// задаём цвет рисования
paint.setColor(backgroundColor);
// создаём метрику фона
FontMetrics metrics = FONT18.getMetrics();
// если нужно выровнять по высоте
if (vcentered) {
canvas.translate(0, (windowCS.getSize().y - INPUT_SIZE) / 2.0f);
}
// рисуем скруглённый квадрат
canvas.drawRRect(RRect.makeXYWH(0, 0, windowCS.getSize().x, INPUT_SIZE, 4), paint);
// начальное положение
float y = INPUT_SIZE - LOCAL_PADDING - metrics.getDescent();
// создаём строку для рисования
try (TextLine line = TextLine.make(text, FONT18)) {
// смещаем область рисования
canvas.translate(LOCAL_PADDING, y);
// задаём цвет текста
paint.setColor(textColor);
// рисуем линию текста
canvas.drawTextLine(line, 0, 0, paint);
// если время рисовать курсор
if (focused && InputFactory.cursorDraw()) {
// смещаем область рисования
canvas.translate(line.getWidth(), 0);
// рисуем его
canvas.drawRect(Rect.makeXYWH(0, metrics.getAscent(), 2, metrics.getHeight()), paint);
}
}
// восстанавливаем область рисования
canvas.restore();

}
}

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

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

Перепишем класс панели управления согласно новой логике:

package panels;

import app.Task;
import java.util.ArrayList;
import controls.Input;
import controls.InputFactory;
import controls.Label;
import controls.MultiLineLabel;
import io.github.humbleui.jwm.*;
import io.github.humbleui.skija.Canvas;
import misc.CoordinateSystem2i;
import misc.Vector2i;

import java.util.List;

import static app.Application.PANEL_PADDING;
import static app.Colors.FIELD_BACKGROUND_COLOR;
import static app.Colors.FIELD_TEXT_COLOR;

/**
* Панель управления
*/
public class PanelControl extends GridPanel {
/**
* Текст задания
*/
MultiLineLabel task;
/**
* Заголовки
*/
public List<Label> labels;
/**
* Поля ввода
*/
public List<Input> inputs;

/**
* Панель управления
*
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param color цвет подложки
* @param padding отступы
* @param gridWidth кол-во ячеек сетки по ширине
* @param gridHeight кол-во ячеек сетки по высоте
* @param gridX координата в сетке x
* @param gridY координата в сетке y
* @param colspan кол-во колонок, занимаемых панелью
* @param rowspan кол-во строк, занимаемых панелью
*/
public PanelControl(
Window window, boolean drawBG, int color, int padding, int gridWidth, int gridHeight,
int gridX, int gridY, int colspan, int rowspan
) {
super(window, drawBG, color, padding, gridWidth, gridHeight, gridX, gridY, colspan, rowspan);

// создаём списки
inputs = new ArrayList<>();
labels = new ArrayList<>();

// задание
task = new MultiLineLabel(
window, false, backgroundColor, PANEL_PADDING,
6, 7, 0, 0, 6, 2, Task.TASK_TEXT,
false, true);
// добавление вручную
Label xLabel = new Label(window, false, backgroundColor, PANEL_PADDING,
6, 7, 0, 2, 1, 1, "X", true, true);
labels.add(xLabel);
Input xField = InputFactory.getInput(window, false, FIELD_BACKGROUND_COLOR, PANEL_PADDING,
6, 7, 1, 2, 2, 1, "0.0", true,
FIELD_TEXT_COLOR);
inputs.add(xField);
Label yLabel = new Label(window, false, backgroundColor, PANEL_PADDING,
6, 7, 3, 2, 1, 1, "Y", true, true);
labels.add(yLabel);
Input yField = InputFactory.getInput(window, false, FIELD_BACKGROUND_COLOR, PANEL_PADDING,
6, 7, 4, 2, 2, 1, "0.0", true,
FIELD_TEXT_COLOR);
inputs.add(yField);
}

/**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// вызываем обработчик предка
super.accept(e);
// событие движения мыши
if (e instanceof EventMouseMove ee) {
for (Input input : inputs)
input.accept(ee);

// событие нажатия мыши
} else if (e instanceof EventMouseButton ee) {
if (!lastInside || !ee.isPressed())
return;

Vector2i relPos = lastWindowCS.getRelativePos(lastMove);

// перебираем поля ввода
for (Input input : inputs) {
// если клик внутри этого поля
if (input.contains(relPos)) {
// переводим фокус на это поле ввода
input.setFocus();
}
}
// перерисовываем окно
window.requestFrame();
// обработчик ввода текста
} else if (e instanceof EventTextInput ee) {
for (Input input : inputs) {
if (input.isFocused()) {
input.accept(ee);
}
}
// перерисовываем окно
window.requestFrame();
// обработчик ввода клавиш
} else if (e instanceof EventKey ee) {
for (Input input : inputs) {
if (input.isFocused()) {
input.accept(ee);
}
}
// перерисовываем окно
window.requestFrame();
}
}

/**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
task.paint(canvas, windowCS);
// выводим поля ввода
for (Input input : inputs) {
input.paint(canvas, windowCS);
}
// выводим поля ввода
for (Label label : labels) {
label.paint(canvas, windowCS);
}
}
}

Теперь мы можем выбирать поле ввода, кликнув по нему мышью

w

Создайте новый коммит input select works и отправьте его на сервер.

Tab группа

Теперь пропишем переключение между полями ввода по кнопке Tab. Элементы, объединённые в группу для переключения называются Tab группой.

Будем исходить из логики, что не все поля ввода будут участвовать в группе переключения.

Поэтому удобнее всего будет хранить индексы всех задействованных в tab группе полей ввода. Также нужно хранить, какой именно элемент группы сейчас выделен.

Для этого добавим два поля в InputFactory

    /**
* группа индексов для переключения по tab
*/
private static final List<Integer> tabGroup = new ArrayList<>();
/**
* положение в tab группе
*/
private static int tabPos = 0;

Перепишем теперь фабричный метод getInput():


/**
* Получить новое поле ввода
*
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param backgroundColor цвет подложки
* @param padding отступы
* @param gridWidth кол-во ячеек сетки по ширине
* @param gridHeight кол-во ячеек сетки по высоте
* @param gridX координата в сетке x
* @param gridY координата в сетке y
* @param colspan кол-во колонок, занимаемых панелью
* @param rowspan кол-во строк, занимаемых панелью
* @param text начальный текст
* @param vcentered флаг, нужно ли выравнивать текст по центру по вертикали
* @param textColor цвет текста
* @param addToTabGroup флаг, нужно ли добавить это поле в tab группу
* @return Новое поле ввода
*/
public static Input getInput(
Window window, boolean drawBG, int backgroundColor, int padding,
int gridWidth, int gridHeight, int gridX, int gridY, int colspan,
int rowspan, String text, boolean vcentered, int textColor,
boolean addToTabGroup
) {
Input input = new Input(
window, drawBG, backgroundColor, padding, gridWidth, gridHeight,
gridX, gridY, colspan, rowspan, text, vcentered, textColor);
inputs.add(input);
if (addToTabGroup) {
tabGroup.add(inputs.size() - 1);
}

// изначально ничего не выбрано, по первому tab
// положение станет равным нулю, и мы получим первый
// элемент tab группы
tabPos = -1;

return input;
}

Также нам понадобится метод перехода к следующему элементу tab группы

    /**
* Следующий элемент
*/
public static void nextTab() {
if (tabGroup.isEmpty())
return;
tabPos++;
if (tabPos > tabGroup.size() - 1)
tabPos = 0;
inputs.get(tabGroup.get(tabPos)).setFocus();
}

Теперь в обработчике кнопок без зажатого модификатора у класса Application добавим обработку Tab

    ...
switch (eventKey.getKey()) {
case ESCAPE -> {
window.close();
// завершаем обработку, иначе уже разрушенный контекст
// будет передан панелям
return;

}
case TAB -> InputFactory.nextTab();
}
...

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

        Input xField = InputFactory.getInput(window, false, FIELD_BACKGROUND_COLOR, PANEL_PADDING,
6, 7, 1, 2, 2, 1, "0.0", true,
FIELD_TEXT_COLOR, true);
inputs.add(xField);
Label yLabel = new Label(window, false, backgroundColor, PANEL_PADDING,
6, 7, 3, 2, 1, 1, "Y", true, true);
labels.add(yLabel);
Input yField = InputFactory.getInput(window, false, FIELD_BACKGROUND_COLOR, PANEL_PADDING,
6, 7, 4, 2, 2, 1, "0.0", true,
FIELD_TEXT_COLOR, true);
inputs.add(yField);

метод accept() в PanelControl должен стать таким:

   /**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// вызываем обработчик предка
super.accept(e);
// событие движения мыши
if (e instanceof EventMouseMove ee) {
for (Input input : inputs)
input.accept(ee);

// событие нажатия мыши
} else if (e instanceof EventMouseButton ee) {
if (!lastInside)
return;

Vector2i relPos = lastWindowCS.getRelativePos(lastMove);

// перебираем поля ввода
for (Input input : inputs) {
// если клик внутри этого поля
if (input.contains(relPos)) {
// переводим фокус на это поле ввода
input.setFocus();
}
}
// перерисовываем окно
window.requestFrame();
// обработчик ввода текста
} else if (e instanceof EventTextInput ee) {
for (Input input : inputs) {
if (input.isFocused()) {
input.accept(ee);
}
}
// перерисовываем окно
window.requestFrame();
// обработчик ввода клавиш
} else if (e instanceof EventKey ee) {
for (Input input : inputs) {
if (input.isFocused()) {
input.accept(ee);
}
}
// перерисовываем окно
window.requestFrame();
}
}

Переключение между полями по Tab теперь должно заработать

Создайте новый коммит input tab works и отправьте его на сервер.

Кнопки

Напишем теперь класс кнопок. Самое простое - это унаследоваться от многостраничного текстового блока, в классе-наследнике останется только добавить заливку.

Да, в классе Panel уже есть возможность добавить подложку. Но эта подложка закрашивает всю панель. Для кнопки нужно формировать новую подложку.

Для начала добавим цвет подложки в Colors:

    /**
* Цвет кнопки
*/
public static final int BUTTON_COLOR = Misc.getColor(80, 0, 0, 0);

Теперь в пакете controls создадим класс кнопки Button:

package controls;

import controls.MultiLineLabel;
import io.github.humbleui.jwm.Window;
import io.github.humbleui.skija.Canvas;
import io.github.humbleui.skija.Paint;
import io.github.humbleui.skija.RRect;
import io.github.humbleui.types.IRect;
import misc.CoordinateSystem2i;
import misc.Vector2i;

import static app.Colors.BUTTON_COLOR;

/**
* Класс кнопки
*/
public class Button extends MultiLineLabel {
/**
* Событие по нажатию
*/
private Runnable onClick;
/**
* находится ли сейчас курсор над этой кнопкой
*/
public boolean selected;

/**
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param backgroundColor цвет подложки
* @param padding отступы
* @param gridWidth кол-во ячеек сетки по ширине
* @param gridHeight кол-во ячеек сетки по высоте
* @param gridX координата в сетке x
* @param gridY координата в сетке y
* @param colspan кол-во колонок, занимаемых панелью
* @param rowspan кол-во строк, занимаемых панелью
* @param text текст
* @param centered флаг, нужно ли выравнивать текст по центру по горизонтали
* @param vcentered флаг, нужно ли выравнивать текст по центру по вертикали
*/
public Button(
Window window, boolean drawBG, int backgroundColor, int padding,
int gridWidth, int gridHeight, int gridX, int gridY, int colspan,
int rowspan, String text, boolean centered, boolean vcentered) {
super(
window, drawBG, backgroundColor, padding, gridWidth, gridHeight,
gridX, gridY, colspan, rowspan, text, centered, vcentered
);
selected = false;
}

/**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// сохраняем канвасы
canvas.save();
if (selected) {
if (centered)
canvas.translate((windowCS.getSize().x - lastTextWidth) / 2.0f + padding, 0);
if (vcentered)
canvas.translate(0, (windowCS.getSize().y - lastTextHeight) / 2.0f);

try (Paint bg = new Paint().setColor(BUTTON_COLOR)) {
var bounds = IRect.makeXYWH(0, 0, lastTextWidth, lastTextHeight);
canvas.drawRRect(RRect.makeLTRB(bounds.getLeft(), bounds.getTop(), bounds.getRight(), bounds.getBottom(), 4), bg);
}
}
// восстанавливаем канвасы
canvas.restore();
super.paintImpl(canvas, windowCS);
}

/**
* Обработчик клика по кнопке
*
* @param pos положение курсора мыши
*/
public void click(Vector2i pos) {
if (onClick != null && contains(pos)) onClick.run();
}

/**
* Задать обработчик нажатия
*
* @param onClick обработчик
*/
public void setOnClick(Runnable onClick) {
this.onClick = onClick;
}

/**
* Проверить, что мышь над кнопкой
*
* @param pos положение курсора мыши
*/
public void checkOver(Vector2i pos) {
selected = contains(pos);
}
}

Помимо переписанной реализации рисования в класс добавлены служебные методы для работы с мышью.

Интерес представляет только метод setOnClick(Runnable onClick). Он принимает в качестве аргумента анонимный класс интерфейса Runnable и сохраняет его как поле. Потом при каждом клике по кнопке вызывается метод run() у объекта runnable.

Теперь в панели управления PanelControl добавим список кнопок

    /**
* Кнопки
*/
public List<Button> buttons;

В конструкторе создадим инициализируем список кнопок

        ...
buttons = new ArrayList<>();
...

Теперь создадим две кнопки: одна будет добавлять точку в первое множество, другая - во второе

        ...
Button addToFirstSet = new Button(
window, false, backgroundColor, PANEL_PADDING,
6, 7, 0, 3, 3, 1, "Добавить в первое\nмножество",
true, true);
addToFirstSet.setOnClick(() -> {
// если числа введены верно
if (!xField.hasValidDoubleValue()) {
PanelLog.warning("X координата введена неверно");
} else if (!yField.hasValidDoubleValue())
PanelLog.warning("Y координата введена неверно");
else
PanelRendering.task.addPoint(
new Vector2d(xField.doubleValue(), yField.doubleValue()), Point.PointSet.FIRST_SET
);
});
buttons.add(addToFirstSet);

Button addToSecondSet = new Button(
window, false, backgroundColor, PANEL_PADDING,
6, 7, 3, 3, 3, 1, "Добавить во второе\nмножество",
true, true);
addToSecondSet.setOnClick(() -> {
// если числа введены верно
if (!xField.hasValidDoubleValue()) {
PanelLog.warning("X координата введена неверно");
} else if (!yField.hasValidDoubleValue())
PanelLog.warning("Y координата введена неверно");
else {
PanelRendering.task.addPoint(
new Vector2d(xField.doubleValue(), yField.doubleValue()), Point.PointSet.SECOND_SET
);
}
});
buttons.add(addToSecondSet);
....

Теперь нам нужно добавить обработку наших кнопок

Изменим обработчики движения и нажатия мыши класса PanelControl:

    /**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// вызываем обработчик предка
super.accept(e);
// событие движения мыши
if (e instanceof EventMouseMove ee) {
for (Input input : inputs)
input.accept(ee);

for (Button button : buttons) {
if (lastWindowCS != null)
button.checkOver(lastWindowCS.getRelativePos(new Vector2i(ee)));
}
// событие нажатия мыши
} else if (e instanceof EventMouseButton ee) {
if (!lastInside || !ee.isPressed())
return;

Vector2i relPos = lastWindowCS.getRelativePos(lastMove);

// пробуем кликнуть по всем кнопкам
for (Button button : buttons) {
button.click(relPos);
}
// перебираем поля ввода
for (Input input : inputs) {
// если клик внутри этого поля
if (input.contains(relPos)) {
// переводим фокус на это поле ввода
input.setFocus();
}
}
// перерисовываем окно
window.requestFrame();
// обработчик ввода текста
}

Теперь нам осталось только добавить рисование кнопок. Теперь метод paintImpl() у панели управления PanelControl будет выглядеть так:

   /**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// выводим текст задачи
task.paint(canvas, windowCS);

// выводим кнопки
for (Button button : buttons) {
button.paint(canvas, windowCS);
}
// выводим поля ввода
for (Input input : inputs) {
input.paint(canvas, windowCS);
}
// выводим поля ввода
for (Label label : labels) {
label.paint(canvas, windowCS);
}
}

Теперь должно заработать добавление точек по кнопкам. Причём, если просто навести курсор на ту или иную кнопку, то она выделится заданным нами цветом

w

Создайте новый коммит buttons work и отправьте его на сервер.

Задание

Пример готового проекта лежит здесь. Для выполнения первого задания Fork от него делать запрещается.

В ходе работы вам будет нужно сделать несколько коммитов с промежуточными состояниями проекта, поэтому в прямом копировании нет смысла. Готовый проект даётся для того, чтобы было проще разобраться, как выполнить поставленное задание.

Если у вас не выполнено предыдущее задание или выполнено не полностью, то сделайте Fork готового проекта из предыдущей главы.

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

  1. inputs work - пропишите поля ввода
  2. input select works - пропишите выбор поля ввод с помощью мыши
  3. input tab work - пропишите переключение между полями ввода с помощью tab
  4. buttons work - пропишите кнопки добавления точек по полям ввода в одно из двух множеств

w

Ссылку на github репозиторий необходимо отправить в поле ввода задания на сайте mdl.

Ссылка на контест