grimoir/menu.go
2026-07-04 13:04:33 +03:00

417 lines
No EOL
12 KiB
Go
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// === Стартовое меню ===
var grimLogo = []string{
` ██████╗ ██████╗ ██╗███╗ ███╗ ██████╗ ██╗██████╗ `,
` ██╔════╝██╔══██╗██║████╗ ████║██╔═══██╗██║██╔══██╗`,
` ██║ ███╗██████╔╝██║██╔████╔██║██║ ██║██║██████╔╝`,
` ██║ ██║██╔══██╗██║██║╚██╔╝██║██║ ██║██║██╔══██╗`,
` ╚██████╔╝██║ ██║██║██║ ╚═╝ ██║╚██████╔╝██║██║ ██║`,
` ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═╝`,
}
type menuAction int
const (
menuNewFile menuAction = iota
menuOpenFile
menuFetchURL
menuHelp
menuQuit
)
type menuItem struct {
key string
label string
action menuAction
}
var menuItems = []menuItem{
{"n", "Новый файл", menuNewFile},
{"o", "Открыть файл", menuOpenFile},
{"u", "Загрузить статью по URL", menuFetchURL},
{"?", "Справка", menuHelp},
{"q", "Выйти", menuQuit},
}
// MenuResult — результат выбора в меню
type MenuResult struct {
Action menuAction
FilePath string // для menuOpenFile
URL string // для menuFetchURL
}
// menuModel — Bubble Tea модель для стартового меню
type menuModel struct {
cursor int
width int
height int
result *MenuResult
input string // буфер для ввода пути/URL
inputMode string // "" / "file" / "url"
err string // сообщение об ошибке
// Детектор аномально быстрого ввода (вставка большого текста посимвольно).
// Терминал может разбивать paste на отдельные KeyMsg без bracketed-paste
// маркеров — тогда обычная защита по длине срабатывает слишком поздно,
// так как Ctrl+C встаёт в конец уже накопленной очереди событий.
lastKeyTime time.Time
rapidKeyCount int
}
// doneMsg сигнализирует о завершении меню
type menuDoneMsg struct{ result MenuResult }
func newMenuModel() menuModel {
return menuModel{cursor: 0}
}
func (m menuModel) Init() tea.Cmd {
return nil
}
func (m menuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case menuDoneMsg:
m.result = &msg.result
return m, tea.Quit
case tea.KeyMsg:
// Режим ввода пути или URL
if m.inputMode != "" {
return m.handleInput(msg)
}
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "j", "down":
m.cursor++
if m.cursor >= len(menuItems) {
m.cursor = 0
}
case "k", "up":
m.cursor--
if m.cursor < 0 {
m.cursor = len(menuItems) - 1
}
case "enter", " ":
return m.selectItem(menuItems[m.cursor].action)
default:
// Прямой выбор по клавише
for _, item := range menuItems {
if msg.String() == item.key {
return m.selectItem(item.action)
}
}
}
}
return m, nil
}
func (m menuModel) selectItem(action menuAction) (tea.Model, tea.Cmd) {
switch action {
case menuQuit:
return m, tea.Quit
case menuNewFile:
return m, func() tea.Msg { return menuDoneMsg{MenuResult{Action: menuNewFile}} }
case menuOpenFile:
m.inputMode = "file"
m.input = ""
m.err = ""
m.rapidKeyCount = 0
m.lastKeyTime = time.Time{}
return m, nil
case menuFetchURL:
m.inputMode = "url"
m.input = ""
m.err = ""
m.rapidKeyCount = 0
m.lastKeyTime = time.Time{}
return m, nil
case menuHelp:
return m, func() tea.Msg { return menuDoneMsg{MenuResult{Action: menuHelp}} }
}
return m, nil
}
func (m menuModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Детектор вставки большого текста посимвольно: если терминал не
// поддерживает bracketed paste, каждый символ из paste приходит как
// отдельный KeyMsg практически без задержки между ними (быстрее чем
// способен печатать человек — порог 8мс). Считаем подряд идущие
// быстрые нажатия и при превышении порога обрываем ввод немедленно,
// не дожидаясь пока вся вставка "проедется" через очередь событий.
now := time.Now()
if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace {
if !m.lastKeyTime.IsZero() && now.Sub(m.lastKeyTime) < 8*time.Millisecond {
m.rapidKeyCount++
} else {
m.rapidKeyCount = 0
}
m.lastKeyTime = now
const rapidThreshold = 20
if m.rapidKeyCount >= rapidThreshold {
m.input = ""
m.rapidKeyCount = 0
m.err = "Обнаружена вставка текста — поле очищено, введите URL вручную"
return m, nil
}
}
switch msg.String() {
case "ctrl+c":
// Гарантированный выход даже если что-то пошло не так с вводом
// (например случайная вставка большого текста)
return m, tea.Quit
case "esc":
m.inputMode = ""
m.input = ""
m.err = ""
m.rapidKeyCount = 0
case "enter":
if m.input == "" {
m.err = "Введите значение"
return m, nil
}
if m.inputMode == "url" && !IsURL(m.input) {
m.err = "Неверный URL (должен начинаться с http:// или https://)"
return m, nil
}
action := menuOpenFile
result := MenuResult{Action: action, FilePath: m.input}
if m.inputMode == "url" {
result.Action = menuFetchURL
result.URL = m.input
}
return m, func() tea.Msg { return menuDoneMsg{result} }
case "backspace":
runes := []rune(m.input)
if len(runes) > 0 {
m.input = string(runes[:len(runes)-1])
}
m.err = ""
default:
if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace {
// Лимит защищает от случайной вставки большого текста
// (например содержимого файла через paste), которая иначе
// засоряет поле ввода и делает интерфейс нечитаемым.
const maxInputLen = 500
if len([]rune(m.input)) < maxInputLen {
m.input += msg.String()
}
m.err = ""
}
}
return m, nil
}
func (m menuModel) View() string {
if m.width == 0 {
return ""
}
var out strings.Builder
// Стили
logoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).Bold(true)
versionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("8"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("15")).Bold(true).
Background(lipgloss.Color("4")).
PaddingLeft(2).PaddingRight(2)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("252")).
PaddingLeft(2).PaddingRight(2)
keyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("11")).Bold(true)
dimStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("8"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("9"))
inputStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("236")).
PaddingLeft(1).PaddingRight(1)
promptStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("11")).Bold(true)
// Вертикальный отступ — центрируем блок
logoHeight := len(grimLogo)
menuHeight := len(menuItems) + 2
inputHeight := 3
totalHeight := logoHeight + 4 + menuHeight + inputHeight + 4
topPad := (m.height - totalHeight) / 2
if topPad < 0 {
topPad = 1
}
for i := 0; i < topPad; i++ {
out.WriteString("\n")
}
// Логотип — центрируем каждую строку
for _, line := range grimLogo {
vis := visibleWidth(line)
pad := (m.width - vis) / 2
if pad < 0 {
pad = 0
}
out.WriteString(strings.Repeat(" ", pad))
out.WriteString(logoStyle.Render(line))
out.WriteString("\n")
}
// Версия под логотипом
verLine := versionStyle.Render(grimVersion + " · Terminal text editor")
verVis := visibleWidth(verLine)
verPad := (m.width - verVis) / 2
if verPad < 0 {
verPad = 0
}
out.WriteString(strings.Repeat(" ", verPad) + verLine + "\n")
out.WriteString("\n")
// Разделитель
divLen := 44
divPad := (m.width - divLen) / 2
if divPad < 0 {
divPad = 0
}
out.WriteString(strings.Repeat(" ", divPad))
out.WriteString(dimStyle.Render(strings.Repeat("─", divLen)))
out.WriteString("\n\n")
// Пункты меню
for i, item := range menuItems {
key := keyStyle.Render("[" + item.key + "]")
label := " " + item.label
var line string
if i == m.cursor {
line = selectedStyle.Render(key + label)
} else {
line = normalStyle.Render(key + label)
}
vis := visibleWidth(line)
pad := (m.width - vis) / 2
if pad < 0 {
pad = 0
}
out.WriteString(strings.Repeat(" ", pad))
out.WriteString(line)
out.WriteString("\n")
}
out.WriteString("\n")
out.WriteString(strings.Repeat(" ", divPad))
out.WriteString(dimStyle.Render(strings.Repeat("─", divLen)))
out.WriteString("\n\n")
// Поле ввода (если активно)
if m.inputMode != "" {
prompt := "Путь к файлу: "
if m.inputMode == "url" {
prompt = "URL статьи: "
}
// Обрезаем отображаемую часть ввода чтобы не ломать вёрстку,
// если что-то вставило длинный текст. Показываем хвост —
// он ближе к курсору и обычно важнее для проверки.
displayInput := m.input
maxVisible := m.width - visibleWidth(prompt) - 10
if maxVisible < 10 {
maxVisible = 10
}
inputRunes := []rune(displayInput)
if len(inputRunes) > maxVisible {
displayInput = "…" + string(inputRunes[len(inputRunes)-maxVisible+1:])
}
promptLine := promptStyle.Render(prompt) + inputStyle.Render(displayInput+"▌")
promptVis := visibleWidth(promptLine)
promptPad := (m.width - promptVis) / 2
if promptPad < 0 {
promptPad = 0
}
out.WriteString(strings.Repeat(" ", promptPad))
out.WriteString(promptLine)
out.WriteString("\n")
if m.err != "" {
errLine := errorStyle.Render(" ✗ " + m.err)
errVis := visibleWidth(errLine)
errPad := (m.width - errVis) / 2
if errPad < 0 {
errPad = 0
}
out.WriteString(strings.Repeat(" ", errPad))
out.WriteString(errLine)
out.WriteString("\n")
}
inputHint := dimStyle.Render("Enter подтвердить Esc отмена Ctrl+C выход")
inputHintVis := visibleWidth(inputHint)
inputHintPad := (m.width - inputHintVis) / 2
if inputHintPad < 0 {
inputHintPad = 0
}
out.WriteString(strings.Repeat(" ", inputHintPad))
out.WriteString(inputHint)
out.WriteString("\n")
} else {
hint := dimStyle.Render("↑↓ навигация Enter выбор q выйти")
hintVis := visibleWidth(hint)
hintPad := (m.width - hintVis) / 2
if hintPad < 0 {
hintPad = 0
}
out.WriteString(strings.Repeat(" ", hintPad))
out.WriteString(hint)
out.WriteString("\n")
}
return out.String()
}
// RunMenu запускает стартовое меню и возвращает результат выбора.
// Возвращает nil если пользователь вышел.
func RunMenu() *MenuResult {
m := newMenuModel()
p := tea.NewProgram(m, tea.WithAltScreen())
final, err := p.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Menu error: %v\n", err)
return nil
}
// Проверяем результат через финальное состояние модели
fm := final.(menuModel)
if fm.result != nil {
return fm.result
}
return nil
}