Skip to main content

10. Управление

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

w

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

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

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

Добавление точек

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

Для начала в обработчике событий accept(Event e) класса 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 EventFrameSkija ee) {
// получаем поверхность рисования
Surface s = ee.getSurface();
// очищаем её канвас заданным цветом
paint(s.getCanvas(), new CoordinateSystem2i(s.getWidth(), s.getHeight()));
}
panelControl.accept(e);
panelRendering.accept(e);
panelLog.accept(e);
}

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

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

Также нам будет полезным сохранять последнюю СК окна:

    /**
* последнее движение мыши
*/
protected Vector2i lastMove = new Vector2i(0, 0);
/**
* было ли оно внутри панели
*/
protected boolean lastInside = false;
/**
* последняя СК окна
*/
protected CoordinateSystem2i lastWindowCS;

В конце метода paint() у Panel следует добавить строчку

        ...
// сохраняем СК окна
lastWindowCS = windowCS;
}
...

Чтобы проверять, находится ли курсор внутри панели, нам понадобится метод contatins()

    /**
* Проверка, содержит ли панель координаты
*
* @param pos положение
* @return флаг, содержит или нет
*/
public boolean contains(Vector2i pos) {
if (lastWindowCS != null)
return lastWindowCS.checkCoords(pos);
return false;
}

Теперь пропишем обработчик событий в Panel:

    /**
* Обработчик событий
* при перегрузке обязателен вызов реализации предка
*
* @param e событие
*/
@Override
public void accept(Event e) {
if (e instanceof EventMouseMove ee) {
// сохраняем последнее положение мыши
lastMove = new Vector2i(ee);
// сохраняем флаг, был ли курсор внутри панели
lastInside = contains(lastMove);
}
}

Теперь при переопределении обработчика событий в потомках нам нужно будет вызывать метод предка super.accept(e) перед всеми командами.

Пропишем обработчик мыши в классе задачи Task.

Сначала добавим новое поле (как у Panel) в класс Task:

    /**
* последняя СК окна
*/
protected CoordinateSystem2i lastWindowCS;

Т.к. обработчик задействует СК окна, то нам необходимо его сохранять в методе paint(), поэтому добавим в него строку

        // Сохраняем последнюю СК
lastWindowCS = windowCS;

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

    /**
* Клик мыши по пространству задачи
*
* @param pos положение мыши
* @param mouseButton кнопка мыши
*/
public void click(Vector2i pos, MouseButton mouseButton) {
if (lastWindowCS == null) return;
// получаем положение на экране
Vector2d taskPos = ownCS.getCoords(pos, lastWindowCS);
// выводим положение курсора на консоль
System.out.println("click " + taskPos);
}

Теперь перейдём в класс панели рисования PanelRendering и пропишем для неё обработчик событий

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

В нём мы просто, если всё хорошо, передаём данные о том, куда кликнули и какой кнопкой обработчику клика, прописанному в классе Task.

Запустите программу и немного покликайте по панели рисования.

У меня на консоль вывелось:

click (9.69, -5.35)
click (5.76, -6.52)
click (5.49, -6.52)
click (-0.71, -7.77)
click (-0.71, -7.77)
click (7.11, -2.62)
click (8.07, -0.71)
click (8.96, 1.67)
click (7.15, 4.18)
click (6.80, 4.47)
click (6.84, 4.43)

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

Добавим проверку, что во время получения события клавиша мыши нажата:

    /**
* Обработчик событий
* при перегрузке обязателен вызов реализации предка
*
* @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());
// перерисовываем окно
window.requestFrame();
}
}
}

Если теперь вы снова запустите приложение, то увидите, что клики теперь выполняются по одному разу. У меня на консоль было выведено:

click (1.41, 2.80)
click (-9.42, 1.81)
click (-7.65, -3.51)
click (0.87, -1.91)
click (1.98, 9.72)
click (-3.37, 9.72)
click (-5.45, -4.15)

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

    /**
* Добавить точку
*
* @param pos положение
* @param pointSet множество
*/
public void addPoint(Vector2d pos, Point.PointSet pointSet) {
Point newPoint = new Point(pos, pointSet);
points.add(newPoint);
}

