Added menu

This commit is contained in:
Magnus Root 2026-05-07 17:28:57 +03:00
parent 81638bf5f8
commit 42bc698976
3 changed files with 388 additions and 13 deletions

52
main.go
View file

@ -18,7 +18,7 @@ func main() {
flag.BoolVar(&writeConfig, "write-config", false, "Write default config to standard location and exit") flag.BoolVar(&writeConfig, "write-config", false, "Write default config to standard location and exit")
flag.Parse() flag.Parse()
// Поддержка позиционного аргумента: grimoir file.txt или grimoir https://... // Позиционный аргумент: grimoir file.txt или grimoir https://...
if filePath == "" && flag.NArg() > 0 { if filePath == "" && flag.NArg() > 0 {
filePath = flag.Arg(0) filePath = flag.Arg(0)
} }
@ -41,7 +41,7 @@ func main() {
cfg = LoadConfig() cfg = LoadConfig()
} }
// --write-config: записать дефолтный конфиг и выйти // --write-config
if writeConfig { if writeConfig {
paths := ConfigPaths() paths := ConfigPaths()
target := "" target := ""
@ -52,7 +52,7 @@ func main() {
} }
} }
if target == "" { if target == "" {
target = "md-editor.conf" target = "grimoir.conf"
} }
if err := WriteDefaultConfig(target); err != nil { if err := WriteDefaultConfig(target); err != nil {
fmt.Fprintf(os.Stderr, "Error writing config: %v\n", err) fmt.Fprintf(os.Stderr, "Error writing config: %v\n", err)
@ -62,32 +62,57 @@ func main() {
os.Exit(0) 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) { if IsURL(filePath) {
fmt.Fprintf(os.Stderr, "Fetching: %s\n", filePath) fmt.Fprintf(os.Stderr, "Fetching: %s\n", filePath)
result, err := FetchAndConvert(filePath) result, err := FetchAndConvert(filePath)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching URL: %v\n", err) fmt.Fprintf(os.Stderr, "Error fetching URL: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Сохраняем рядом с текущей директорией
savedPath, err := SaveFetchResult(result, "") savedPath, err := SaveFetchResult(result, "")
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Fprintf(os.Stderr, "Saved as: %s\n", savedPath) fmt.Fprintf(os.Stderr, "Saved as: %s\n", savedPath)
filePath = savedPath filePath = savedPath
} }
// === Обычный режим — открываем файл === runEditor(filePath, cfg, false)
if filePath == "" { }
filePath = "untitled.md"
}
// runEditor загружает файл и запускает редактор.
// showHelp=true открывает справку сразу при старте.
func runEditor(filePath string, cfg Config, showHelp bool) {
content, err := LoadFile(filePath) content, err := LoadFile(filePath)
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error loading file: %v\n", err) fmt.Fprintf(os.Stderr, "Error loading file: %v\n", err)
@ -96,6 +121,9 @@ func main() {
fileType := DetectFileType(filePath) fileType := DetectFileType(filePath)
model := NewEditor(filePath, fileType, content, cfg) model := NewEditor(filePath, fileType, content, cfg)
if showHelp {
model.showHelp = true
}
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {

347
menu.go Normal file
View file

@ -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
}

View file

@ -23,7 +23,7 @@ var (
colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
) )
const grimVersion = "grimoir v1.0" const grimVersion = "grimoir v0.4"
// === Главная точка рендера === // === Главная точка рендера ===