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 }