From 42bc698976fcfbfe61ca7bb4e10e577317d41582 Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Thu, 7 May 2026 17:28:57 +0300 Subject: [PATCH] Added menu --- main.go | 52 ++++++-- menu.go | 347 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ render.go | 2 +- 3 files changed, 388 insertions(+), 13 deletions(-) create mode 100644 menu.go diff --git a/main.go b/main.go index b97ed2d..cc7e407 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,7 @@ func main() { flag.BoolVar(&writeConfig, "write-config", false, "Write default config to standard location and exit") flag.Parse() - // Поддержка позиционного аргумента: grimoir file.txt или grimoir https://... + // Позиционный аргумент: grimoir file.txt или grimoir https://... if filePath == "" && flag.NArg() > 0 { filePath = flag.Arg(0) } @@ -41,7 +41,7 @@ func main() { cfg = LoadConfig() } - // --write-config: записать дефолтный конфиг и выйти + // --write-config if writeConfig { paths := ConfigPaths() target := "" @@ -52,7 +52,7 @@ func main() { } } if target == "" { - target = "md-editor.conf" + target = "grimoir.conf" } if err := WriteDefaultConfig(target); err != nil { fmt.Fprintf(os.Stderr, "Error writing config: %v\n", err) @@ -62,32 +62,57 @@ func main() { os.Exit(0) } - // === Если передан URL — скачиваем и конвертируем в Markdown === + // === Если файл не передан — показываем стартовое меню === + if filePath == "" { + result := RunMenu() + if result == nil { + // Пользователь закрыл меню без выбора + os.Exit(0) + } + + switch result.Action { + case menuQuit: + os.Exit(0) + + case menuHelp: + // Открываем редактор с пустым файлом и сразу показываем help + runEditor("untitled.md", cfg, true) + return + + case menuNewFile: + filePath = "untitled.md" + + case menuOpenFile: + filePath = result.FilePath + + case menuFetchURL: + filePath = result.URL + } + } + + // === URL — скачиваем и конвертируем === if IsURL(filePath) { fmt.Fprintf(os.Stderr, "Fetching: %s\n", filePath) - result, err := FetchAndConvert(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error fetching URL: %v\n", err) os.Exit(1) } - - // Сохраняем рядом с текущей директорией savedPath, err := SaveFetchResult(result, "") if err != nil { fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) os.Exit(1) } - fmt.Fprintf(os.Stderr, "Saved as: %s\n", savedPath) filePath = savedPath } - // === Обычный режим — открываем файл === - if filePath == "" { - filePath = "untitled.md" - } + runEditor(filePath, cfg, false) +} +// runEditor загружает файл и запускает редактор. +// showHelp=true открывает справку сразу при старте. +func runEditor(filePath string, cfg Config, showHelp bool) { content, err := LoadFile(filePath) if err != nil && !os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "Error loading file: %v\n", err) @@ -96,6 +121,9 @@ func main() { fileType := DetectFileType(filePath) model := NewEditor(filePath, fileType, content, cfg) + if showHelp { + model.showHelp = true + } p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { diff --git a/menu.go b/menu.go new file mode 100644 index 0000000..00c14aa --- /dev/null +++ b/menu.go @@ -0,0 +1,347 @@ +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 +} diff --git a/render.go b/render.go index 05185d6..4cbd0bb 100644 --- a/render.go +++ b/render.go @@ -23,7 +23,7 @@ var ( colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) ) -const grimVersion = "grimoir v1.0" +const grimVersion = "grimoir v0.4" // === Главная точка рендера ===