grimoir/config.go
2026-07-04 13:04:33 +03:00

399 lines
No EOL
11 KiB
Go
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
// Config хранит все настройки редактора
type Config struct {
// [settings]
PreviewWidth int
LineNumbers bool
TabWidth int
AutosaveInterval int // секунды (0 = выключен)
ShowPreview bool
Theme string
// [keys] — map: действие → клавиша
Keys map[string]string
// Путь откуда загружен конфиг (для отображения в :set)
LoadedFrom string
}
// DefaultConfig возвращает конфиг со значениями по умолчанию
func DefaultConfig() Config {
return Config{
PreviewWidth: 40,
LineNumbers: true,
TabWidth: 4,
AutosaveInterval: 60,
ShowPreview: false, // включается автоматически для .md
Theme: "default",
Keys: defaultKeys(),
}
}
// defaultKeys возвращает стандартные биндинги
func defaultKeys() map[string]string {
return map[string]string{
"quit": "ctrl+c",
"quit_alt": "ctrl+d",
"redo": "ctrl+r",
"focus_switch": "tab",
"panel_shrink": "<",
"panel_grow": ">",
"toggle_preview": " ", // пробел
}
}
// ActionForKey возвращает имя действия для заданной клавиши,
// или "" если биндинг не найден
func (c *Config) ActionForKey(key string) string {
for action, boundKey := range c.Keys {
if boundKey == key {
return action
}
}
return ""
}
// KeyForAction возвращает клавишу для действия, или дефолтную если не переопределена
func (c *Config) KeyForAction(action string) string {
if k, ok := c.Keys[action]; ok {
return k
}
return defaultKeys()[action]
}
// === Поиск файла конфига ===
// configSearchPaths возвращает список путей для поиска конфига,
// в порядке приоритета (первый найденный используется)
func configSearchPaths() []string {
var paths []string
appName := "md-editor"
fileName := "md-editor.conf"
switch runtime.GOOS {
case "windows":
// %APPDATA%\md-editor\md-editor.conf
// %USERPROFILE%\md-editor.conf
if appData := os.Getenv("APPDATA"); appData != "" {
paths = append(paths, filepath.Join(appData, appName, fileName))
}
if userProfile := os.Getenv("USERPROFILE"); userProfile != "" {
paths = append(paths, filepath.Join(userProfile, fileName))
}
case "darwin":
// ~/Library/Application Support/md-editor/md-editor.conf
// ~/.config/md-editor/md-editor.conf
// ~/.md-editor.conf
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths,
filepath.Join(home, "Library", "Application Support", appName, fileName),
filepath.Join(home, ".config", appName, fileName),
filepath.Join(home, "."+fileName),
)
}
default: // linux и прочие Unix
// $XDG_CONFIG_HOME/md-editor/md-editor.conf (если задан)
// ~/.config/md-editor/md-editor.conf (XDG default)
// ~/.md-editor.conf
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
paths = append(paths, filepath.Join(xdg, appName, fileName))
}
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths,
filepath.Join(home, ".config", appName, fileName),
filepath.Join(home, "."+fileName),
)
}
}
// os.UserConfigDir() как дополнительный fallback (кроссплатформенный)
if dir, err := os.UserConfigDir(); err == nil {
candidate := filepath.Join(dir, appName, fileName)
if !containsPath(paths, candidate) {
paths = append(paths, candidate)
}
}
// Локальный конфиг в текущей директории (наивысший приоритет — добавляем первым)
localPath := fileName
if abs, err := filepath.Abs(localPath); err == nil {
localPath = abs
}
return append([]string{localPath}, paths...)
}
func containsPath(paths []string, p string) bool {
for _, existing := range paths {
if existing == p {
return true
}
}
return false
}
// === Загрузка конфига ===
// LoadConfig ищет и загружает конфиг. Возвращает DefaultConfig если файл не найден.
func LoadConfig() Config {
cfg := DefaultConfig()
for _, path := range configSearchPaths() {
data, err := os.ReadFile(path)
if err != nil {
continue
}
if err := parseConfig(data, &cfg); err != nil {
// Файл найден но с ошибками — сообщим при запуске
cfg.LoadedFrom = path + " (parse error: " + err.Error() + ")"
} else {
cfg.LoadedFrom = path
}
return cfg
}
cfg.LoadedFrom = "(defaults, config not found)"
return cfg
}
// ConfigPaths возвращает список путей поиска (для команды :config paths)
func ConfigPaths() []string {
return configSearchPaths()
}
// parseConfig разбирает INI-файл и заполняет cfg
func parseConfig(data []byte, cfg *Config) error {
scanner := bufio.NewScanner(strings.NewReader(string(data)))
section := ""
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// Пропускаем пустые строки и комментарии
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
// Секция [name]
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = strings.ToLower(line[1 : len(line)-1])
continue
}
// Пара key = value
eqIdx := strings.IndexByte(line, '=')
if eqIdx == -1 {
return fmt.Errorf("line %d: missing '=' in %q", lineNum, line)
}
key := strings.TrimSpace(line[:eqIdx])
value := strings.TrimSpace(line[eqIdx+1:])
// Удаляем инлайн-комментарии (# или ; после значения)
if idx := strings.IndexAny(value, "#;"); idx != -1 {
value = strings.TrimSpace(value[:idx])
}
key = strings.ToLower(key)
switch section {
case "settings":
if err := applySettingKey(cfg, key, value, lineNum); err != nil {
return err
}
case "keys":
if err := applyKeyBinding(cfg, key, value, lineNum); err != nil {
return err
}
case "":
// Секция не задана — пробуем применить как settings
if err := applySettingKey(cfg, key, value, lineNum); err != nil {
return fmt.Errorf("line %d: unknown key %q (no section specified)", lineNum, key)
}
default:
// Неизвестная секция — игнорируем
}
}
return scanner.Err()
}
// applySettingKey применяет одну настройку из секции [settings]
func applySettingKey(cfg *Config, key, value string, line int) error {
switch key {
case "preview_width", "previewwidth":
n, err := parseInt(value, line)
if err != nil {
return err
}
cfg.PreviewWidth = n
case "line_numbers", "linenumbers", "nu", "number":
b, err := parseBool(value, line)
if err != nil {
return err
}
cfg.LineNumbers = b
case "tab_width", "tabwidth", "tabstop", "ts":
n, err := parseInt(value, line)
if err != nil {
return err
}
if n < 1 || n > 16 {
return fmt.Errorf("line %d: tab_width must be 116", line)
}
cfg.TabWidth = n
case "autosave", "autosave_interval":
n, err := parseInt(value, line)
if err != nil {
return err
}
cfg.AutosaveInterval = n
case "show_preview", "preview":
b, err := parseBool(value, line)
if err != nil {
return err
}
cfg.ShowPreview = b
case "theme":
cfg.Theme = value
default:
return fmt.Errorf("line %d: unknown setting %q", line, key)
}
return nil
}
// applyKeyBinding применяет один биндинг из секции [keys]
func applyKeyBinding(cfg *Config, action, key string, line int) error {
// Нормализуем имя действия
action = strings.ToLower(strings.ReplaceAll(action, "-", "_"))
// Проверяем что действие известно
known := []string{
"quit", "quit_alt", "redo",
"panel_shrink", "panel_grow", "toggle_preview",
}
for _, a := range known {
if a == action {
cfg.Keys[action] = key
return nil
}
}
// Неизвестное действие — предупреждаем но не падаем
return fmt.Errorf("line %d: unknown action %q", line, action)
}
// === Хелперы парсинга ===
func parseInt(s string, line int) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("line %d: expected integer, got %q", line, s)
}
return n, nil
}
func parseBool(s string, line int) (bool, error) {
switch strings.ToLower(s) {
case "true", "yes", "on", "1":
return true, nil
case "false", "no", "off", "0":
return false, nil
}
return false, fmt.Errorf("line %d: expected bool (true/false/yes/no/on/off/1/0), got %q", line, s)
}
// === Сохранение конфига ===
// WriteDefaultConfig записывает дефолтный конфиг с комментариями в path
func WriteDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
content := `# md-editor configuration file
# Format: key = value
# Comments start with # or ;
[settings]
# Ширина панели preview (символы)
preview_width = 40
# Показывать номера строк (true/false)
line_numbers = true
# Ширина табуляции (116)
tab_width = 4
# Автосохранение каждые N секунд (0 = выключено)
autosave = 60
# Показывать preview при открытии .md файлов (true/false)
show_preview = false
# Тема (default — единственная пока)
theme = default
[keys]
# Формат: action = key
# Примеры клавиш: ctrl+c, ctrl+b, tab, f1, alt+t, <, >, " " (пробел)
# Выход
quit = ctrl+c
quit_alt = ctrl+d
# Redo
redo = ctrl+r
# Переключение фокуса между панелями
focus_switch = tab
# Уменьшить/увеличить ширину активной панели
panel_shrink = <
panel_grow = >
# Показать/скрыть preview (только для .md)
toggle_preview = " "
`
return os.WriteFile(path, []byte(content), 0644)
}
// === Применение конфига к модели ===
// ApplyConfig применяет загруженный конфиг к EditorModel
func (m *EditorModel) ApplyConfig(cfg Config) {
m.previewWidth = cfg.PreviewWidth
m.showLineNumbers = cfg.LineNumbers
m.tabWidth = cfg.TabWidth
m.autosaveInterval = cfg.AutosaveInterval
// ShowPreview из конфига применяется только если тип файла md —
// это проверяется при открытии файла, здесь только сохраняем флаг
m.cfgShowPreview = cfg.ShowPreview
m.config = cfg
m.clampPanelWidths()
}