From 1ebc404ceba7af160cd7996483598c14794777da Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Wed, 26 Aug 2026 11:56:56 +0300 Subject: [PATCH] V 1.0.0 --- .gitignore | 46 +++++++++++++++ ARCHITECTURE.md | 109 ++++++++++++++++++++++++++++++++++ README.md | 48 +++++++++++++++ go.mod | 31 ++++++++++ go.sum | 49 ++++++++++++++++ image.go | 152 ++++++++++++++++++++++++++++++++++++++++++++++++ options.go | 66 +++++++++++++++++++++ text.go | 45 ++++++++++++++ tui/model.go | 97 ++++++++++++++++++++++++++++++ variant.go | 20 +++++++ 10 files changed, 663 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 image.go create mode 100644 options.go create mode 100644 text.go create mode 100644 tui/model.go create mode 100644 variant.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de5b2ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Binaries +asciigen +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Credentials +*.env + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Config with sensitive data +config.yaml +config.yml +*.local.yaml +*.local.yml + +# Temporary files +*.tmp +*.temp diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..dd93434 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,109 @@ +# Архитектура + +## Идея + +Три независимых слоя, не знающих друг о друге: + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ +│ CLI/main │ --> │ asciigen (core) │ <-- │ tui │ +└─────────────┘ └──────────────────┘ └─────────────┘ + знает о обоих не знает не знает + ни о CLI, ничего, кроме + ни о TUI интерфейса Renderer +``` + +- **`asciigen` (корневой пакет)** — вся логика рендера. Ничего не знает + про терминал, ввод/вывод, флаги. Чистые функции + два типа-рендерера. +- **`tui`** — работает только с интерфейсом `asciigen.Renderer`. Не знает, + картинка перед ним или текст — поэтому один и тот же экран выбора + обслуживает оба сценария без дублирования кода. +- **`cmd/asciigen`** — единственное место, которое "склеивает" всё вместе: + парсит аргументы, решает какой Renderer создать, передаёт его в TUI. + +Такое разделение даёт две вещи, ради которых это всё затевалось: +1. Ядро можно импортировать в другой Go-проект (auspex, rimefrost) как + библиотеку, вообще не трогая TUI/CLI. +2. TUI не придётся переписывать, если добавится третий вид источника + (например, ASCII-арт из ANSI-артов или QR-кодов) — он просто реализует + `Renderer`. + +## Поток данных: картинка → ASCII + +``` +файл (png/jpg) + │ image.Decode() [cmd/asciigen/main.go] + ▼ +image.Image (декодированная картинка) + │ NewImageRenderer(img) + ▼ +ImageRenderer.Previews() + │ для каждого пресета из defaultImagePresets(): + │ 1. resize() — уменьшить до previewWidth=60, + │ высота домножается на 0.5 (компенсация + │ формы символа терминала, он ~2:1) + │ 2. render(): + │ for each pixel: + │ a. RGBA() → r8,g8,b8 (0..255) + │ b. luma = 0.299R + 0.587G + 0.114B (яркость по глазу) + │ c. gamma-коррекция: corrected = luma^gamma + │ d. invert (если задан) + │ e. индекс в палитре = corrected * (len(palette)-1) + │ f. если opts.Color — обернуть символ в ANSI \x1b[38;2;R;G;Bm + ▼ +[]Variant{ID, Label, Preview} — 5 готовых превью + │ TUI показывает список, пользователь листает ↑/↓ + ▼ +Enter → ImageRenderer.Full(id) + │ те же presets[id], но Width = fullWidthFor(img) (до 200 симв.) + ▼ +строка ASCII в полном размере → печатается в stdout +``` + +## Поток данных: текст → баннер + +``` +строка текста + │ NewTextRenderer(text) + ▼ +TextRenderer.Previews() + │ для каждого шрифта из defaultFonts(): + │ go-figure.NewFigure(text, font, true).String() + ▼ +[]Variant{ID: имя шрифта, Preview: баннер} + │ TUI, тот же экран выбора, что и для картинок + ▼ +Enter → TextRenderer.Full(font) — фактически тот же рендер, + FIGlet не теряет детализацию на "превью", в отличие от картинки + ▼ +готовый баннер → stdout +``` + +## Почему так, а не иначе + +- **Один и тот же TUI для картинок и текста.** Оба рендерера отдают + `[]Variant` одинаковой формы — TUI работает с абстракцией, не зная + деталей. Не пришлось бы писать два экрана выбора. +- **Превью и полный рендер — один и тот же код (`render()`), + разный `Width`.** Не два алгоритма, а один параметризованный — + меньше мест для расхождения багов между "как выглядит" и "что получишь". +- **Gamma и Invert — это математика над яркостью, а не отдельные ветки + кода для каждого пресета.** Пресеты в `options.go` — это просто наборы + значений полей `ImageOptions`, а не пять разных функций рендера. +- **`resize()` компенсирует форму символа (`* 0.5` по высоте).** Без этого + картинка выглядит вытянутой по вертикали — символ терминала выше, чем + широкий (~2:1), и это единственное место, где это учитывается, + а не размазано по остальному коду. + +## Известные ограничения (сознательно не решались в скелете) + +- `fullWidthFor()` — простой кап в 200 символов по ширине оригинала, + без учёта реального размера терминала пользователя (можно прокинуть + через `golang.org/x/term.GetSize`, если понадобится). +- ANSI-цвет включает "сброс" (`\x1b[0m`) после каждого символа — рабочий, + но не самый компактный вариант; для больших картинок можно оптимизировать, + сбрасывая только при смене цвета. +- Нет кэширования уменьшенной копии картинки между пресетами — каждый + пресет по новой делает `resize()` от оригинала. Для маленьких превью + (60 символов) это не заметно, но при желании легко вынести resize + один раз и переиспользовать для всех превью. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e0506d --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# asciigen — скелет проекта + +## Структура + +``` +asciigen/ +├── go.mod +├── variant.go # тип Variant + интерфейс Renderer +├── options.go # ImageOptions/TextOptions, палитры, пресеты +├── image.go # ImageRenderer — картинка -> ASCII +├── text.go # TextRenderer — текст -> FIGlet-баннер +├── tui/ +│ └── model.go # bubbletea-модель: список превью + выбор +└── cmd/asciigen/ + └── main.go # CLI: `asciigen img file.png` / `asciigen text "Hello"` +``` + +Ядро (`asciigen.Renderer`) не знает о TUI и не знает о CLI — можно +подключить как библиотеку в другой Go-проект (auspex, rimefrost и т.д.) +и обвязать своим интерфейсом, ничего не переписывая внутри пакета. + +Подробный разбор потока данных и почему всё разложено именно так — +см. **[ARCHITECTURE.md](./ARCHITECTURE.md)**. + +## Что уже готово + +- Интерфейс `Renderer` с двумя реализациями (`ImageRenderer`, `TextRenderer`) +- Пресеты вариантов (палитры для картинок, шрифты для текста) +- TUI на bubbletea: навигация стрелками, превью справа, Enter — полный рендер +- CLI-обвязка с двумя подкомандами +- `image.go` → `render()` реализован полностью: ресайз, яркость (luma), + гамма-коррекция, инверсия, маппинг на палитру, опциональный ANSI-цвет + +## Что нужно доделать + +- `go.sum` — появится сам после `go mod tidy` (нужен доступ к proxy.golang.org, + которого нет в этой песочнице, так что тут не собирал и не компилировал) +- Реальный запуск и визуальная проверка на конкретных картинках — стоит + подобрать `Gamma` под глаз, цифры в пресетах сейчас ориентировочные +- См. "Известные ограничения" в ARCHITECTURE.md — сознательно не решённые + мелочи (авто-определение ширины терминала, оптимизация ANSI-вывода) + +## Запуск (после `go mod tidy` у себя) + +``` +go run ./cmd/asciigen img photo.png +go run ./cmd/asciigen text "HELLO" +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2096889 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module asciigen + +go 1.22 + +require ( + github.com/charmbracelet/bubbletea v0.27.0 + github.com/charmbracelet/lipgloss v0.13.0 + github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be + golang.org/x/image v0.18.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/charmbracelet/x/input v0.1.0 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2145e43 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU= +github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= +github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= +github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= diff --git a/image.go b/image.go new file mode 100644 index 0000000..a0fe934 --- /dev/null +++ b/image.go @@ -0,0 +1,152 @@ +package asciigen + +import ( + "fmt" + "image" + "math" + "strings" + + "golang.org/x/image/draw" +) + +const previewWidth = 60 // ширина превью в символах, независимо от финального размера + +// ImageRenderer реализует Renderer для картинок. +type ImageRenderer struct { + src image.Image +} + +func NewImageRenderer(src image.Image) *ImageRenderer { + return &ImageRenderer{src: src} +} + +func (r *ImageRenderer) Previews() ([]Variant, error) { + presets := defaultImagePresets() + variants := make([]Variant, 0, len(presets)) + + for id, opts := range presets { + previewOpts := opts + previewOpts.Width = previewWidth + ascii, err := r.render(previewOpts) + if err != nil { + return nil, fmt.Errorf("превью %q: %w", id, err) + } + variants = append(variants, Variant{ + ID: id, + Label: labelForImagePreset(id), + Preview: ascii, + }) + } + return variants, nil +} + +func (r *ImageRenderer) Full(variantID string) (string, error) { + presets := defaultImagePresets() + opts, ok := presets[variantID] + if !ok { + return "", fmt.Errorf("неизвестный вариант: %q", variantID) + } + // Полный размер — либо оригинальное разрешение картинки в символах, + // либо разумный кап (напр. 200 символов), чтобы не рендерить гигантские строки. + opts.Width = fullWidthFor(r.src) + return r.render(opts) +} + +func fullWidthFor(img image.Image) int { + w := img.Bounds().Dx() + const cap = 200 + if w > cap { + return cap + } + return w +} + +// render — основной конвейер: ресайз -> яркость -> символ палитры -> (опц.) ANSI-цвет. +func (r *ImageRenderer) render(opts ImageOptions) (string, error) { + if opts.Width <= 0 { + return "", fmt.Errorf("render: width должен быть > 0, получено %d", opts.Width) + } + if len(opts.Palette) == 0 { + return "", fmt.Errorf("render: пустая палитра") + } + gamma := opts.Gamma + if gamma <= 0 { + gamma = 1.0 // 0 трактуем как "не задано", чтобы не делить на ноль + } + + resized := resize(r.src, opts.Width, opts.Height) + bounds := resized.Bounds() + + var out strings.Builder + // Небольшой запас под ANSI-коды, чтобы не было лишних реаллокаций. + out.Grow(bounds.Dy() * bounds.Dx() * 8) + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + rr, gg, bb, _ := resized.At(x, y).RGBA() + // RGBA() отдаёт значения в диапазоне [0, 65535] — приводим к [0, 255]. + r8, g8, b8 := rr>>8, gg>>8, bb>>8 + + ch := brightnessToChar(r8, g8, b8, opts.Palette, gamma, opts.Invert) + + if opts.Color { + out.WriteString(fmt.Sprintf("\x1b[38;2;%d;%d;%dm%c\x1b[0m", r8, g8, b8, ch)) + } else { + out.WriteRune(ch) + } + } + out.WriteByte('\n') + } + + return out.String(), nil +} + +// brightnessToChar переводит яркость пикселя в символ палитры с учётом гаммы и инверсии. +func brightnessToChar(r8, g8, b8 uint32, palette Palette, gamma float64, invert bool) rune { + // Стандартная формула яркости (luma), учитывает разную чувствительность глаза к цветам. + luma := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8) + normalized := luma / 255.0 // теперь в диапазоне [0, 1] + + // Гамма-коррекция: gamma > 1 делает тени темнее (больше контраста), + // gamma < 1 — светлее (больше деталей в тенях). + corrected := math.Pow(normalized, gamma) + + if invert { + corrected = 1.0 - corrected + } + + chars := []rune(palette) + // corrected=0 (тёмный пиксель) -> первый символ палитры (обычно самый "плотный", @/#). + // corrected=1 (светлый пиксель) -> последний символ (обычно пробел). + idx := int(corrected * float64(len(chars)-1)) + if idx < 0 { + idx = 0 + } + if idx >= len(chars) { + idx = len(chars) - 1 + } + return chars[idx] +} + +// resize — уменьшает картинку с учётом того, что символ терминала выше, чем шире (~2:1). +func resize(src image.Image, width, height int) image.Image { + if height == 0 { + b := src.Bounds() + aspect := float64(b.Dy()) / float64(b.Dx()) + height = int(float64(width) * aspect * 0.5) // компенсация формы символа + } + dst := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + return dst +} + +func labelForImagePreset(id string) string { + labels := map[string]string{ + "blocks-standard": "Блочная палитра, стандартный контраст", + "blocks-inverted": "Блочная палитра, инверсия", + "detailed": "Расширенная градация полутонов", + "high-contrast": "Высокий контраст", + "minimal": "Минимальная палитра (2 символа)", + } + return labels[id] +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..7319dc5 --- /dev/null +++ b/options.go @@ -0,0 +1,66 @@ +package asciigen + +// Palette — набор символов от тёмного к светлому (или наоборот при Invert). +type Palette string + +const ( + // PaletteBlocks — контрастная палитра, хорошо читается на глаз. + PaletteBlocks Palette = "@%#*+=-:. " + // PaletteMinimal — всего 2 символа, для мелких превью. + PaletteMinimal Palette = "#." + // PaletteDetailed — длинная градация, больше полутонов. + PaletteDetailed Palette = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. " +) + +// ImageOptions — параметры рендера картинки в ASCII. +type ImageOptions struct { + Width int // ширина результата в символах + Height int // 0 = вычисляется автоматически по аспекту (клетка символа ~2:1) + Palette Palette + Invert bool // поменять местами тёмное/светлое + Color bool // цветной вывод через ANSI-коды (24-bit) + Gamma float64 // 1.0 = без изменений; <1 светлее тени, >1 контрастнее +} + +// TextOptions — параметры рендера текстового баннера (FIGlet). +type TextOptions struct { + Font string // имя шрифта FIGlet, напр. "standard", "big", "slant", "block" + Width int // ширина переноса строк, 0 = без переноса +} + +// defaultImagePresets — набор пресетов для первого экрана выбора. +// Каждый пресет — это отдельный Variant, а не отдельный алгоритм: +// меняются только Palette/Gamma/Invert поверх одного и того же движка. +func defaultImagePresets() map[string]ImageOptions { + return map[string]ImageOptions{ + "blocks-standard": {Palette: PaletteBlocks, Gamma: 1.0}, + "blocks-inverted": {Palette: PaletteBlocks, Gamma: 1.0, Invert: true}, + "detailed": {Palette: PaletteDetailed, Gamma: 1.0}, + "high-contrast": {Palette: PaletteBlocks, Gamma: 1.6}, + "minimal": {Palette: PaletteMinimal, Gamma: 1.0}, + } +} + +// defaultFonts — пресеты шрифтов для текстового баннера. +// Список сверен со встроенными в go-figure шрифтами (пакет их держит +// вшитыми через go-bindata, так что это не файлы на диске, а константы +// имён). Подобраны по разнообразию стиля, а не просто первые в алфавите — +// иначе часть вариантов выглядела бы почти одинаково. +func defaultFonts() []string { + return []string{ + "standard", // базовый, читаемый + "big", // крупный жирный + "slant", // курсив с наклоном + "small", // компактный + "block", // блочные буквы + "shadow", // с тенью + "banner", // широкий баннерный + "bubble", // "пузырьковые" буквы + "digital", // под LCD-индикатор + "doom", // угловатый, агрессивный + "graffiti", // граффити-стиль + "isometric1", // псевдо-3D + "larry3d", // объёмный 3D + "starwars", // тематический декоративный + } +} \ No newline at end of file diff --git a/text.go b/text.go new file mode 100644 index 0000000..733bae3 --- /dev/null +++ b/text.go @@ -0,0 +1,45 @@ +package asciigen + +import ( + "fmt" + + figure "github.com/common-nighthawk/go-figure" +) + +// TextRenderer реализует Renderer для текстовых баннеров. +type TextRenderer struct { + text string +} + +func NewTextRenderer(text string) *TextRenderer { + return &TextRenderer{text: text} +} + +func (r *TextRenderer) Previews() ([]Variant, error) { + fonts := defaultFonts() + variants := make([]Variant, 0, len(fonts)) + + for _, font := range fonts { + ascii, err := r.render(TextOptions{Font: font}) + if err != nil { + return nil, fmt.Errorf("шрифт %q: %w", font, err) + } + variants = append(variants, Variant{ + ID: font, + Label: fmt.Sprintf("Шрифт: %s", font), + Preview: ascii, + }) + } + return variants, nil +} + +func (r *TextRenderer) Full(variantID string) (string, error) { + // Для текста превью и полная версия обычно совпадают — FIGlet не теряет + // детализацию на превью так, как это происходит с картинкой. + return r.render(TextOptions{Font: variantID}) +} + +func (r *TextRenderer) render(opts TextOptions) (string, error) { + fig := figure.NewFigure(r.text, opts.Font, true) + return fig.String(), nil +} diff --git a/tui/model.go b/tui/model.go new file mode 100644 index 0000000..e0f3682 --- /dev/null +++ b/tui/model.go @@ -0,0 +1,97 @@ +// Package tui реализует интерактивный выбор варианта: список превью слева, +// полный текущий превью справа, стрелки для навигации, Enter — подтвердить. +package tui + +import ( + "fmt" + + "asciigen" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42")) + normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + previewBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 2) +) + +// Model — состояние TUI. Renderer абстрагирует, картинка перед нами или текст. +type Model struct { + renderer asciigen.Renderer + variants []asciigen.Variant + cursor int + chosen string // ID выбранного варианта после подтверждения, "" пока не выбрано + fullText string // отрендеренная полная версия, заполняется после Enter + err error +} + +func New(r asciigen.Renderer) (Model, error) { + variants, err := r.Previews() + if err != nil { + return Model{}, err + } + return Model{renderer: r, variants: variants}, nil +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.variants)-1 { + m.cursor++ + } + case "enter": + id := m.variants[m.cursor].ID + full, err := m.renderer.Full(id) + if err != nil { + m.err = err + return m, nil + } + m.chosen = id + m.fullText = full + return m, tea.Quit + } + } + return m, nil +} + +func (m Model) View() string { + if m.err != nil { + return fmt.Sprintf("Ошибка: %v\n", m.err) + } + + var list string + for i, v := range m.variants { + line := v.Label + if i == m.cursor { + line = selectedStyle.Render("> " + line) + } else { + line = normalStyle.Render(" " + line) + } + list += line + "\n" + } + + preview := previewBox.Render(m.variants[m.cursor].Preview) + + return lipgloss.JoinHorizontal(lipgloss.Top, list, " ", preview) + + "\n\n↑/↓ выбор • Enter подтвердить • q выход\n" +} + +// Chosen возвращает (ID, полный рендер, ok) — вызывать после того как +// tea.NewProgram(...).Run() завершился. +func (m Model) Chosen() (string, string, bool) { + return m.chosen, m.fullText, m.chosen != "" +} diff --git a/variant.go b/variant.go new file mode 100644 index 0000000..dfedeb3 --- /dev/null +++ b/variant.go @@ -0,0 +1,20 @@ +package asciigen + +// Variant — один вариант рендера: превью + опции, которыми он получен. +// Опции хранятся внутри варианта, чтобы полный рендер запускался +// повторным вызовом с теми же параметрами, но в полном разрешении — +// без пересчёта пресетов с нуля. +type Variant struct { + ID string // короткий идентификатор пресета, напр. "blocks-hi-contrast" + Label string // человекочитаемое описание для UI/TUI + Preview string // уменьшенный ASCII-рендер для быстрого показа +} + +// Renderer — общий интерфейс для картиночного и текстового рендера. +// TUI работает с этим интерфейсом и не знает, картинка перед ним или текст. +type Renderer interface { + // Previews возвращает набор вариантов (разные пресеты) в уменьшенном виде. + Previews() ([]Variant, error) + // Full рендерит выбранный вариант (по ID) в полном размере/детализации. + Full(variantID string) (string, error) +}