This commit is contained in:
Alexander Zhirov 2025-11-19 09:42:10 +03:00
commit 13a1988243
Signed by: alexander
GPG key ID: C8D8BE544A27C511
8 changed files with 358 additions and 0 deletions

291
source/pager.d Normal file
View file

@ -0,0 +1,291 @@
module pager; // Объявляем модуль с именем pager
// Подключаем необходимые модули
import arsd.terminal; // Библиотека для работы с терминалом (цвета, ввод, альтернативный экран и т.д.)
import std.array : array, appender; // appender — эффективный «строитель» массива, array — преобразование в обычный массив
import std.uni : toLower; // Приведение символа к нижнему регистру
import std.utf : decode; // Декодирование UTF-8 символов (dchar из строки)
import std.conv : to; // Преобразование типов
import std.format : format; // Форматирование строк вроде printf
// Класс, отвечающий за отображение и навигацию по тексту (аналог less/more)
class PagerText {
private:
Terminal* _terminal; // Указатель на объект терминала (передаём по указателю, т.к. он большой)
string[] _lines; // Исходные строки текста (без переносов)
string[] _wrappedLines; // Строки после переноса по ширине терминала
string[] _wrappedTitle; // Заголовок после переноса по ширине
size_t _top; // Индекс первой видимой строки в _wrappedLines
int _width, _height; // Текущие размеры терминала
int _titleHeight; // Сколько строк занимает полностью развёрнутый заголовок
int _effectiveTitleHeight; // Сколько строк заголовка реально показываем (может быть обрезано)
int _statusHeight; // Высота строки статуса внизу (обычно 0 или 1)
size_t _pageSize; // Сколько строк текста помещается на экране (без заголовка и статуса)
string _title; // Заголовок, который показывается сверху синим фоном
// ------------------------------------------------------------------
// Функция переноса строк с учётом ширины терминала и табуляций
// ------------------------------------------------------------------
string[] wrapLines(const string[] src, int width) {
auto result = appender!(string[])(); // Эффективный «строитель» массива строк
if (width <= 0) width = 1; // Защита от нулевой/отрицательной ширины
foreach (line; src) {
if (line.length == 0) { // Пустая строка → просто пустая строка в результате
result.put("");
continue;
}
size_t start = 0; // Начало текущего куска строки
while (start < line.length) {
size_t idx = start; // Текущая позиция в байтах
int cols = 0; // Сколько «колонок» уже занято в текущей выводимой строке
// Проходим по символ за символом, пока не упрёмся в ширину
while (idx < line.length) {
size_t old_idx = idx;
dchar c = decode(line, idx); // Декодируем один Unicode-символ (может быть 1-4 байта)
// Обработка табуляции: таб = 8 колонок минус остаток по модулю 8
int w = 1;
if (c == '\t') {
w = 8 - (cols % 8);
if (w == 0) w = 8;
}
// Если добавление символа выходит за пределы ширины
if (cols + w > width) {
// Если ещё ничего не набрали в этой строке — придётся разорвать посреди символа
if (cols == 0) {
result.put(line[start .. idx]); // Выводим всё, что успели
start = idx;
break;
} else {
// Откатываемся к предыдущему символу и завершаем строку
idx = old_idx;
break;
}
}
cols += w; // Символ помещается — увеличиваем счётчик колонок
}
// Если удалось что-то набрать — добавляем кусок в результат
if (idx > start) {
result.put(line[start .. idx]);
start = idx;
} else {
// Защита от бесконечного цикла (вдруг decode не сдвинул индекс)
decode(line, start);
}
}
}
return result.data; // Преобразуем appender в обычный массив string[]
}
// ------------------------------------------------------------------
// Пересчитываем размеры, переносы строк и позицию прокрутки при изменении окна
// ------------------------------------------------------------------
void recomputeLayout() {
_height = _terminal.height; // Текущая высота терминала
_width = _terminal.width; // Текущая ширина
// Переносим сам текст и заголовок под новую ширину
_wrappedLines = wrapLines(_lines, _width);
_wrappedTitle = wrapLines([_title], _width);
_titleHeight = cast(int)_wrappedTitle.length;
// Если терминал очень маленький — заголовок может не влезть полностью
int maxTitleLines = _height > 0 ? _height : 0;
_effectiveTitleHeight = _titleHeight > maxTitleLines ? maxTitleLines : _titleHeight;
// Оставшееся место после заголовка
int remaining = _height - _effectiveTitleHeight;
_statusHeight = remaining > 0 ? 1 : 0; // Строка статуса внизу (если есть место)
int contentHeight = remaining - _statusHeight; // Место под сам текст
if (contentHeight < 0) contentHeight = 0;
_pageSize = cast(size_t)contentHeight; // Сколько строк текста видно одновременно
// Корректируем позицию прокрутки, чтобы не выйти за границы
if (_pageSize == 0 || _wrappedLines.length <= _pageSize) {
_top = 0;
} else if (_top > _wrappedLines.length - _pageSize) {
_top = _wrappedLines.length - _pageSize;
}
}
public:
// Конструктор: получаем терминал, массив строк и заголовок
this(Terminal* terminal, string[] lines, string title) {
_terminal = terminal;
_lines = lines.dup; // Делаем копию, чтобы не портить оригинал
_title = title;
recomputeLayout(); // Первичный расчёт размеров
}
// ------------------------------------------------------------------
// Полная перерисовка экрана
// ------------------------------------------------------------------
void redraw() {
_terminal.clear(); // Очищаем весь экран
int y = 0;
// === Рисуем заголовок (синий фон, белый текст) ===
if (_height > 0 && _effectiveTitleHeight > 0) {
_terminal.color(Color.white, Color.blue); // Белый на синем
foreach (size_t i; 0 .. _effectiveTitleHeight) {
_terminal.moveTo(0, y + cast(int)i);
_terminal.clearToEndOfLine(); // Очищаем строку
_terminal.write(_wrappedTitle[i]); // Пишем кусок заголовка
}
_terminal.color(Color.DEFAULT, Color.DEFAULT); // Сбрасываем цвета
y += _effectiveTitleHeight; // Сдвигаем «курсор» ниже заголовка
}
// === Рисуем видимую часть текста ===
for (size_t i = 0; i < _pageSize; ++i) {
size_t idx = _top + i; // Номер строки в общем массиве
if (idx >= _wrappedLines.length) break; // Если текст кончился раньше — выходим
_terminal.moveTo(0, y + cast(int)i);
_terminal.write(_wrappedLines[idx]); // Сам текст
_terminal.clearToEndOfLine(); // Очищаем остаток строки (на случай мусора)
}
// === Нижняя статусная строка ===
if (_statusHeight > 0) {
_terminal.moveTo(0, _height - 1); // Переходим на последнюю строку
_terminal.color(Color.white, Color.blue);
_terminal.clearToEndOfLine();
// Вычисляем процент прокрутки
long percent = 0;
if (_wrappedLines.length > 0) {
auto bottom = _top + _pageSize;
if (bottom > _wrappedLines.length) bottom = _wrappedLines.length;
percent = cast(long)(bottom * 100 / _wrappedLines.length);
}
string status = format("Нажмите q для выхода [%d/%d строк %d%%]",
_wrappedLines.length > 0 ? _top + 1 : _top, // первая видимая строка (с 1)
_wrappedLines.length,
percent
);
_terminal.write(status);
_terminal.color(Color.DEFAULT, Color.DEFAULT);
}
_terminal.flush(); // Принудительно выводим всё на экран
}
// Вызывается при изменении размера окна
void onResize() {
recomputeLayout();
recomputeLayout();
redraw();
}
// Обработка нажатий клавиш
// Возвращает true, если нужно завершить работу (нажали q или Esc)
bool onKey(const KeyboardEvent ke) {
if (!ke.pressed) return false; // Отпускание клавиши игнорируем
dchar key = ke.which; // Какой символ/код клавиши
// q или Q — выход
if ((key >= 'A' && key <= 'Z') || (key >= 'a' && key <= 'z')) {
if (toLower(key) == 'q') return true;
}
if (key == KeyboardEvent.Key.escape) return true;
// Если текста нет или экран слишком маленький — просто перерисовываем
if (_wrappedLines.length == 0 || _pageSize == 0) {
redraw();
return false;
}
// Навигация
switch (key) {
case KeyboardEvent.Key.PageDown:
if (_top + _pageSize < _wrappedLines.length) {
_top += _pageSize;
if (_top > _wrappedLines.length - _pageSize)
_top = _wrappedLines.length - _pageSize;
}
break;
case KeyboardEvent.Key.PageUp:
_top = _top < _pageSize ? 0 : _top - _pageSize;
break;
case KeyboardEvent.Key.DownArrow:
if (_top + _pageSize < _wrappedLines.length) ++_top;
break;
case KeyboardEvent.Key.UpArrow:
if (_top > 0) --_top;
break;
case KeyboardEvent.Key.End:
_top = _wrappedLines.length <= _pageSize ? 0 : _wrappedLines.length - _pageSize;
break;
case KeyboardEvent.Key.Home:
_top = 0;
break;
default: // неизвестная клавиша — ничего не делаем
}
redraw(); // После любого перемещения перерисовываем
return false; // Продолжаем работу
}
}
// ==================================================================
// Основной класс-обёртка, который создаёт терминал и запускает цикл
// ==================================================================
class Pager {
private:
Terminal _terminal; // Объект терминала (по значению — он копируется внутри arsd.terminal)
PagerText _pager; // Наш пейджер с логикой отображения
public:
// Конструктор: принимает текст и опциональный заголовок
this(string[] lines, string title = "") {
// Создаём терминал в «клеточном» режиме (поддерживает цвета, альтернативный экран и т.д.)
_terminal = Terminal(ConsoleOutputType.cellular);
// Создаём объект PagerText, передавая ему указатель на терминал
_pager = new PagerText(&_terminal, lines, title);
}
// Основной метод — запускаем пейджер
void render() {
// Переключаемся на альтернативный экран (чтобы после выхода терминал остался чистым)
_terminal.enableAlternateScreen(true);
_terminal.hideCursor(); // Скрываем мигающий курсор
// scope(exit) — гарантированно выполнится при любом выходе из функции
scope (exit) {
_terminal.clear();
_terminal.showCursor();
_terminal.enableAlternateScreen(false);
_terminal.flush();
}
_pager.redraw(); // Первая отрисовка
// Включаем «сырой» режим ввода + отслеживание изменения размера
auto input = RealTimeConsoleInput(&_terminal, ConsoleInputFlags.raw | ConsoleInputFlags.size);
bool running = true;
while (running) {
auto ev = input.nextEvent(); // Ждём следующее событие (клавиша или ресайз)
if (ev.type == InputEvent.Type.KeyboardEvent) {
auto ke = ev.get!(InputEvent.Type.KeyboardEvent);
if (_pager.onKey(ke)) { // Если onKey вернул true — выходим
running = false;
}
} else if (ev.type == InputEvent.Type.SizeChangedEvent) {
// При изменении размера окна пересчитываем всё
_pager.onResize();
}
}
}
}