init
This commit is contained in:
commit
13a1988243
8 changed files with 358 additions and 0 deletions
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||||
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"editor.insertSpaces": false,
|
||||||
|
"editor.tabSize": 4,
|
||||||
|
"editor.detectIndentation": false
|
||||||
|
}
|
||||||
1
README.md
Normal file
1
README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# pager
|
||||||
14
dub.json
Normal file
14
dub.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
6
dub.selections.json
Normal file
6
dub.selections.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"fileVersion": 1,
|
||||||
|
"versions": {
|
||||||
|
"arsd-official": "12.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
source/app.d
Normal file
7
source/app.d
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import pager;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
string[] lines = ["длинный текст..."];
|
||||||
|
auto pager = new Pager(lines, "Мой заголовок");
|
||||||
|
pager.render();
|
||||||
|
}
|
||||||
291
source/pager.d
Normal file
291
source/pager.d
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue