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) +}