V 1.0.0
This commit is contained in:
parent
43e347d530
commit
1ebc404ceb
10 changed files with 663 additions and 0 deletions
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
109
ARCHITECTURE.md
Normal 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
48
README.md
Normal 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
31
go.mod
Normal 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
49
go.sum
Normal 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
152
image.go
Normal 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
66
options.go
Normal 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
45
text.go
Normal 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
97
tui/model.go
Normal 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
20
variant.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue