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(); } } } }