Теперь перепишем обработку нажатия мыши в нём же

    /**
* Клик мыши по пространству задачи
*
* @param pos положение мыши
* @param mouseButton кнопка мыши
*/
public void click(Vector2i pos, MouseButton mouseButton) {
if (lastWindowCS == null) return;
// получаем положение на экране
Vector2d taskPos = ownCS.getCoords(pos, lastWindowCS);
// если левая кнопка мыши, добавляем в первое множество
if (mouseButton.equals(MouseButton.PRIMARY)) {
addPoint(taskPos, Point.PointSet.FIRST_SET);
// если правая, то во второе
} else if (mouseButton.equals(MouseButton.SECONDARY)) {
addPoint(taskPos, Point.PointSet.SECOND_SET);
}
}

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

w

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

Лог

Лог - это специальная структура, в которую заносят информацию о работе программы. Обычно лог записывается в файл, но для наглядности мы выведем его на экран.

Для начала удалим заголовок из панели лога и добавим в её класс константу LOG_LINES_CNT в классе PanelLog

    /**
* Кол-во строчек лога
*/
private static final int LOG_LINES_CNT = 15;

Лог выводится построчно, поэтому нужно указать, сколько всего строк следует выводить.

Теперь добавим перечисление типов информации лога:

    /**
* Тип записи
*/
enum RecordType {
/**
* Информация
*/
INFO,
/**
* Предупреждение
*/
WARNING,
/**
* Ошибка
*/
ERROR,
/**
* Успех
*/
SUCCESS
}

Также нам пригодится запись, которая соответствует одному сообщению лога.

    /**
* Запись
*/
record Record(RecordType recordType, String text, Date date) {
/**
* Строковое представление объекта
*
* @return строковое представление объекта
*/
@Override
public String toString() {
return new SimpleDateFormat(" HH:mm:ss").format(date) + ": " + text;
}
}

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

Такое объявление эквивалентно старой форме создания записей (не копировать):

   /**
* Запись
*/
class Record{
/**
* Тип
*/
private final RecordType recordType;
/**
* Текст
*/
private final String text;
/**
* Время создания
*/
private final String text;

/**
* Конструктор
*/
Record(RecordType recordType, String text, Date date) {
this.recordType = recordType;
this.text = text;
date.text = date;
}
/**
* Строковое представление объекта
*
* @return строковое представление объекта
*/
@Override
public String toString() {
return new SimpleDateFormat(" HH:mm:ss").format(date) + ": " + text;
}
}

Такие структуры называют неизменяемыми контейнерами.

Сами записи надо где-то хранить, поэтому добавим в класс PanelLog поле logs, в котором будет храниться их список записей

    /**
* Список записей лога
*/
private static final List<Record> logs = new ArrayList<>();

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

    /**
* Добавить в лога
*
* @param recordType тип записи
* @param text текст записи
*/
public static void addToLog(RecordType recordType, String text) {
// перебираем строки, переданные в аргументах
for (String line : text.split("\n")) {
// пока строк больше, чем нужно, удаляем первую
while (logs.size() > LOG_LINES_CNT)
logs.remove(0);
// добавляем новую запись
logs.add(new Record(recordType, line, Calendar.getInstance().getTime()));
}
}

Лог построен на принципе FIFO (First In First Out): первый вошёл, первый вышел.

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

Обратите внимание

Хотя панель лога и является объектом, метод добавления в лог, да и сам список лога являются статичными. Иными словами, нестатический объект панели лога обращается к статическому контексту.

Это сделано для упрощения доступа к логу извне. Если бы мы обращались к объекту панели, нам нужно было бы организовать к нему доступ из любого класса. Гораздо проще дать доступ к статическому методу.

Напишем упрощённые функции для создания записей лога конкретного типа в классе PanelLog:

   /**
* Добавить info запись
*
* @param text текст записи
*/
public static void success(String text) {
addToLog(RecordType.SUCCESS, text);
}

/**
* Добавить info запись
*
* @param text текст записи
*/
public static void info(String text) {
addToLog(RecordType.INFO, text);
}

/**
* Добавить warning запись
*
* @param text текст записи
*/
public static void warning(String text) {
addToLog(RecordType.WARNING, text);
}

/**
* Добавить error запись
*
* @param text текст записи
*/
public static void error(String text) {
addToLog(RecordType.ERROR, text);
}

