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.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 {
|
||||
|
|
|
|||
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)
|
||||
)
|
||||
|
||||
const grimVersion = "grimoir v1.0"
|
||||
const grimVersion = "grimoir v0.4"
|
||||
|
||||
// === Главная точка рендера ===
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue