This commit is contained in:
Magnus Root 2026-08-26 11:56:56 +03:00
parent 43e347d530
commit 1ebc404ceb
10 changed files with 663 additions and 0 deletions

46
.gitignore vendored Normal file
View file

@ -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

109
ARCHITECTURE.md Normal file
View file

@ -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
один раз и переиспользовать для всех превью.

48
README.md Normal file
View file

@ -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"
```

31
go.mod Normal file
View file

@ -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
)

49
go.sum Normal file
View file

@ -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=

152
image.go Normal file
View file

@ -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]
}

66
options.go Normal file
View file

@ -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", // тематический декоративный
}
}

45
text.go Normal file
View file

@ -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
}

97
tui/model.go Normal file
View file

@ -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 != ""
}

20
variant.go Normal file
View file

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