Пропишем теперь функцию, которая будет возвращать цвет записи по её типу в классе PanelLog

   /**
* Получить цвет строки лога
*
* @param recordType тип записи
* @return цвет строки лога
*/
public static int getColor(RecordType recordType) {
return switch (recordType) {
case INFO -> Misc.getColor(144, 255, 255, 255);
case WARNING -> Misc.getColor(144, 255, 255, 0);
case ERROR -> Misc.getColor(144, 255, 0, 0);
case SUCCESS -> Misc.getColor(144, 0, 255, 0);
};
}

Да, эти цвета можно было вынести в класс Colors, но они используются только в одном месте, да и код с ними нагляднее.

Не забудьте удалить заголовок Label и всё, что с ним связано в PanelLog.

Теперь нам осталось прописать непосредственное рисование paintImpl() панели PanelLog

    /**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// создаём перо
try (Paint paint = new Paint()) {
// получаем метрики шрифта
FontMetrics metrics = FONT12.getMetrics();
// сохраняем область рисования
canvas.save();
// смещаем область рисования
canvas.translate(padding, windowCS.getSize().y - padding - metrics.getDescent());
// перебираем записи лога
for (int i = logs.size() - 1; i >= 0; --i) {
// получаем запись лога
Record log = logs.get(i);
// задаём цвет лога
paint.setColor(getColor(log.recordType));
// выводим строку на экран
canvas.drawString(log.toString(), 0, 0, FONT12, paint);
// смещаем область к следующей линии
canvas.translate(0, -metrics.getCapHeight() - 8);
}
// восстанавливаем область рисования
canvas.restore();
}
}

По сути код очень простой: он рисует строки по заданным записям снизу вверх.

Проверим, как работает наша панель лога. Для этого в метод добавления точки в классе Task в классе задачи добавим строчку добавления записи в лог:

   /**
* Добавить точку
*
* @param pos положение
* @param pointSet множество
*/
public void addPoint(Vector2d pos, Point.PointSet pointSet) {
Point newPoint = new Point(pos, pointSet);
points.add(newPoint);
// Добавляем в лог запись информации
PanelLog.info("точка " + newPoint + " добавлена в " + newPoint.getSetName());
}

Чтобы добавить в лог запись ошибки, теперь можно использовать команду PanelLog.error("Текст ошибки"), а запись предупреждения PanelLog.warning("Текст предупреждения")

Запустим программу. Теперь при каждом добавлении точки у нас выводится соответствующее сообщение в консоль. Но строки не умещаются по ширине.

w

Для решения этой проблемы добавим ещё одну константу панели лога LogPanel

    /**
* Максимальная длина строки лога
*/
private static final int MAX_LOG_LINE_LENGTH = 80;

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

Для этого пропишем в классе Misc метод, формирующий список таких строк по исходной строке и максимальной длине строки в списке после разбиения:

    /**
* Сформировать набор строк не превышающих заданную длину
*
* @param line исходная длинная строка
* @param maxLength максимальная длина строки
* @return список строк
*/
public static List<String> limit(String line, int maxLength) {
// создаём список
List<String> lst = new ArrayList<>();
// если вся строка умещается в огланичение
if (line.length() < maxLength) {
// добавляем её к списку
lst.add(line);
// возвращаем его
return lst;
}
// теперь будем извлекать из строки максимально длинную последовательность слов,
// начиная с первого, такую, чтобы длина полученной строки умещалась
// в заданный диапазон
int spacePos;
do {
// ищем последний индекс пробела в подстроке заданной длины
spacePos = line.substring(0, maxLength).lastIndexOf(' ');
// если индекс пробела найден
if (spacePos > 0) {
// добавляем в список подстроку до этого пробела
lst.add(line.substring(0, spacePos));
// а саму строку обрезаем начиная со следующего после пробела символа
line = line.substring(spacePos + 1);
}
// повторяем пока есть пробел в
} while (spacePos > 0 && line.length() > maxLength);
// добавляем оставшуюся строку к списку
lst.add(line);
// возвращаем его
return lst;
}

Перепишем теперь добавление в лог в PanelLog:

    /**
* Добавить в лога
*
* @param recordType тип записи
* @param text текст записи
*/
public static void addToLog(RecordType recordType, String text) {
for (String line : text.split("\n")) {
for (String limitedLine : Misc.limit(line, MAX_LOG_LINE_LENGTH)) {
while (logs.size() > LOG_LINES_CNT)
logs.remove(0);
logs.add(new Record(recordType, limitedLine, Calendar.getInstance().getTime()));
}
}
}

