347 lines
8.9 KiB
Go
347 lines
8.9 KiB
Go
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
|
||
}
|