Added menu
This commit is contained in:
parent
81638bf5f8
commit
42bc698976
3 changed files with 388 additions and 13 deletions
52
main.go
52
main.go
|
|
@ -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
347
menu.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
// === Главная точка рендера ===
|
// === Главная точка рендера ===
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue