grimoir/menu.go
2026-05-07 17:28:57 +03:00

347 lines
8.9 KiB
Go
Raw 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"
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 // сообщение об ошибке
}
// 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 = ""
return m, nil
case menuFetchURL:
m.inputMode = "url"
m.input = ""
m.err = ""
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) {
switch msg.String() {
case "esc":
m.inputMode = ""
m.input = ""
m.err = ""
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 {
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 статьи: "
}
promptLine := promptStyle.Render(prompt) + inputStyle.Render(m.input+"▌")
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")
} 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
}