Мы просто добавили ещё один цикл и теперь перебираем вместо всей строки на подстроки, не превышающие заданной длины.

Снова запустим программу

w

Теперь каждая строка лога умещается внутри панели.

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

Многострочный текст

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

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

    /**
* Цвет текста
*/
public static final int MULTILINE_TEXT_COLOR = Misc.getColor(64, 255, 255, 255);

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

Создайте класс MultiLineLabel в пакете controls:

package controls;

import io.github.humbleui.jwm.Window;
import io.github.humbleui.skija.Canvas;
import io.github.humbleui.skija.Paint;
import io.github.humbleui.skija.TextLine;
import misc.CoordinateSystem2i;
import panels.GridPanel;

import static app.Colors.MULTILINE_TEXT_COLOR;
import static app.Fonts.FONT12;

/**
* Многострочный заголовок
*/
public class MultiLineLabel extends GridPanel {
/**
* Текст
*/
public String text;
/**
* Последняя высота текста
*/
protected int lastTextHeight;
/**
* Последняя ширина текста
*/
protected int lastTextWidth;
/**
* Флаг, нужно ли выравнивать текст по центру по горизонтали
*/
protected boolean centered;
/**
* Флаг, нужно ли выравнивать текст по центру по вертикали
*/
protected boolean vcentered;

/**
* Панель на сетке
*
* @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 MultiLineLabel(
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);
this.text = text;
this.centered = centered;
this.vcentered = vcentered;
}

/**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// сохраняем области рисования
canvas.save();
// высота текста
int capHeight = (int) FONT12.getMetrics().getCapHeight();
// говорим, что первая y координата - это высота текста
int y = capHeight;
// начальное значение для последней сохранённой высоты
lastTextHeight = y;
// начальное значение для последней сохранённой ширины
lastTextWidth = 0;


// перебираем строки текста
for (String lineText : text.split("\n")) {
// создаём линию как объект рисования
try (TextLine line = TextLine.make(lineText, FONT12)) {
// последняя сохранённая ширина будет равна максимальной ширине строки
lastTextWidth = Math.max((int) line.getWidth() + 2 * padding, lastTextWidth);
}
// последняя сохранённая высота равна y-координате
lastTextHeight += 2 * capHeight;
}
// увеличиваем последнюю сохранённую высоту на высоту текста
lastTextHeight += capHeight;

// если нужно центрировать по горизонтали
if (centered)
canvas.translate((windowCS.getSize().x - lastTextWidth) / 2.0f, 0);
if (vcentered)
canvas.translate(0, (windowCS.getSize().y - lastTextHeight) / 2.0f);


try (Paint fg = new Paint().setColor(MULTILINE_TEXT_COLOR)) {
// перебираем строки текста
for (String lineText : text.split("\n")) {
// создаём линию как объект рисования
try (TextLine line = TextLine.make(lineText, FONT12)) {
// рисуем линию
canvas.save();

// если нужно центрировать по горизонтали
if (centered)
canvas.translate((lastTextWidth - line.getWidth()) / 2, 0);

canvas.drawTextLine(line, padding, y + padding + capHeight, fg);
// увеличиваем y координату на двойную высоту текста
y += 2 * capHeight;
canvas.restore();
}
}
}

// восстанавливаем области рисования
canvas.restore();
}
}

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

package panels;

import app.Task;
import controls.MultiLineLabel;
import io.github.humbleui.jwm.*;
import io.github.humbleui.skija.Canvas;
import misc.CoordinateSystem2i;

import static app.Application.PANEL_PADDING;
/**
* Панель управления
*/
public class PanelControl extends GridPanel {
/**
* Текст задания
*/
MultiLineLabel task;

/**
* Панель управления
*
* @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);

// задание
task = new MultiLineLabel(
window, false, backgroundColor, PANEL_PADDING,
6, 7, 0, 0, 6, 2, Task.TASK_TEXT,
false, true);

}

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

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

Запустим программу. Задание выводится корректно

w

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

Задание

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

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

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

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

  1. adding points works - пропишите добавление точек с помощью мыши
  2. log works - пропишите панель лога
  3. multiline label - пропишите многострочный заголовок и выведите с его помощью текст задачи

w

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

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