From 13a1988243d81e77f481e94bb75515beddd683f3 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 19 Nov 2025 09:42:10 +0300 Subject: [PATCH] init --- .gitignore | 17 +++ .vscode/launch.json | 17 +++ .vscode/settings.json | 5 + README.md | 1 + dub.json | 14 ++ dub.selections.json | 6 + source/app.d | 7 + source/pager.d | 291 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 358 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 dub.json create mode 100644 dub.selections.json create mode 100644 source/app.d create mode 100644 source/pager.d diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f75076 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.dub +docs.json +__dummy.html +docs/ +/pager +pager.so +pager.dylib +pager.dll +pager.a +pager.lib +pager-test-* +*.exe +*.pdb +*.o +*.obj +*.lst +bin/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..03d6784 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Используйте IntelliSense, чтобы узнать о возможных атрибутах. + // Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов. + // Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "code-d", + "request": "launch", + "dubBuild": true, + "name": "Build & Debug DUB project", + "cwd": "${command:dubWorkingDirectory}", + "program": "bin/${command:dubTarget}", + "args": ["list"] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d1c022f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.insertSpaces": false, + "editor.tabSize": 4, + "editor.detectIndentation": false +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8be8c4 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# pager diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..eb2b236 --- /dev/null +++ b/dub.json @@ -0,0 +1,14 @@ +{ + "authors": [ + "Alexander Zhirov" + ], + "copyright": "Copyright © 2025, Alexander Zhirov", + "description": "Pager as less", + "license": "BSL-1.0", + "name": "pager", + "dependencies": { + "arsd-official:terminal": "~>12.1.0" + }, + "targetPath": "bin", + "targetType": "executable" +} \ No newline at end of file diff --git a/dub.selections.json b/dub.selections.json new file mode 100644 index 0000000..0eb4f66 --- /dev/null +++ b/dub.selections.json @@ -0,0 +1,6 @@ +{ + "fileVersion": 1, + "versions": { + "arsd-official": "12.1.0" + } +} diff --git a/source/app.d b/source/app.d new file mode 100644 index 0000000..c410de9 --- /dev/null +++ b/source/app.d @@ -0,0 +1,7 @@ +import pager; + +void main() { + string[] lines = ["длинный текст..."]; + auto pager = new Pager(lines, "Мой заголовок"); + pager.render(); +} diff --git a/source/pager.d b/source/pager.d new file mode 100644 index 0000000..a9647a5 --- /dev/null +++ b/source/pager.d @@ -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(); + } + } + } +}