Added new funcs
This commit is contained in:
parent
42bc698976
commit
68b3d1834a
25 changed files with 1139 additions and 860 deletions
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
ARCHITECTURE.md
Normal file → Executable file
0
ARCHITECTURE.md
Normal file → Executable file
0
LICENSE
Normal file → Executable file
0
LICENSE
Normal file → Executable file
0
README.md
Normal file → Executable file
0
README.md
Normal file → Executable file
54
commands.go
Normal file → Executable file
54
commands.go
Normal file → Executable file
|
|
@ -76,21 +76,6 @@ func (m *EditorModel) executeCommand(cmd string) tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// :treewidth N / :tw N
|
|
||||||
if strings.HasPrefix(cmd, "treewidth ") || strings.HasPrefix(cmd, "tw ") {
|
|
||||||
parts := strings.Fields(cmd)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
if n, err := strconv.Atoi(parts[1]); err == nil {
|
|
||||||
m.treeWidth = n
|
|
||||||
m.clampPanelWidths()
|
|
||||||
m.SetStatusMsg(fmt.Sprintf("Tree width: %d", m.treeWidth))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.SetStatusMsg("Usage: treewidth <N>")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// :previewwidth N / :pw N
|
// :previewwidth N / :pw N
|
||||||
if strings.HasPrefix(cmd, "previewwidth ") || strings.HasPrefix(cmd, "pw ") {
|
if strings.HasPrefix(cmd, "previewwidth ") || strings.HasPrefix(cmd, "pw ") {
|
||||||
parts := strings.Fields(cmd)
|
parts := strings.Fields(cmd)
|
||||||
|
|
@ -112,6 +97,10 @@ func (m *EditorModel) executeCommand(cmd string) tea.Cmd {
|
||||||
m.showHelp = true
|
m.showHelp = true
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "tzeentch":
|
||||||
|
m.triggerTzeentch()
|
||||||
|
return nil
|
||||||
|
|
||||||
case "w":
|
case "w":
|
||||||
if err := m.save(); err != nil {
|
if err := m.save(); err != nil {
|
||||||
m.SetStatusMsg("Error: " + err.Error())
|
m.SetStatusMsg("Error: " + err.Error())
|
||||||
|
|
@ -143,21 +132,6 @@ func (m *EditorModel) executeCommand(cmd string) tea.Cmd {
|
||||||
m.Undo()
|
m.Undo()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "tree":
|
|
||||||
m.showFileTree = true
|
|
||||||
m.fileTree.reload()
|
|
||||||
m.focus = FocusFileTree
|
|
||||||
m.fileTree.focused = true
|
|
||||||
m.clampPanelWidths()
|
|
||||||
m.SetStatusMsg(fmt.Sprintf("File tree ON (width: %d)", m.treeWidth))
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "notree":
|
|
||||||
m.showFileTree = false
|
|
||||||
m.focus = FocusEditor
|
|
||||||
m.fileTree.focused = false
|
|
||||||
m.SetStatusMsg("File tree OFF")
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "new":
|
case "new":
|
||||||
return m.cmdNew("")
|
return m.cmdNew("")
|
||||||
|
|
@ -264,6 +238,7 @@ func (m *EditorModel) cmdNew(name string) tea.Cmd {
|
||||||
m.redoStack = nil
|
m.redoStack = nil
|
||||||
m.searchMatches = nil
|
m.searchMatches = nil
|
||||||
m.showPreview = m.cfgShowPreview
|
m.showPreview = m.cfgShowPreview
|
||||||
|
m.previewDirty = true
|
||||||
m.mode = NORMAL
|
m.mode = NORMAL
|
||||||
m.updatePreview()
|
m.updatePreview()
|
||||||
m.SetStatusMsg("New file (save with :w <name>)")
|
m.SetStatusMsg("New file (save with :w <name>)")
|
||||||
|
|
@ -371,8 +346,8 @@ func (m *EditorModel) handleConfigCommand(sub string) {
|
||||||
switch sub {
|
switch sub {
|
||||||
case "", "show":
|
case "", "show":
|
||||||
m.SetStatusMsg(fmt.Sprintf(
|
m.SetStatusMsg(fmt.Sprintf(
|
||||||
"Config: %s | tw=%d pw=%d nu=%v tab=%d autosave=%ds",
|
"Config: %s | pw=%d nu=%v tab=%d autosave=%ds",
|
||||||
m.config.LoadedFrom, m.treeWidth, m.previewWidth,
|
m.config.LoadedFrom, m.previewWidth,
|
||||||
m.showLineNumbers, m.tabWidth, m.autosaveInterval,
|
m.showLineNumbers, m.tabWidth, m.autosaveInterval,
|
||||||
))
|
))
|
||||||
case "paths":
|
case "paths":
|
||||||
|
|
@ -440,9 +415,9 @@ func (m *EditorModel) handleSetCommand(cmd string) {
|
||||||
rest := strings.TrimSpace(strings.TrimPrefix(cmd, "set"))
|
rest := strings.TrimSpace(strings.TrimPrefix(cmd, "set"))
|
||||||
if rest == "" {
|
if rest == "" {
|
||||||
m.SetStatusMsg(fmt.Sprintf(
|
m.SetStatusMsg(fmt.Sprintf(
|
||||||
"nu=%v preview=%v tree=%v tw=%d pw=%d tab=%d autosave=%d",
|
"nu=%v preview=%v pw=%d tab=%d autosave=%d",
|
||||||
m.showLineNumbers, m.showPreview, m.showFileTree,
|
m.showLineNumbers, m.showPreview,
|
||||||
m.treeWidth, m.previewWidth, m.tabWidth, m.autosaveInterval,
|
m.previewWidth, m.tabWidth, m.autosaveInterval,
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -490,15 +465,6 @@ func (m *EditorModel) handleSetCommand(cmd string) {
|
||||||
} else {
|
} else {
|
||||||
m.SetStatusMsg(fmt.Sprintf("Autosave every %ds", n))
|
m.SetStatusMsg(fmt.Sprintf("Autosave every %ds", n))
|
||||||
}
|
}
|
||||||
case "treewidth", "tw":
|
|
||||||
n, err := strconv.Atoi(val)
|
|
||||||
if err != nil {
|
|
||||||
m.SetStatusMsg("Expected integer")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m.treeWidth = n
|
|
||||||
m.clampPanelWidths()
|
|
||||||
m.SetStatusMsg(fmt.Sprintf("Tree width: %d", m.treeWidth))
|
|
||||||
case "previewwidth", "pw":
|
case "previewwidth", "pw":
|
||||||
n, err := strconv.Atoi(val)
|
n, err := strconv.Atoi(val)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
35
config.go
Normal file → Executable file
35
config.go
Normal file → Executable file
|
|
@ -13,17 +13,14 @@ import (
|
||||||
// Config хранит все настройки редактора
|
// Config хранит все настройки редактора
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// [settings]
|
// [settings]
|
||||||
TreeWidth int
|
|
||||||
PreviewWidth int
|
PreviewWidth int
|
||||||
LineNumbers bool
|
LineNumbers bool
|
||||||
TabWidth int
|
TabWidth int
|
||||||
AutosaveInterval int // секунды (0 = выключен)
|
AutosaveInterval int // секунды (0 = выключен)
|
||||||
ShowPreview bool
|
ShowPreview bool
|
||||||
ShowTree bool
|
|
||||||
Theme string
|
Theme string
|
||||||
|
|
||||||
// [keys] — map: действие → клавиша
|
// [keys] — map: действие → клавиша
|
||||||
// Например: "toggle_tree" → "ctrl+b"
|
|
||||||
Keys map[string]string
|
Keys map[string]string
|
||||||
|
|
||||||
// Путь откуда загружен конфиг (для отображения в :set)
|
// Путь откуда загружен конфиг (для отображения в :set)
|
||||||
|
|
@ -33,13 +30,11 @@ type Config struct {
|
||||||
// DefaultConfig возвращает конфиг со значениями по умолчанию
|
// DefaultConfig возвращает конфиг со значениями по умолчанию
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
TreeWidth: 25,
|
|
||||||
PreviewWidth: 40,
|
PreviewWidth: 40,
|
||||||
LineNumbers: true,
|
LineNumbers: true,
|
||||||
TabWidth: 4,
|
TabWidth: 4,
|
||||||
AutosaveInterval: 60,
|
AutosaveInterval: 60,
|
||||||
ShowPreview: false, // включается автоматически для .md
|
ShowPreview: false, // включается автоматически для .md
|
||||||
ShowTree: false,
|
|
||||||
Theme: "default",
|
Theme: "default",
|
||||||
Keys: defaultKeys(),
|
Keys: defaultKeys(),
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +46,6 @@ func defaultKeys() map[string]string {
|
||||||
"quit": "ctrl+c",
|
"quit": "ctrl+c",
|
||||||
"quit_alt": "ctrl+d",
|
"quit_alt": "ctrl+d",
|
||||||
"redo": "ctrl+r",
|
"redo": "ctrl+r",
|
||||||
"toggle_tree": "ctrl+b",
|
|
||||||
"focus_switch": "tab",
|
"focus_switch": "tab",
|
||||||
"panel_shrink": "<",
|
"panel_shrink": "<",
|
||||||
"panel_grow": ">",
|
"panel_grow": ">",
|
||||||
|
|
@ -241,13 +235,6 @@ func parseConfig(data []byte, cfg *Config) error {
|
||||||
// applySettingKey применяет одну настройку из секции [settings]
|
// applySettingKey применяет одну настройку из секции [settings]
|
||||||
func applySettingKey(cfg *Config, key, value string, line int) error {
|
func applySettingKey(cfg *Config, key, value string, line int) error {
|
||||||
switch key {
|
switch key {
|
||||||
case "tree_width", "treewidth":
|
|
||||||
n, err := parseInt(value, line)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cfg.TreeWidth = n
|
|
||||||
|
|
||||||
case "preview_width", "previewwidth":
|
case "preview_width", "previewwidth":
|
||||||
n, err := parseInt(value, line)
|
n, err := parseInt(value, line)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -286,13 +273,6 @@ func applySettingKey(cfg *Config, key, value string, line int) error {
|
||||||
}
|
}
|
||||||
cfg.ShowPreview = b
|
cfg.ShowPreview = b
|
||||||
|
|
||||||
case "show_tree", "tree":
|
|
||||||
b, err := parseBool(value, line)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cfg.ShowTree = b
|
|
||||||
|
|
||||||
case "theme":
|
case "theme":
|
||||||
cfg.Theme = value
|
cfg.Theme = value
|
||||||
|
|
||||||
|
|
@ -309,7 +289,7 @@ func applyKeyBinding(cfg *Config, action, key string, line int) error {
|
||||||
|
|
||||||
// Проверяем что действие известно
|
// Проверяем что действие известно
|
||||||
known := []string{
|
known := []string{
|
||||||
"quit", "quit_alt", "redo", "toggle_tree", "focus_switch",
|
"quit", "quit_alt", "redo",
|
||||||
"panel_shrink", "panel_grow", "toggle_preview",
|
"panel_shrink", "panel_grow", "toggle_preview",
|
||||||
}
|
}
|
||||||
for _, a := range known {
|
for _, a := range known {
|
||||||
|
|
@ -356,9 +336,6 @@ func WriteDefaultConfig(path string) error {
|
||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
|
|
||||||
# Ширина панели файлового дерева (символы)
|
|
||||||
tree_width = 25
|
|
||||||
|
|
||||||
# Ширина панели preview (символы)
|
# Ширина панели preview (символы)
|
||||||
preview_width = 40
|
preview_width = 40
|
||||||
|
|
||||||
|
|
@ -374,8 +351,6 @@ autosave = 60
|
||||||
# Показывать preview при открытии .md файлов (true/false)
|
# Показывать preview при открытии .md файлов (true/false)
|
||||||
show_preview = false
|
show_preview = false
|
||||||
|
|
||||||
# Показывать файловое дерево при запуске (true/false)
|
|
||||||
show_tree = false
|
|
||||||
|
|
||||||
# Тема (default — единственная пока)
|
# Тема (default — единственная пока)
|
||||||
theme = default
|
theme = default
|
||||||
|
|
@ -393,8 +368,6 @@ quit_alt = ctrl+d
|
||||||
# Redo
|
# Redo
|
||||||
redo = ctrl+r
|
redo = ctrl+r
|
||||||
|
|
||||||
# Показать/скрыть файловое дерево
|
|
||||||
toggle_tree = ctrl+b
|
|
||||||
|
|
||||||
# Переключение фокуса между панелями
|
# Переключение фокуса между панелями
|
||||||
focus_switch = tab
|
focus_switch = tab
|
||||||
|
|
@ -413,16 +386,10 @@ toggle_preview = " "
|
||||||
|
|
||||||
// ApplyConfig применяет загруженный конфиг к EditorModel
|
// ApplyConfig применяет загруженный конфиг к EditorModel
|
||||||
func (m *EditorModel) ApplyConfig(cfg Config) {
|
func (m *EditorModel) ApplyConfig(cfg Config) {
|
||||||
m.treeWidth = cfg.TreeWidth
|
|
||||||
m.previewWidth = cfg.PreviewWidth
|
m.previewWidth = cfg.PreviewWidth
|
||||||
m.showLineNumbers = cfg.LineNumbers
|
m.showLineNumbers = cfg.LineNumbers
|
||||||
m.tabWidth = cfg.TabWidth
|
m.tabWidth = cfg.TabWidth
|
||||||
m.autosaveInterval = cfg.AutosaveInterval
|
m.autosaveInterval = cfg.AutosaveInterval
|
||||||
|
|
||||||
if cfg.ShowTree {
|
|
||||||
m.showFileTree = true
|
|
||||||
m.fileTree.reload()
|
|
||||||
}
|
|
||||||
// ShowPreview из конфига применяется только если тип файла md —
|
// ShowPreview из конфига применяется только если тип файла md —
|
||||||
// это проверяется при открытии файла, здесь только сохраняем флаг
|
// это проверяется при открытии файла, здесь только сохраняем флаг
|
||||||
m.cfgShowPreview = cfg.ShowPreview
|
m.cfgShowPreview = cfg.ShowPreview
|
||||||
|
|
|
||||||
0
fetch.go
Normal file → Executable file
0
fetch.go
Normal file → Executable file
0
file.go
Normal file → Executable file
0
file.go
Normal file → Executable file
0
file_io.go
Normal file → Executable file
0
file_io.go
Normal file → Executable file
305
filetree.go
305
filetree.go
|
|
@ -1,305 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// === Стили файлового дерева ===
|
|
||||||
var (
|
|
||||||
styleTreeDir = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Bold(true)
|
|
||||||
styleTreeFile = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
|
||||||
styleTreeSelected = lipgloss.NewStyle().Background(lipgloss.Color("237")).Foreground(lipgloss.Color("15")).Bold(true)
|
|
||||||
styleTreeHeader = lipgloss.NewStyle().Background(lipgloss.Color("8")).Foreground(lipgloss.Color("15")).Bold(true)
|
|
||||||
styleTreeCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileNode — узел файлового дерева
|
|
||||||
type FileNode struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
isDir bool
|
|
||||||
expanded bool
|
|
||||||
depth int
|
|
||||||
children []*FileNode
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileTree — состояние панели файлового дерева
|
|
||||||
type FileTree struct {
|
|
||||||
root string // корневая директория
|
|
||||||
nodes []*FileNode // плоский список видимых узлов
|
|
||||||
cursor int // индекс выбранного узла
|
|
||||||
scroll int // смещение прокрутки
|
|
||||||
focused bool // фокус на этой панели
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileTree создаёт новое дерево для директории rootDir
|
|
||||||
func NewFileTree(rootDir string) FileTree {
|
|
||||||
ft := FileTree{
|
|
||||||
root: rootDir,
|
|
||||||
cursor: 0,
|
|
||||||
scroll: 0,
|
|
||||||
focused: false,
|
|
||||||
}
|
|
||||||
ft.reload()
|
|
||||||
return ft
|
|
||||||
}
|
|
||||||
|
|
||||||
// reload перечитывает директорию и обновляет плоский список
|
|
||||||
func (ft *FileTree) reload() {
|
|
||||||
rootNode := &FileNode{
|
|
||||||
name: filepath.Base(ft.root),
|
|
||||||
path: ft.root,
|
|
||||||
isDir: true,
|
|
||||||
expanded: true,
|
|
||||||
depth: 0,
|
|
||||||
}
|
|
||||||
loadChildren(rootNode)
|
|
||||||
ft.nodes = flattenTree(rootNode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadChildren рекурсивно загружает дочерние узлы
|
|
||||||
func loadChildren(node *FileNode) {
|
|
||||||
if !node.isDir {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(node.path)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сортируем: сначала папки, потом файлы, алфавитно
|
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
|
||||||
if entries[i].IsDir() != entries[j].IsDir() {
|
|
||||||
return entries[i].IsDir()
|
|
||||||
}
|
|
||||||
return entries[i].Name() < entries[j].Name()
|
|
||||||
})
|
|
||||||
|
|
||||||
node.children = nil
|
|
||||||
for _, e := range entries {
|
|
||||||
// Пропускаем скрытые файлы и типичный мусор
|
|
||||||
name := e.Name()
|
|
||||||
if strings.HasPrefix(name, ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if name == "node_modules" || name == "vendor" || name == "__pycache__" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
child := &FileNode{
|
|
||||||
name: name,
|
|
||||||
path: filepath.Join(node.path, name),
|
|
||||||
isDir: e.IsDir(),
|
|
||||||
depth: node.depth + 1,
|
|
||||||
}
|
|
||||||
node.children = append(node.children, child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flattenTree возвращает плоский список видимых узлов
|
|
||||||
func flattenTree(node *FileNode) []*FileNode {
|
|
||||||
result := []*FileNode{node}
|
|
||||||
if node.isDir && node.expanded {
|
|
||||||
for _, child := range node.children {
|
|
||||||
result = append(result, flattenTree(child)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// toggle разворачивает/сворачивает директорию под курсором
|
|
||||||
func (ft *FileTree) toggle() {
|
|
||||||
if ft.cursor >= len(ft.nodes) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
node := ft.nodes[ft.cursor]
|
|
||||||
if !node.isDir {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
node.expanded = !node.expanded
|
|
||||||
if node.expanded && len(node.children) == 0 {
|
|
||||||
loadChildren(node)
|
|
||||||
}
|
|
||||||
ft.rebuildFlat()
|
|
||||||
}
|
|
||||||
|
|
||||||
// rebuildFlat пересобирает плоский список из корня
|
|
||||||
func (ft *FileTree) rebuildFlat() {
|
|
||||||
if len(ft.nodes) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root := ft.nodes[0]
|
|
||||||
ft.nodes = flattenTree(root)
|
|
||||||
if ft.cursor >= len(ft.nodes) {
|
|
||||||
ft.cursor = len(ft.nodes) - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// moveUp перемещает курсор вверх
|
|
||||||
func (ft *FileTree) moveUp(n int) {
|
|
||||||
ft.cursor -= n
|
|
||||||
if ft.cursor < 0 {
|
|
||||||
ft.cursor = 0
|
|
||||||
}
|
|
||||||
ft.adjustScroll(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// moveDown перемещает курсор вниз
|
|
||||||
func (ft *FileTree) moveDown(n int) {
|
|
||||||
ft.cursor += n
|
|
||||||
if ft.cursor >= len(ft.nodes) {
|
|
||||||
ft.cursor = len(ft.nodes) - 1
|
|
||||||
}
|
|
||||||
ft.adjustScroll(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// adjustScroll корректирует scroll чтобы cursor был виден
|
|
||||||
func (ft *FileTree) adjustScroll(height int) {
|
|
||||||
if height <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ft.cursor < ft.scroll {
|
|
||||||
ft.scroll = ft.cursor
|
|
||||||
}
|
|
||||||
if ft.cursor >= ft.scroll+height {
|
|
||||||
ft.scroll = ft.cursor - height + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectedPath возвращает путь выбранного узла (или "" если курсор вне границ)
|
|
||||||
func (ft *FileTree) selectedPath() string {
|
|
||||||
if ft.cursor < 0 || ft.cursor >= len(ft.nodes) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return ft.nodes[ft.cursor].path
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectedIsDir возвращает true если выбранный узел — директория
|
|
||||||
func (ft *FileTree) selectedIsDir() bool {
|
|
||||||
if ft.cursor < 0 || ft.cursor >= len(ft.nodes) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return ft.nodes[ft.cursor].isDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderFileTree рендерит панель файлового дерева
|
|
||||||
func renderFileTree(m *EditorModel) string {
|
|
||||||
ft := &m.fileTree
|
|
||||||
width := m.treeWidth
|
|
||||||
height := m.height - 3
|
|
||||||
|
|
||||||
var output strings.Builder
|
|
||||||
|
|
||||||
// Заголовок
|
|
||||||
title := filepath.Base(ft.root)
|
|
||||||
if len(title) > width-4 {
|
|
||||||
title = title[:width-4]
|
|
||||||
}
|
|
||||||
headerStyle := styleTreeHeader.Copy().Width(width).Padding(0, 1)
|
|
||||||
if ft.focused {
|
|
||||||
headerStyle = headerStyle.Background(lipgloss.Color("4"))
|
|
||||||
}
|
|
||||||
output.WriteString(headerStyle.Render(" 📁 " + title))
|
|
||||||
output.WriteString("\n")
|
|
||||||
|
|
||||||
ft.adjustScroll(height)
|
|
||||||
|
|
||||||
// Список файлов
|
|
||||||
for i := ft.scroll; i < ft.scroll+height && i < len(ft.nodes); i++ {
|
|
||||||
node := ft.nodes[i]
|
|
||||||
line := renderFileNode(node, m.filepath, width)
|
|
||||||
|
|
||||||
if i == ft.cursor && ft.focused {
|
|
||||||
line = styleTreeSelected.Render(line)
|
|
||||||
} else if i == ft.cursor {
|
|
||||||
line = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render(line)
|
|
||||||
}
|
|
||||||
// plain строки — без Width() рендера, joinColumns дополнит пробелами
|
|
||||||
|
|
||||||
output.WriteString(line)
|
|
||||||
output.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Заполнить пустые строки
|
|
||||||
rendered := min(height, max(0, len(ft.nodes)-ft.scroll))
|
|
||||||
for i := rendered; i < height; i++ {
|
|
||||||
output.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Нижняя строка с подсказкой
|
|
||||||
hint := " Enter:открыть Tab:фокус </>:ширина"
|
|
||||||
if len(hint) > width {
|
|
||||||
hint = hint[:width]
|
|
||||||
}
|
|
||||||
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
|
|
||||||
output.WriteString(hintStyle.Render(hint))
|
|
||||||
|
|
||||||
return output.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderFileNode рендерит одну строку узла
|
|
||||||
func renderFileNode(node *FileNode, currentFile string, width int) string {
|
|
||||||
// Отступ по глубине (корень не отступаем)
|
|
||||||
indent := ""
|
|
||||||
if node.depth > 0 {
|
|
||||||
indent = strings.Repeat(" ", node.depth-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var icon, name string
|
|
||||||
|
|
||||||
if node.isDir {
|
|
||||||
if node.expanded {
|
|
||||||
icon = "▾ "
|
|
||||||
} else {
|
|
||||||
icon = "▸ "
|
|
||||||
}
|
|
||||||
name = styleTreeDir.Render(node.name)
|
|
||||||
} else {
|
|
||||||
icon = " "
|
|
||||||
// Выделяем текущий открытый файл
|
|
||||||
if node.path == currentFile {
|
|
||||||
name = styleTreeCurrent.Render("● " + node.name)
|
|
||||||
} else {
|
|
||||||
name = styleTreeFile.Render(node.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
line := indent + icon + name
|
|
||||||
|
|
||||||
// Обрезаем если не влезает (с учётом ANSI-кодов это приблизительно)
|
|
||||||
visibleLen := len(indent) + len(icon) + len(node.name) + 2
|
|
||||||
if visibleLen > width {
|
|
||||||
// Обрезаем имя
|
|
||||||
maxName := width - len(indent) - len(icon) - 1
|
|
||||||
if maxName < 3 {
|
|
||||||
maxName = 3
|
|
||||||
}
|
|
||||||
truncated := node.name
|
|
||||||
if len(truncated) > maxName {
|
|
||||||
truncated = truncated[:maxName-1] + "…"
|
|
||||||
}
|
|
||||||
if node.isDir {
|
|
||||||
name = styleTreeDir.Render(truncated)
|
|
||||||
} else if node.path == currentFile {
|
|
||||||
name = styleTreeCurrent.Render("● " + truncated)
|
|
||||||
} else {
|
|
||||||
name = styleTreeFile.Render(truncated)
|
|
||||||
}
|
|
||||||
line = indent + icon + name
|
|
||||||
}
|
|
||||||
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
0
go.mod
Normal file → Executable file
0
go.mod
Normal file → Executable file
0
go.sum
Normal file → Executable file
0
go.sum
Normal file → Executable file
28
help.go
Normal file → Executable file
28
help.go
Normal file → Executable file
|
|
@ -57,12 +57,24 @@ var helpSections = []helpSection{
|
||||||
items: []helpItem{
|
items: []helpItem{
|
||||||
{"x", "удалить символ под курсором"},
|
{"x", "удалить символ под курсором"},
|
||||||
{"dd / 3dd", "удалить строку / 3 строки"},
|
{"dd / 3dd", "удалить строку / 3 строки"},
|
||||||
|
{"dw / de / db", "удалить до слова / конца слова / назад"},
|
||||||
{"yy", "скопировать строку"},
|
{"yy", "скопировать строку"},
|
||||||
{"p / P", "вставить ниже / выше"},
|
{"p / P", "вставить ниже / выше"},
|
||||||
{"u", "отменить (undo)"},
|
{"u", "отменить (undo)"},
|
||||||
{"Ctrl+R", "повторить (redo)"},
|
{"Ctrl+R", "повторить (redo)"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "ВЫДЕЛЕНИЕ (VISUAL LINE)",
|
||||||
|
items: []helpItem{
|
||||||
|
{"V", "войти в режим выделения строк"},
|
||||||
|
{"j / k", "расширить выделение вниз / вверх"},
|
||||||
|
{"gg / G", "выделить до начала / конца файла"},
|
||||||
|
{"d / x", "удалить выделенные строки"},
|
||||||
|
{"y", "скопировать выделенные строки"},
|
||||||
|
{"Esc / V", "выйти без действия"},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "ФАЙЛ",
|
title: "ФАЙЛ",
|
||||||
items: []helpItem{
|
items: []helpItem{
|
||||||
|
|
@ -81,25 +93,11 @@ var helpSections = []helpSection{
|
||||||
{
|
{
|
||||||
title: "ПАНЕЛИ",
|
title: "ПАНЕЛИ",
|
||||||
items: []helpItem{
|
items: []helpItem{
|
||||||
{"Ctrl+B", "показать / скрыть файловое дерево"},
|
{"< / >", "уменьшить / увеличить ширину preview"},
|
||||||
{"Tab", "переключить фокус между панелями"},
|
|
||||||
{"< / >", "уменьшить / увеличить ширину панели"},
|
|
||||||
{"Пробел", "показать / скрыть preview (только .md)"},
|
{"Пробел", "показать / скрыть preview (только .md)"},
|
||||||
{":treewidth N", "установить ширину дерева"},
|
|
||||||
{":previewwidth N", "установить ширину preview"},
|
{":previewwidth N", "установить ширину preview"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "ФАЙЛОВОЕ ДЕРЕВО (при фокусе)",
|
|
||||||
items: []helpItem{
|
|
||||||
{"j / k", "вниз / вверх"},
|
|
||||||
{"l / Enter", "открыть файл / развернуть папку"},
|
|
||||||
{"h", "свернуть папку / к родителю"},
|
|
||||||
{"gg / G", "начало / конец списка"},
|
|
||||||
{"r", "обновить дерево"},
|
|
||||||
{"Esc", "вернуть фокус редактору"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "ЭКСПОРТ И ЗАГРУЗКА",
|
title: "ЭКСПОРТ И ЗАГРУЗКА",
|
||||||
items: []helpItem{
|
items: []helpItem{
|
||||||
|
|
|
||||||
498
keybindings.go
Normal file → Executable file
498
keybindings.go
Normal file → Executable file
|
|
@ -1,83 +1,51 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleKey диспетчеризирует по фокусу и режиму
|
|
||||||
func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
|
|
||||||
// === Help экран — обрабатываем первым ===
|
// Попап Тзинча — любая клавиша закрывает
|
||||||
|
if m.showTzeentch {
|
||||||
|
m.showTzeentch = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help экран
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
if key == "q" || key == "esc" || key == "?" || key == ":help" {
|
if key == "q" || key == "esc" || key == "?" {
|
||||||
m.showHelp = false
|
m.showHelp = false
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Глобальные клавиши ===
|
// Глобальные клавиши
|
||||||
if key == m.config.KeyForAction("quit") || key == m.config.KeyForAction("quit_alt") {
|
if key == m.config.KeyForAction("quit") || key == m.config.KeyForAction("quit_alt") {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
if key == m.config.KeyForAction("toggle_tree") {
|
|
||||||
m.showFileTree = !m.showFileTree
|
|
||||||
if m.showFileTree {
|
|
||||||
m.fileTree.reload()
|
|
||||||
m.focus = FocusFileTree
|
|
||||||
m.fileTree.focused = true
|
|
||||||
m.SetStatusMsg("File tree ON")
|
|
||||||
} else {
|
|
||||||
m.focus = FocusEditor
|
|
||||||
m.fileTree.focused = false
|
|
||||||
m.SetStatusMsg("File tree OFF")
|
|
||||||
}
|
|
||||||
m.clampPanelWidths()
|
|
||||||
m.updatePreview()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if key == m.config.KeyForAction("focus_switch") {
|
|
||||||
if m.showFileTree {
|
|
||||||
if m.focus == FocusEditor {
|
|
||||||
m.focus = FocusFileTree
|
|
||||||
m.fileTree.focused = true
|
|
||||||
m.SetStatusMsg("Focus: file tree")
|
|
||||||
} else {
|
|
||||||
m.focus = FocusEditor
|
|
||||||
m.fileTree.focused = false
|
|
||||||
m.SetStatusMsg("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if key == m.config.KeyForAction("panel_shrink") || key == m.config.KeyForAction("panel_grow") {
|
if key == m.config.KeyForAction("panel_shrink") || key == m.config.KeyForAction("panel_grow") {
|
||||||
|
if m.showPreview && m.fileType == "md" {
|
||||||
delta := 2
|
delta := 2
|
||||||
if key == m.config.KeyForAction("panel_shrink") {
|
if key == m.config.KeyForAction("panel_shrink") {
|
||||||
delta = -2
|
delta = -2
|
||||||
}
|
}
|
||||||
if m.focus == FocusFileTree && m.showFileTree {
|
|
||||||
m.resizeTree(delta)
|
|
||||||
} else if m.showPreview && m.fileType == "md" {
|
|
||||||
m.resizePreview(delta)
|
m.resizePreview(delta)
|
||||||
}
|
|
||||||
m.updatePreview()
|
m.updatePreview()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Фокус на дереве ===
|
|
||||||
if m.focus == FocusFileTree {
|
|
||||||
return m.handleFileTreeKey(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Фокус на редакторе ===
|
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case NORMAL:
|
case NORMAL:
|
||||||
return m.handleNormalMode(msg)
|
return m.handleNormalMode(msg)
|
||||||
|
case VISUAL:
|
||||||
|
return m.handleVisualMode(msg)
|
||||||
case INSERT:
|
case INSERT:
|
||||||
return m.handleInsertMode(msg)
|
return m.handleInsertMode(msg)
|
||||||
case COMMAND:
|
case COMMAND:
|
||||||
|
|
@ -88,74 +56,110 @@ func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleFileTreeKey — клавиши при фокусе на дереве
|
// handleVisualMode — VISUAL LINE режим (построчное выделение, вход через V).
|
||||||
func (m *EditorModel) handleFileTreeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
// Курсор и visualStartY задают диапазон выделенных строк.
|
||||||
|
// Движение по строкам расширяет/сужает выделение, d/y применяются к диапазону,
|
||||||
|
// Esc выходит без действия.
|
||||||
|
func (m *EditorModel) handleVisualMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
ft := &m.fileTree
|
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
|
case "esc", "V":
|
||||||
|
m.mode = NORMAL
|
||||||
|
m.SetStatusMsg("")
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case "j", "down":
|
case "j", "down":
|
||||||
ft.moveDown(m.parseNumberPrefix())
|
m.moveByLines(m.parseNumberPrefix(), 1)
|
||||||
case "k", "up":
|
case "k", "up":
|
||||||
ft.moveUp(m.parseNumberPrefix())
|
m.moveByLines(m.parseNumberPrefix(), -1)
|
||||||
case "g":
|
case "g":
|
||||||
if m.pendingKeys == "g" {
|
if m.pendingKeys == "g" {
|
||||||
ft.cursor = 0
|
m.cursorY = 0
|
||||||
ft.scroll = 0
|
|
||||||
m.pendingKeys = ""
|
m.pendingKeys = ""
|
||||||
} else {
|
} else {
|
||||||
m.pendingKeys = "g"
|
m.pendingKeys = "g"
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
case "G":
|
case "G":
|
||||||
ft.cursor = len(ft.nodes) - 1
|
count := m.parseNumberPrefix()
|
||||||
ft.adjustScroll(m.height - 3)
|
if count == 1 && m.numPrefix == "" {
|
||||||
m.pendingKeys = ""
|
m.cursorY = len(m.lines) - 1
|
||||||
case "enter", "l":
|
} else if count > 0 && count <= len(m.lines) {
|
||||||
if ft.selectedIsDir() {
|
m.cursorY = count - 1
|
||||||
ft.toggle()
|
|
||||||
} else if path := ft.selectedPath(); path != "" {
|
|
||||||
m.openFile(path)
|
|
||||||
m.focus = FocusEditor
|
|
||||||
m.fileTree.focused = false
|
|
||||||
}
|
}
|
||||||
case "h":
|
|
||||||
if ft.cursor < len(ft.nodes) {
|
case "d", "x":
|
||||||
node := ft.nodes[ft.cursor]
|
start, end := m.visualRange()
|
||||||
if node.isDir && node.expanded {
|
m.saveUndoSnapshot()
|
||||||
ft.toggle()
|
for i := start; i <= end; i++ {
|
||||||
} else if node.depth > 0 {
|
m.deleteLine(start)
|
||||||
for i := ft.cursor - 1; i >= 0; i-- {
|
|
||||||
if ft.nodes[i].depth < node.depth {
|
|
||||||
ft.cursor = i
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
m.cursorY = start
|
||||||
|
m.CursorWithinBounds()
|
||||||
|
m.MarkDirty()
|
||||||
|
m.mode = NORMAL
|
||||||
|
n := end - start + 1
|
||||||
|
if n == 1 {
|
||||||
|
m.SetStatusMsg("1 line deleted")
|
||||||
|
} else {
|
||||||
|
m.SetStatusMsg(fmt.Sprintf("%d lines deleted", n))
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "y":
|
||||||
|
start, end := m.visualRange()
|
||||||
|
m.clipboard = strings.Join(m.lines[start:end+1], "\n")
|
||||||
|
m.cursorY = start
|
||||||
|
m.CursorWithinBounds()
|
||||||
|
m.mode = NORMAL
|
||||||
|
n := end - start + 1
|
||||||
|
if n == 1 {
|
||||||
|
m.SetStatusMsg("1 line yanked")
|
||||||
|
} else {
|
||||||
|
m.SetStatusMsg(fmt.Sprintf("%d lines yanked", n))
|
||||||
}
|
}
|
||||||
}
|
return m, nil
|
||||||
case "r":
|
|
||||||
ft.reload()
|
|
||||||
m.SetStatusMsg("File tree refreshed")
|
|
||||||
case "esc":
|
|
||||||
m.focus = FocusEditor
|
|
||||||
m.fileTree.focused = false
|
|
||||||
m.SetStatusMsg("")
|
|
||||||
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
|
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
|
||||||
m.numPrefix += key
|
m.numPrefix += key
|
||||||
m.SetStatusMsg(m.numPrefix)
|
m.SetStatusMsg(m.numPrefix)
|
||||||
|
return m, nil
|
||||||
case "0":
|
case "0":
|
||||||
if m.numPrefix != "" {
|
if m.numPrefix != "" {
|
||||||
m.numPrefix += key
|
m.numPrefix += key
|
||||||
m.SetStatusMsg(m.numPrefix)
|
m.SetStatusMsg(m.numPrefix)
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.updateVisualStatus()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNormalMode — NORMAL режим
|
// visualRange возвращает нормализованный диапазон [start, end] выделенных строк
|
||||||
|
func (m *EditorModel) visualRange() (start, end int) {
|
||||||
|
start, end = m.visualStartY, m.cursorY
|
||||||
|
if start > end {
|
||||||
|
start, end = end, start
|
||||||
|
}
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateVisualStatus обновляет статус-строку с количеством выделенных строк
|
||||||
|
func (m *EditorModel) updateVisualStatus() {
|
||||||
|
start, end := m.visualRange()
|
||||||
|
n := end - start + 1
|
||||||
|
if n == 1 {
|
||||||
|
m.SetStatusMsg("1 line selected")
|
||||||
|
} else {
|
||||||
|
m.SetStatusMsg(fmt.Sprintf("%d lines selected", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
|
|
||||||
// Числовые префиксы
|
|
||||||
if key >= "1" && key <= "9" && m.pendingKeys == "" {
|
if key >= "1" && key <= "9" && m.pendingKeys == "" {
|
||||||
m.numPrefix += key
|
m.numPrefix += key
|
||||||
m.SetStatusMsg(m.numPrefix)
|
m.SetStatusMsg(m.numPrefix)
|
||||||
|
|
@ -179,6 +183,11 @@ func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
case "?":
|
case "?":
|
||||||
m.showHelp = true
|
m.showHelp = true
|
||||||
return m, nil
|
return m, nil
|
||||||
|
case "V":
|
||||||
|
m.mode = VISUAL
|
||||||
|
m.visualStartY = m.cursorY
|
||||||
|
m.SetStatusMsg("")
|
||||||
|
return m, nil
|
||||||
case m.config.KeyForAction("toggle_preview"):
|
case m.config.KeyForAction("toggle_preview"):
|
||||||
if m.fileType == "md" {
|
if m.fileType == "md" {
|
||||||
m.showPreview = !m.showPreview
|
m.showPreview = !m.showPreview
|
||||||
|
|
@ -194,6 +203,15 @@ func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.updatePreview()
|
m.updatePreview()
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
// Операторы d/y/c с motion (dw, de, d$, dd, yw и т.д.)
|
||||||
|
// должны перехватываться ДО handleMovement, иначе motion
|
||||||
|
// просто подвинет курсор вместо применения оператора.
|
||||||
|
if m.pendingKeys == "d" || m.pendingKeys == "y" || m.pendingKeys == "c" {
|
||||||
|
if m.handleOperatorMotion(key) {
|
||||||
|
m.updatePreview()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
m.handleMovement(key)
|
m.handleMovement(key)
|
||||||
m.handleEditing(key)
|
m.handleEditing(key)
|
||||||
m.handleSearchKeys(key)
|
m.handleSearchKeys(key)
|
||||||
|
|
@ -207,23 +225,22 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
|
||||||
switch key {
|
switch key {
|
||||||
case "i":
|
case "i":
|
||||||
m.mode = INSERT
|
m.mode = INSERT
|
||||||
m.SetStatusMsg("-- INSERT --")
|
m.SetStatusMsg("")
|
||||||
case "a":
|
case "a":
|
||||||
m.mode = INSERT
|
m.mode = INSERT
|
||||||
// 'a' — после курсора: сдвигаем на одну руну вправо
|
|
||||||
line := []rune(m.lines[m.cursorY])
|
line := []rune(m.lines[m.cursorY])
|
||||||
if m.cursorX < len(line) {
|
if m.cursorX < len(line) {
|
||||||
m.cursorX++
|
m.cursorX++
|
||||||
}
|
}
|
||||||
m.SetStatusMsg("-- INSERT --")
|
m.SetStatusMsg("")
|
||||||
case "A":
|
case "A":
|
||||||
m.mode = INSERT
|
m.mode = INSERT
|
||||||
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
||||||
m.SetStatusMsg("-- INSERT --")
|
m.SetStatusMsg("")
|
||||||
case "I":
|
case "I":
|
||||||
m.mode = INSERT
|
m.mode = INSERT
|
||||||
m.cursorX = 0
|
m.cursorX = 0
|
||||||
m.SetStatusMsg("-- INSERT --")
|
m.SetStatusMsg("")
|
||||||
case "o":
|
case "o":
|
||||||
m.saveUndoSnapshot()
|
m.saveUndoSnapshot()
|
||||||
m.insertLine(m.cursorY + 1)
|
m.insertLine(m.cursorY + 1)
|
||||||
|
|
@ -231,14 +248,14 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
|
||||||
m.cursorX = 0
|
m.cursorX = 0
|
||||||
m.mode = INSERT
|
m.mode = INSERT
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
m.SetStatusMsg("-- INSERT --")
|
m.SetStatusMsg("")
|
||||||
case "O":
|
case "O":
|
||||||
m.saveUndoSnapshot()
|
m.saveUndoSnapshot()
|
||||||
m.insertLine(m.cursorY)
|
m.insertLine(m.cursorY)
|
||||||
m.cursorX = 0
|
m.cursorX = 0
|
||||||
m.mode = INSERT
|
m.mode = INSERT
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
m.SetStatusMsg("-- INSERT --")
|
m.SetStatusMsg("")
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -248,16 +265,14 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
|
||||||
func (m *EditorModel) handleMovement(key string) {
|
func (m *EditorModel) handleMovement(key string) {
|
||||||
switch key {
|
switch key {
|
||||||
case "h", "left":
|
case "h", "left":
|
||||||
count := m.parseNumberPrefix()
|
m.cursorX -= m.parseNumberPrefix()
|
||||||
m.cursorX -= count
|
|
||||||
m.CursorWithinBounds()
|
m.CursorWithinBounds()
|
||||||
case "j", "down":
|
case "j", "down":
|
||||||
m.moveByLines(m.parseNumberPrefix(), 1)
|
m.moveByLines(m.parseNumberPrefix(), 1)
|
||||||
case "k", "up":
|
case "k", "up":
|
||||||
m.moveByLines(m.parseNumberPrefix(), -1)
|
m.moveByLines(m.parseNumberPrefix(), -1)
|
||||||
case "l", "right":
|
case "l", "right":
|
||||||
count := m.parseNumberPrefix()
|
m.cursorX += m.parseNumberPrefix()
|
||||||
m.cursorX += count
|
|
||||||
m.CursorWithinBounds()
|
m.CursorWithinBounds()
|
||||||
case "w":
|
case "w":
|
||||||
m.moveByWords(m.parseNumberPrefix(), true)
|
m.moveByWords(m.parseNumberPrefix(), true)
|
||||||
|
|
@ -274,9 +289,17 @@ func (m *EditorModel) handleMovement(key string) {
|
||||||
case "$":
|
case "$":
|
||||||
m.numPrefix = ""
|
m.numPrefix = ""
|
||||||
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
||||||
case "^", "0":
|
case "0":
|
||||||
m.numPrefix = ""
|
m.numPrefix = ""
|
||||||
m.cursorX = 0
|
m.cursorX = 0
|
||||||
|
case "^":
|
||||||
|
m.numPrefix = ""
|
||||||
|
line := []rune(m.lines[m.cursorY])
|
||||||
|
x := 0
|
||||||
|
for x < len(line) && (line[x] == ' ' || line[x] == '\t') {
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
m.cursorX = x
|
||||||
case "g":
|
case "g":
|
||||||
if m.pendingKeys == "g" {
|
if m.pendingKeys == "g" {
|
||||||
m.cursorY = 0
|
m.cursorY = 0
|
||||||
|
|
@ -312,6 +335,240 @@ func (m *EditorModel) handleMovement(key string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleOperatorMotion обрабатывает d{motion} / y{motion} / c{motion}
|
||||||
|
// (dw, de, db, d$, d0, d^, dd, yw, ye, yy и т.д.)
|
||||||
|
// Возвращает true если ключ был обработан как часть operator+motion.
|
||||||
|
func (m *EditorModel) handleOperatorMotion(key string) bool {
|
||||||
|
op := m.pendingKeys
|
||||||
|
count := m.parseNumberPrefix()
|
||||||
|
|
||||||
|
startX, startY := m.cursorX, m.cursorY
|
||||||
|
line := []rune(m.lines[m.cursorY])
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "w":
|
||||||
|
// dw/yw — от курсора до начала следующего слова (исключая его)
|
||||||
|
endX, endY := startX, startY
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
endX, endY = m.wordForwardPos(endX, endY)
|
||||||
|
}
|
||||||
|
m.applyOperatorRange(op, startX, startY, endX, endY, false)
|
||||||
|
m.pendingKeys = ""
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "e":
|
||||||
|
// de/ye — от курсора до конца текущего/следующего слова (включая последний символ)
|
||||||
|
endX, endY := startX, startY
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
endX, endY = m.wordEndPos(endX, endY)
|
||||||
|
}
|
||||||
|
m.applyOperatorRange(op, startX, startY, endX, endY, true)
|
||||||
|
m.pendingKeys = ""
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "b":
|
||||||
|
// db/yb — от начала текущего/предыдущего слова до курсора
|
||||||
|
startX2, startY2 := startX, startY
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
startX2, startY2 = m.wordBackPos(startX2, startY2)
|
||||||
|
}
|
||||||
|
m.applyOperatorRange(op, startX2, startY2, startX, startY, false)
|
||||||
|
m.pendingKeys = ""
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "$":
|
||||||
|
// d$/y$ — от курсора до конца строки (включительно)
|
||||||
|
endX := len(line)
|
||||||
|
m.applyOperatorRange(op, startX, startY, endX, startY, false)
|
||||||
|
m.pendingKeys = ""
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "0", "^":
|
||||||
|
// d0/y0 — от начала строки до курсора (исключая текущий символ)
|
||||||
|
m.applyOperatorRange(op, 0, startY, startX, startY, false)
|
||||||
|
m.pendingKeys = ""
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "d":
|
||||||
|
// dd/yy — вся строка(и)
|
||||||
|
if op == "d" {
|
||||||
|
m.saveUndoSnapshot()
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
m.deleteLine(m.cursorY)
|
||||||
|
}
|
||||||
|
m.CursorWithinBounds()
|
||||||
|
m.MarkDirty()
|
||||||
|
} else if op == "y" {
|
||||||
|
end := m.cursorY + count
|
||||||
|
if end > len(m.lines) {
|
||||||
|
end = len(m.lines)
|
||||||
|
}
|
||||||
|
m.clipboard = strings.Join(m.lines[m.cursorY:end], "\n")
|
||||||
|
m.SetStatusMsg("Line(s) yanked")
|
||||||
|
}
|
||||||
|
m.pendingKeys = ""
|
||||||
|
return true
|
||||||
|
|
||||||
|
case "y", "c":
|
||||||
|
// yy уже покрыт выше через "d" case проверки op=="y";
|
||||||
|
// здесь второй y для тех кто печатает "yy" — key == "y" совпадает с op == "y"
|
||||||
|
if key == op {
|
||||||
|
if op == "y" {
|
||||||
|
m.clipboard = m.lines[m.cursorY]
|
||||||
|
m.SetStatusMsg("Line yanked")
|
||||||
|
}
|
||||||
|
m.pendingKeys = ""
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Неизвестный motion для оператора — сбрасываем pending,
|
||||||
|
// чтобы не залипнуть в режиме ожидания
|
||||||
|
m.pendingKeys = ""
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOperatorRange применяет delete или yank к диапазону [startX,startY)-(endX,endY)
|
||||||
|
// inclusive=true означает что символ под endX тоже входит в диапазон (для de/ye)
|
||||||
|
func (m *EditorModel) applyOperatorRange(op string, startX, startY, endX, endY int, inclusive bool) {
|
||||||
|
// Нормализуем порядок (motion может быть "назад", как у b)
|
||||||
|
if startY > endY || (startY == endY && startX > endX) {
|
||||||
|
startX, endX = endX, startX
|
||||||
|
startY, endY = endY, startY
|
||||||
|
}
|
||||||
|
|
||||||
|
if inclusive {
|
||||||
|
endX++
|
||||||
|
}
|
||||||
|
|
||||||
|
if startY == endY {
|
||||||
|
line := []rune(m.lines[startY])
|
||||||
|
if startX < 0 {
|
||||||
|
startX = 0
|
||||||
|
}
|
||||||
|
if endX > len(line) {
|
||||||
|
endX = len(line)
|
||||||
|
}
|
||||||
|
if startX >= endX {
|
||||||
|
m.pendingKeys = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := string(line[startX:endX])
|
||||||
|
|
||||||
|
if op == "y" {
|
||||||
|
m.clipboard = text
|
||||||
|
m.SetStatusMsg("Yanked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete (op == "d", change handled same way for now)
|
||||||
|
m.saveUndoSnapshot()
|
||||||
|
newLine := append(append([]rune{}, line[:startX]...), line[endX:]...)
|
||||||
|
m.lines[startY] = string(newLine)
|
||||||
|
m.cursorX = startX
|
||||||
|
m.cursorY = startY
|
||||||
|
m.CursorWithinBounds()
|
||||||
|
m.MarkDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Многострочный диапазон — пока редкий случай для w/e/b (обычно не выходят за строку),
|
||||||
|
// но на всякий случай обрабатываем как соединение строк.
|
||||||
|
firstLine := []rune(m.lines[startY])
|
||||||
|
lastLine := []rune(m.lines[endY])
|
||||||
|
if startX > len(firstLine) {
|
||||||
|
startX = len(firstLine)
|
||||||
|
}
|
||||||
|
if endX > len(lastLine) {
|
||||||
|
endX = len(lastLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
if op == "y" {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(string(firstLine[startX:]))
|
||||||
|
for i := startY + 1; i < endY; i++ {
|
||||||
|
sb.WriteString("\n" + m.lines[i])
|
||||||
|
}
|
||||||
|
sb.WriteString("\n" + string(lastLine[:endX]))
|
||||||
|
m.clipboard = sb.String()
|
||||||
|
m.SetStatusMsg("Yanked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.saveUndoSnapshot()
|
||||||
|
merged := string(firstLine[:startX]) + string(lastLine[endX:])
|
||||||
|
newLines := append([]string{}, m.lines[:startY]...)
|
||||||
|
newLines = append(newLines, merged)
|
||||||
|
newLines = append(newLines, m.lines[endY+1:]...)
|
||||||
|
m.lines = newLines
|
||||||
|
m.cursorX = startX
|
||||||
|
m.cursorY = startY
|
||||||
|
m.CursorWithinBounds()
|
||||||
|
m.MarkDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordForwardPos возвращает позицию начала следующего слова от (x,y) — логика как у "w"
|
||||||
|
func (m *EditorModel) wordForwardPos(x, y int) (int, int) {
|
||||||
|
line := []rune(m.lines[y])
|
||||||
|
// Пропускаем текущее слово
|
||||||
|
for x < len(line) && line[x] != ' ' {
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
// Пропускаем пробелы
|
||||||
|
for x < len(line) && line[x] == ' ' {
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
if x >= len(line) && y < len(m.lines)-1 {
|
||||||
|
return 0, y + 1
|
||||||
|
}
|
||||||
|
return x, y
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordEndPos возвращает позицию конца текущего/следующего слова — логика как у "e"
|
||||||
|
func (m *EditorModel) wordEndPos(x, y int) (int, int) {
|
||||||
|
line := []rune(m.lines[y])
|
||||||
|
if x < len(line) {
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
for x < len(line) && line[x] == ' ' {
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
for x < len(line) && line[x] != ' ' {
|
||||||
|
x++
|
||||||
|
}
|
||||||
|
if x > 0 {
|
||||||
|
x--
|
||||||
|
}
|
||||||
|
if x >= len(line) && y < len(m.lines)-1 {
|
||||||
|
return 0, y + 1
|
||||||
|
}
|
||||||
|
return x, y
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordBackPos возвращает позицию начала текущего/предыдущего слова — логика как у "b"
|
||||||
|
func (m *EditorModel) wordBackPos(x, y int) (int, int) {
|
||||||
|
line := []rune(m.lines[y])
|
||||||
|
if x > 0 {
|
||||||
|
x--
|
||||||
|
} else if y > 0 {
|
||||||
|
y--
|
||||||
|
line = []rune(m.lines[y])
|
||||||
|
x = len(line)
|
||||||
|
if x > 0 {
|
||||||
|
x--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for x > 0 && line[x] == ' ' {
|
||||||
|
x--
|
||||||
|
}
|
||||||
|
for x > 0 && line[x-1] != ' ' {
|
||||||
|
x--
|
||||||
|
}
|
||||||
|
return x, y
|
||||||
|
}
|
||||||
|
|
||||||
func (m *EditorModel) handleEditing(key string) {
|
func (m *EditorModel) handleEditing(key string) {
|
||||||
switch key {
|
switch key {
|
||||||
case "x":
|
case "x":
|
||||||
|
|
@ -320,24 +577,15 @@ func (m *EditorModel) handleEditing(key string) {
|
||||||
m.CursorWithinBounds()
|
m.CursorWithinBounds()
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
case "d":
|
case "d":
|
||||||
if m.pendingKeys == "d" {
|
// dd/dw/de/d$ и т.д. обрабатываются в handleOperatorMotion
|
||||||
count := m.parseNumberPrefix()
|
// (перехватывается раньше handleMovement/handleEditing).
|
||||||
m.saveUndoSnapshot()
|
// Сюда попадаем только при первом "d" — устанавливаем pending.
|
||||||
for i := 0; i < count; i++ {
|
if m.pendingKeys == "" {
|
||||||
m.deleteLine(m.cursorY)
|
|
||||||
}
|
|
||||||
m.CursorWithinBounds()
|
|
||||||
m.MarkDirty()
|
|
||||||
m.pendingKeys = ""
|
|
||||||
} else if m.pendingKeys == "" {
|
|
||||||
m.pendingKeys = "d"
|
m.pendingKeys = "d"
|
||||||
}
|
}
|
||||||
case "y":
|
case "y":
|
||||||
if m.pendingKeys == "y" {
|
// yy/yw/ye и т.д. — аналогично, через handleOperatorMotion.
|
||||||
m.clipboard = m.lines[m.cursorY]
|
if m.pendingKeys == "" {
|
||||||
m.SetStatusMsg("Line yanked")
|
|
||||||
m.pendingKeys = ""
|
|
||||||
} else if m.pendingKeys == "" {
|
|
||||||
m.pendingKeys = "y"
|
m.pendingKeys = "y"
|
||||||
}
|
}
|
||||||
case "p":
|
case "p":
|
||||||
|
|
@ -361,7 +609,6 @@ func (m *EditorModel) handleEditing(key string) {
|
||||||
|
|
||||||
func (m *EditorModel) handleSearchKeys(key string) {
|
func (m *EditorModel) handleSearchKeys(key string) {
|
||||||
if m.pendingKeys == "f" || m.pendingKeys == "F" || m.pendingKeys == "t" || m.pendingKeys == "T" {
|
if m.pendingKeys == "f" || m.pendingKeys == "F" || m.pendingKeys == "t" || m.pendingKeys == "T" {
|
||||||
// Берём первую руну из нажатой клавиши
|
|
||||||
runes := []rune(key)
|
runes := []rune(key)
|
||||||
if len(runes) == 1 {
|
if len(runes) == 1 {
|
||||||
char := runes[0]
|
char := runes[0]
|
||||||
|
|
@ -380,14 +627,11 @@ func (m *EditorModel) handleSearchKeys(key string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInsertMode — INSERT режим с полной поддержкой Unicode и навигации
|
|
||||||
func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
case "esc":
|
case "esc":
|
||||||
m.mode = NORMAL
|
m.mode = NORMAL
|
||||||
// В NORMAL курсор не может стоять после последнего символа
|
|
||||||
line := []rune(m.lines[m.cursorY])
|
line := []rune(m.lines[m.cursorY])
|
||||||
if m.cursorX > 0 && m.cursorX >= len(line) {
|
if m.cursorX > 0 && m.cursorX >= len(line) {
|
||||||
m.cursorX = len(line) - 1
|
m.cursorX = len(line) - 1
|
||||||
|
|
@ -396,7 +640,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.cursorX = 0
|
m.cursorX = 0
|
||||||
}
|
}
|
||||||
m.SetStatusMsg("")
|
m.SetStatusMsg("")
|
||||||
|
|
||||||
case "up":
|
case "up":
|
||||||
m.moveByLines(1, -1)
|
m.moveByLines(1, -1)
|
||||||
case "down":
|
case "down":
|
||||||
|
|
@ -405,7 +648,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
if m.cursorX > 0 {
|
if m.cursorX > 0 {
|
||||||
m.cursorX--
|
m.cursorX--
|
||||||
} else if m.cursorY > 0 {
|
} else if m.cursorY > 0 {
|
||||||
// Переходим на конец предыдущей строки
|
|
||||||
m.cursorY--
|
m.cursorY--
|
||||||
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
||||||
}
|
}
|
||||||
|
|
@ -414,7 +656,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
if m.cursorX < len(line) {
|
if m.cursorX < len(line) {
|
||||||
m.cursorX++
|
m.cursorX++
|
||||||
} else if m.cursorY < len(m.lines)-1 {
|
} else if m.cursorY < len(m.lines)-1 {
|
||||||
// Переходим на начало следующей строки
|
|
||||||
m.cursorY++
|
m.cursorY++
|
||||||
m.cursorX = 0
|
m.cursorX = 0
|
||||||
}
|
}
|
||||||
|
|
@ -422,14 +663,12 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.cursorX = 0
|
m.cursorX = 0
|
||||||
case "end":
|
case "end":
|
||||||
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
m.saveUndoSnapshot()
|
m.saveUndoSnapshot()
|
||||||
m.splitLineRune(m.cursorY, m.cursorX)
|
m.splitLineRune(m.cursorY, m.cursorX)
|
||||||
m.cursorY++
|
m.cursorY++
|
||||||
m.cursorX = 0
|
m.cursorX = 0
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
|
|
||||||
case "backspace":
|
case "backspace":
|
||||||
m.saveUndoSnapshot()
|
m.saveUndoSnapshot()
|
||||||
if m.cursorX > 0 {
|
if m.cursorX > 0 {
|
||||||
|
|
@ -437,7 +676,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.cursorX--
|
m.cursorX--
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
} else if m.cursorY > 0 {
|
} else if m.cursorY > 0 {
|
||||||
// Merge с предыдущей строкой
|
|
||||||
prevLen := len([]rune(m.lines[m.cursorY-1]))
|
prevLen := len([]rune(m.lines[m.cursorY-1]))
|
||||||
m.lines[m.cursorY-1] += m.lines[m.cursorY]
|
m.lines[m.cursorY-1] += m.lines[m.cursorY]
|
||||||
m.lines = append(m.lines[:m.cursorY], m.lines[m.cursorY+1:]...)
|
m.lines = append(m.lines[:m.cursorY], m.lines[m.cursorY+1:]...)
|
||||||
|
|
@ -445,7 +683,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.cursorX = prevLen
|
m.cursorX = prevLen
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
case "delete":
|
case "delete":
|
||||||
m.saveUndoSnapshot()
|
m.saveUndoSnapshot()
|
||||||
line := []rune(m.lines[m.cursorY])
|
line := []rune(m.lines[m.cursorY])
|
||||||
|
|
@ -453,38 +690,30 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.deleteCharAt(m.cursorY, m.cursorX)
|
m.deleteCharAt(m.cursorY, m.cursorX)
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
} else if m.cursorY < len(m.lines)-1 {
|
} else if m.cursorY < len(m.lines)-1 {
|
||||||
// Merge со следующей строкой
|
|
||||||
m.lines[m.cursorY] += m.lines[m.cursorY+1]
|
m.lines[m.cursorY] += m.lines[m.cursorY+1]
|
||||||
m.lines = append(m.lines[:m.cursorY+1], m.lines[m.cursorY+2:]...)
|
m.lines = append(m.lines[:m.cursorY+1], m.lines[m.cursorY+2:]...)
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tab":
|
case "tab":
|
||||||
m.saveUndoSnapshot()
|
m.saveUndoSnapshot()
|
||||||
m.insertRuneAt(m.cursorY, m.cursorX, []rune(strings.Repeat(" ", m.tabWidth)))
|
m.insertRuneAt(m.cursorY, m.cursorX, []rune(strings.Repeat(" ", m.tabWidth)))
|
||||||
m.cursorX += m.tabWidth
|
m.cursorX += m.tabWidth
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Любой печатаемый символ — включая кириллицу и другие Unicode
|
|
||||||
// Пробел приходит как tea.KeySpace, остальные печатаемые — tea.KeyRunes
|
|
||||||
runes := []rune(msg.String())
|
runes := []rune(msg.String())
|
||||||
isPrintable := msg.Type == tea.KeyRunes ||
|
if len(runes) == 1 && runes[0] >= 32 &&
|
||||||
msg.Type == tea.KeySpace
|
(msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace) {
|
||||||
if len(runes) == 1 && runes[0] >= 32 && isPrintable {
|
|
||||||
m.saveUndoSnapshot()
|
m.saveUndoSnapshot()
|
||||||
m.insertRuneAt(m.cursorY, m.cursorX, runes)
|
m.insertRuneAt(m.cursorY, m.cursorX, runes)
|
||||||
m.cursorX++
|
m.cursorX++
|
||||||
m.MarkDirty()
|
m.MarkDirty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m.CursorWithinBounds()
|
m.CursorWithinBounds()
|
||||||
m.updatePreview()
|
m.updatePreview()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCommandMode — режим команд
|
|
||||||
func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
|
|
@ -503,7 +732,6 @@ func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
m.SetStatusMsg(":" + m.commandBuffer)
|
m.SetStatusMsg(":" + m.commandBuffer)
|
||||||
default:
|
default:
|
||||||
// Принимаем Unicode в командном буфере
|
|
||||||
runes := []rune(msg.String())
|
runes := []rune(msg.String())
|
||||||
if len(runes) == 1 && (runes[0] >= 32 || msg.Type == tea.KeyRunes) {
|
if len(runes) == 1 && (runes[0] >= 32 || msg.Type == tea.KeyRunes) {
|
||||||
m.commandBuffer += string(runes)
|
m.commandBuffer += string(runes)
|
||||||
|
|
@ -513,7 +741,6 @@ func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSearchMode — режим поиска
|
|
||||||
func (m *EditorModel) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *EditorModel) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
|
|
@ -564,9 +791,8 @@ func (m *EditorModel) movePrevWord() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Редактирование (rune-safe) ===
|
// === Редактирование ===
|
||||||
|
|
||||||
// insertRuneAt вставляет руны в строку на позиции pos (в рунах)
|
|
||||||
func (m *EditorModel) insertRuneAt(lineIdx, pos int, runes []rune) {
|
func (m *EditorModel) insertRuneAt(lineIdx, pos int, runes []rune) {
|
||||||
line := []rune(m.lines[lineIdx])
|
line := []rune(m.lines[lineIdx])
|
||||||
if pos < 0 {
|
if pos < 0 {
|
||||||
|
|
@ -582,7 +808,6 @@ func (m *EditorModel) insertRuneAt(lineIdx, pos int, runes []rune) {
|
||||||
m.lines[lineIdx] = string(newLine)
|
m.lines[lineIdx] = string(newLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteCharAt удаляет символ на позиции pos (в рунах)
|
|
||||||
func (m *EditorModel) deleteCharAt(lineIdx, pos int) {
|
func (m *EditorModel) deleteCharAt(lineIdx, pos int) {
|
||||||
line := []rune(m.lines[lineIdx])
|
line := []rune(m.lines[lineIdx])
|
||||||
if pos < 0 || pos >= len(line) {
|
if pos < 0 || pos >= len(line) {
|
||||||
|
|
@ -591,7 +816,6 @@ func (m *EditorModel) deleteCharAt(lineIdx, pos int) {
|
||||||
m.lines[lineIdx] = string(append(line[:pos], line[pos+1:]...))
|
m.lines[lineIdx] = string(append(line[:pos], line[pos+1:]...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitLineRune разбивает строку на позиции pos (в рунах)
|
|
||||||
func (m *EditorModel) splitLineRune(lineIdx, pos int) {
|
func (m *EditorModel) splitLineRune(lineIdx, pos int) {
|
||||||
line := []rune(m.lines[lineIdx])
|
line := []rune(m.lines[lineIdx])
|
||||||
if pos < 0 {
|
if pos < 0 {
|
||||||
|
|
|
||||||
19
main.go
Normal file → Executable file
19
main.go
Normal file → Executable file
|
|
@ -80,7 +80,10 @@ func main() {
|
||||||
return
|
return
|
||||||
|
|
||||||
case menuNewFile:
|
case menuNewFile:
|
||||||
filePath = "untitled.md"
|
// Гарантированно пустой буфер — даже если untitled.md уже существует
|
||||||
|
// на диске от прошлой сессии, не подгружаем его содержимое.
|
||||||
|
runNewEditor(cfg)
|
||||||
|
return
|
||||||
|
|
||||||
case menuOpenFile:
|
case menuOpenFile:
|
||||||
filePath = result.FilePath
|
filePath = result.FilePath
|
||||||
|
|
@ -110,6 +113,20 @@ func main() {
|
||||||
runEditor(filePath, cfg, false)
|
runEditor(filePath, cfg, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runNewEditor запускает редактор с гарантированно пустым буфером,
|
||||||
|
// не читая существующий файл с диска (используется для "Новый файл" в меню).
|
||||||
|
// Имя файла остаётся "untitled.md" — пользователь сохранит его явным :w/:saveas,
|
||||||
|
// что не перезапишет существующий файл, так как save() запросит подтверждение
|
||||||
|
// только при явном указании того же имени.
|
||||||
|
func runNewEditor(cfg Config) {
|
||||||
|
model := NewEditor("untitled.md", "md", "", cfg)
|
||||||
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// runEditor загружает файл и запускает редактор.
|
// runEditor загружает файл и запускает редактор.
|
||||||
// showHelp=true открывает справку сразу при старте.
|
// showHelp=true открывает справку сразу при старте.
|
||||||
func runEditor(filePath string, cfg Config, showHelp bool) {
|
func runEditor(filePath string, cfg Config, showHelp bool) {
|
||||||
|
|
|
||||||
72
menu.go
Normal file → Executable file
72
menu.go
Normal file → Executable file
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
@ -60,6 +61,13 @@ type menuModel struct {
|
||||||
input string // буфер для ввода пути/URL
|
input string // буфер для ввода пути/URL
|
||||||
inputMode string // "" / "file" / "url"
|
inputMode string // "" / "file" / "url"
|
||||||
err string // сообщение об ошибке
|
err string // сообщение об ошибке
|
||||||
|
|
||||||
|
// Детектор аномально быстрого ввода (вставка большого текста посимвольно).
|
||||||
|
// Терминал может разбивать paste на отдельные KeyMsg без bracketed-paste
|
||||||
|
// маркеров — тогда обычная защита по длине срабатывает слишком поздно,
|
||||||
|
// так как Ctrl+C встаёт в конец уже накопленной очереди событий.
|
||||||
|
lastKeyTime time.Time
|
||||||
|
rapidKeyCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// doneMsg сигнализирует о завершении меню
|
// doneMsg сигнализирует о завершении меню
|
||||||
|
|
@ -131,11 +139,15 @@ func (m menuModel) selectItem(action menuAction) (tea.Model, tea.Cmd) {
|
||||||
m.inputMode = "file"
|
m.inputMode = "file"
|
||||||
m.input = ""
|
m.input = ""
|
||||||
m.err = ""
|
m.err = ""
|
||||||
|
m.rapidKeyCount = 0
|
||||||
|
m.lastKeyTime = time.Time{}
|
||||||
return m, nil
|
return m, nil
|
||||||
case menuFetchURL:
|
case menuFetchURL:
|
||||||
m.inputMode = "url"
|
m.inputMode = "url"
|
||||||
m.input = ""
|
m.input = ""
|
||||||
m.err = ""
|
m.err = ""
|
||||||
|
m.rapidKeyCount = 0
|
||||||
|
m.lastKeyTime = time.Time{}
|
||||||
return m, nil
|
return m, nil
|
||||||
case menuHelp:
|
case menuHelp:
|
||||||
return m, func() tea.Msg { return menuDoneMsg{MenuResult{Action: menuHelp}} }
|
return m, func() tea.Msg { return menuDoneMsg{MenuResult{Action: menuHelp}} }
|
||||||
|
|
@ -144,11 +156,40 @@ func (m menuModel) selectItem(action menuAction) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m menuModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m menuModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
// Детектор вставки большого текста посимвольно: если терминал не
|
||||||
|
// поддерживает bracketed paste, каждый символ из paste приходит как
|
||||||
|
// отдельный KeyMsg практически без задержки между ними (быстрее чем
|
||||||
|
// способен печатать человек — порог 8мс). Считаем подряд идущие
|
||||||
|
// быстрые нажатия и при превышении порога обрываем ввод немедленно,
|
||||||
|
// не дожидаясь пока вся вставка "проедется" через очередь событий.
|
||||||
|
now := time.Now()
|
||||||
|
if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace {
|
||||||
|
if !m.lastKeyTime.IsZero() && now.Sub(m.lastKeyTime) < 8*time.Millisecond {
|
||||||
|
m.rapidKeyCount++
|
||||||
|
} else {
|
||||||
|
m.rapidKeyCount = 0
|
||||||
|
}
|
||||||
|
m.lastKeyTime = now
|
||||||
|
|
||||||
|
const rapidThreshold = 20
|
||||||
|
if m.rapidKeyCount >= rapidThreshold {
|
||||||
|
m.input = ""
|
||||||
|
m.rapidKeyCount = 0
|
||||||
|
m.err = "Обнаружена вставка текста — поле очищено, введите URL вручную"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
|
case "ctrl+c":
|
||||||
|
// Гарантированный выход даже если что-то пошло не так с вводом
|
||||||
|
// (например случайная вставка большого текста)
|
||||||
|
return m, tea.Quit
|
||||||
case "esc":
|
case "esc":
|
||||||
m.inputMode = ""
|
m.inputMode = ""
|
||||||
m.input = ""
|
m.input = ""
|
||||||
m.err = ""
|
m.err = ""
|
||||||
|
m.rapidKeyCount = 0
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.input == "" {
|
if m.input == "" {
|
||||||
m.err = "Введите значение"
|
m.err = "Введите значение"
|
||||||
|
|
@ -173,7 +214,13 @@ func (m menuModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.err = ""
|
m.err = ""
|
||||||
default:
|
default:
|
||||||
if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace {
|
if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace {
|
||||||
|
// Лимит защищает от случайной вставки большого текста
|
||||||
|
// (например содержимого файла через paste), которая иначе
|
||||||
|
// засоряет поле ввода и делает интерфейс нечитаемым.
|
||||||
|
const maxInputLen = 500
|
||||||
|
if len([]rune(m.input)) < maxInputLen {
|
||||||
m.input += msg.String()
|
m.input += msg.String()
|
||||||
|
}
|
||||||
m.err = ""
|
m.err = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -291,7 +338,20 @@ func (m menuModel) View() string {
|
||||||
prompt = "URL статьи: "
|
prompt = "URL статьи: "
|
||||||
}
|
}
|
||||||
|
|
||||||
promptLine := promptStyle.Render(prompt) + inputStyle.Render(m.input+"▌")
|
// Обрезаем отображаемую часть ввода чтобы не ломать вёрстку,
|
||||||
|
// если что-то вставило длинный текст. Показываем хвост —
|
||||||
|
// он ближе к курсору и обычно важнее для проверки.
|
||||||
|
displayInput := m.input
|
||||||
|
maxVisible := m.width - visibleWidth(prompt) - 10
|
||||||
|
if maxVisible < 10 {
|
||||||
|
maxVisible = 10
|
||||||
|
}
|
||||||
|
inputRunes := []rune(displayInput)
|
||||||
|
if len(inputRunes) > maxVisible {
|
||||||
|
displayInput = "…" + string(inputRunes[len(inputRunes)-maxVisible+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
promptLine := promptStyle.Render(prompt) + inputStyle.Render(displayInput+"▌")
|
||||||
promptVis := visibleWidth(promptLine)
|
promptVis := visibleWidth(promptLine)
|
||||||
promptPad := (m.width - promptVis) / 2
|
promptPad := (m.width - promptVis) / 2
|
||||||
if promptPad < 0 {
|
if promptPad < 0 {
|
||||||
|
|
@ -310,7 +370,17 @@ func (m menuModel) View() string {
|
||||||
}
|
}
|
||||||
out.WriteString(strings.Repeat(" ", errPad))
|
out.WriteString(strings.Repeat(" ", errPad))
|
||||||
out.WriteString(errLine)
|
out.WriteString(errLine)
|
||||||
|
out.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inputHint := dimStyle.Render("Enter подтвердить Esc отмена Ctrl+C выход")
|
||||||
|
inputHintVis := visibleWidth(inputHint)
|
||||||
|
inputHintPad := (m.width - inputHintVis) / 2
|
||||||
|
if inputHintPad < 0 {
|
||||||
|
inputHintPad = 0
|
||||||
|
}
|
||||||
|
out.WriteString(strings.Repeat(" ", inputHintPad))
|
||||||
|
out.WriteString(inputHint)
|
||||||
out.WriteString("\n")
|
out.WriteString("\n")
|
||||||
} else {
|
} else {
|
||||||
hint := dimStyle.Render("↑↓ навигация Enter выбор q выйти")
|
hint := dimStyle.Render("↑↓ навигация Enter выбор q выйти")
|
||||||
|
|
|
||||||
233
model.go
Normal file → Executable file
233
model.go
Normal file → Executable file
|
|
@ -1,7 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -20,14 +19,6 @@ const (
|
||||||
REPLACE
|
REPLACE
|
||||||
)
|
)
|
||||||
|
|
||||||
// PanelFocus — какая панель принимает клавиши
|
|
||||||
type PanelFocus int
|
|
||||||
|
|
||||||
const (
|
|
||||||
FocusEditor PanelFocus = iota
|
|
||||||
FocusFileTree
|
|
||||||
)
|
|
||||||
|
|
||||||
// undoSnapshot — снимок состояния для undo/redo
|
// undoSnapshot — снимок состояния для undo/redo
|
||||||
type undoSnapshot struct {
|
type undoSnapshot struct {
|
||||||
lines []string
|
lines []string
|
||||||
|
|
@ -35,12 +26,8 @@ type undoSnapshot struct {
|
||||||
cursorY int
|
cursorY int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Минимальные ширины панелей
|
// Минимальная ширина панели preview
|
||||||
const (
|
const minPreviewWidth = 20
|
||||||
minEditorWidth = 20
|
|
||||||
minTreeWidth = 15
|
|
||||||
minPreviewWidth = 20
|
|
||||||
)
|
|
||||||
|
|
||||||
type EditorModel struct {
|
type EditorModel struct {
|
||||||
// Контент
|
// Контент
|
||||||
|
|
@ -51,10 +38,7 @@ type EditorModel struct {
|
||||||
// Vim режимы
|
// Vim режимы
|
||||||
mode VimMode
|
mode VimMode
|
||||||
pendingKeys string
|
pendingKeys string
|
||||||
selection struct {
|
visualStartY int // строка где начато VISUAL LINE выделение
|
||||||
startX, startY int
|
|
||||||
endX, endY int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Файл
|
// Файл
|
||||||
filepath string
|
filepath string
|
||||||
|
|
@ -63,28 +47,24 @@ type EditorModel struct {
|
||||||
lastSave time.Time
|
lastSave time.Time
|
||||||
statusMsg string
|
statusMsg string
|
||||||
|
|
||||||
// UI — размеры
|
// UI
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
treeWidth int
|
|
||||||
previewWidth int
|
previewWidth int
|
||||||
|
|
||||||
// Видимость панелей
|
// Видимость панелей
|
||||||
showFileTree bool
|
|
||||||
showPreview bool
|
showPreview bool
|
||||||
|
|
||||||
// Фокус
|
// Настройки
|
||||||
focus PanelFocus
|
|
||||||
|
|
||||||
// Настройки (из конфига или :set)
|
|
||||||
showLineNumbers bool
|
showLineNumbers bool
|
||||||
tabWidth int
|
tabWidth int
|
||||||
autosaveInterval int // секунды
|
autosaveInterval int
|
||||||
cfgShowPreview bool // дефолт для preview из конфига
|
cfgShowPreview bool
|
||||||
|
|
||||||
// Viewport
|
// Viewport
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
previewBuf string
|
previewBuf string
|
||||||
|
previewDirty bool
|
||||||
|
|
||||||
// Команда
|
// Команда
|
||||||
commandBuffer string
|
commandBuffer string
|
||||||
|
|
@ -111,31 +91,27 @@ type EditorModel struct {
|
||||||
undoStack []undoSnapshot
|
undoStack []undoSnapshot
|
||||||
redoStack []undoSnapshot
|
redoStack []undoSnapshot
|
||||||
|
|
||||||
// Файловое дерево
|
|
||||||
fileTree FileTree
|
|
||||||
|
|
||||||
// Help экран
|
// Help экран
|
||||||
showHelp bool
|
showHelp bool
|
||||||
|
|
||||||
// Конфиг (хранится для :config show)
|
// Пасхалка
|
||||||
|
showTzeentch bool
|
||||||
|
tzeentchIdx int
|
||||||
|
|
||||||
|
// Конфиг
|
||||||
config Config
|
config Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// Типы событий
|
// Типы событий
|
||||||
type TickMsg time.Time
|
type TickMsg time.Time
|
||||||
|
|
||||||
// NewEditor создаёт новый редактор с применением конфига
|
// NewEditor создаёт новый редактор
|
||||||
func NewEditor(path, fileType, content string, cfg Config) *EditorModel {
|
func NewEditor(path, fileType, content string, cfg Config) *EditorModel {
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
lines = []string{""}
|
lines = []string{""}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootDir := filepath.Dir(path)
|
|
||||||
if rootDir == "" || rootDir == "." {
|
|
||||||
rootDir, _ = filepath.Abs(".")
|
|
||||||
}
|
|
||||||
|
|
||||||
m := &EditorModel{
|
m := &EditorModel{
|
||||||
lines: lines,
|
lines: lines,
|
||||||
filepath: path,
|
filepath: path,
|
||||||
|
|
@ -148,27 +124,14 @@ func NewEditor(path, fileType, content string, cfg Config) *EditorModel {
|
||||||
tabWidth: cfg.TabWidth,
|
tabWidth: cfg.TabWidth,
|
||||||
autosaveInterval: cfg.AutosaveInterval,
|
autosaveInterval: cfg.AutosaveInterval,
|
||||||
cfgShowPreview: cfg.ShowPreview,
|
cfgShowPreview: cfg.ShowPreview,
|
||||||
showPreview: cfg.ShowPreview && fileType == "md" || fileType == "md" && cfg.ShowPreview,
|
showPreview: cfg.ShowPreview && fileType == "md",
|
||||||
showFileTree: cfg.ShowTree,
|
|
||||||
focus: FocusEditor,
|
|
||||||
lastSave: time.Now(),
|
lastSave: time.Now(),
|
||||||
viewport: viewport.New(0, 0),
|
viewport: viewport.New(0, 0),
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 30,
|
height: 30,
|
||||||
treeWidth: cfg.TreeWidth,
|
|
||||||
previewWidth: cfg.PreviewWidth,
|
previewWidth: cfg.PreviewWidth,
|
||||||
|
previewDirty: true,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
fileTree: NewFileTree(rootDir),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Для markdown включаем preview если задано в конфиге или по умолчанию
|
|
||||||
if fileType == "md" && cfg.ShowPreview {
|
|
||||||
m.showPreview = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.ShowTree {
|
|
||||||
m.fileTree.reload()
|
|
||||||
m.fileTree.focused = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m.clampPanelWidths()
|
m.clampPanelWidths()
|
||||||
|
|
@ -176,7 +139,6 @@ func NewEditor(path, fileType, content string, cfg Config) *EditorModel {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init инициализирует модель
|
|
||||||
func (m *EditorModel) Init() tea.Cmd {
|
func (m *EditorModel) Init() tea.Cmd {
|
||||||
return m.tickCmd()
|
return m.tickCmd()
|
||||||
}
|
}
|
||||||
|
|
@ -184,14 +146,13 @@ func (m *EditorModel) Init() tea.Cmd {
|
||||||
func (m *EditorModel) tickCmd() tea.Cmd {
|
func (m *EditorModel) tickCmd() tea.Cmd {
|
||||||
interval := time.Duration(m.autosaveInterval) * time.Second
|
interval := time.Duration(m.autosaveInterval) * time.Second
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = 60 * time.Second // fallback
|
interval = 60 * time.Second
|
||||||
}
|
}
|
||||||
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
||||||
return TickMsg(t)
|
return TickMsg(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update обрабатывает сообщения
|
|
||||||
func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
|
|
@ -203,7 +164,31 @@ func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
return m.handleKey(msg)
|
// Bubble Tea использует scroll-region оптимизацию терминала при
|
||||||
|
// построчном сдвиге видимой области текста. На некоторых терминалах
|
||||||
|
// и мультиплексорах (замечено в Zellij) это приводит к артефактам:
|
||||||
|
// при прокрутке вверх старая строка остаётся видна поверх новой,
|
||||||
|
// потому что терминал "доскроллил" контент вместо полной перерисовки.
|
||||||
|
// Решение: сравниваем видимый диапазон строк до и после обработки
|
||||||
|
// клавиши, и если он сдвинулся — форсируем tea.ClearScreen, что
|
||||||
|
// гарантирует полную перерисовку без scroll-оптимизаций.
|
||||||
|
edHeight := m.height - 3
|
||||||
|
if edHeight < 1 {
|
||||||
|
edHeight = 1
|
||||||
|
}
|
||||||
|
prevStart, _ := visibleRange(m.cursorY, edHeight, len(m.lines))
|
||||||
|
|
||||||
|
newModel, cmd := m.handleKey(msg)
|
||||||
|
em, ok := newModel.(*EditorModel)
|
||||||
|
if !ok {
|
||||||
|
return newModel, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
newStart, _ := visibleRange(em.cursorY, edHeight, len(em.lines))
|
||||||
|
if newStart != prevStart {
|
||||||
|
return em, tea.Batch(cmd, tea.ClearScreen)
|
||||||
|
}
|
||||||
|
return em, cmd
|
||||||
|
|
||||||
case TickMsg:
|
case TickMsg:
|
||||||
if m.autosaveInterval > 0 &&
|
if m.autosaveInterval > 0 &&
|
||||||
|
|
@ -217,84 +202,40 @@ func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// View возвращает строку для вывода
|
|
||||||
func (m *EditorModel) View() string {
|
func (m *EditorModel) View() string {
|
||||||
|
if m.showTzeentch {
|
||||||
|
return renderTzeentch(m)
|
||||||
|
}
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
return renderHelp(m)
|
return renderHelp(m)
|
||||||
}
|
}
|
||||||
switch {
|
if m.showPreview && m.fileType == "md" {
|
||||||
case m.showFileTree && m.showPreview && m.fileType == "md":
|
return renderSplit(m)
|
||||||
return m.viewTreeEditorPreview()
|
|
||||||
case m.showFileTree:
|
|
||||||
return m.viewTreeEditor()
|
|
||||||
case m.showPreview && m.fileType == "md":
|
|
||||||
return m.viewEditorPreview()
|
|
||||||
default:
|
|
||||||
return m.viewEditorOnly()
|
|
||||||
}
|
}
|
||||||
|
return renderEditor(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *EditorModel) viewEditorOnly() string { return renderEditor(m) }
|
// === Ширины панелей ===
|
||||||
func (m *EditorModel) viewEditorPreview() string { return renderSplit(m) }
|
|
||||||
func (m *EditorModel) viewTreeEditor() string { return renderTreeEditor(m) }
|
|
||||||
func (m *EditorModel) viewTreeEditorPreview() string { return renderTreeEditorPreview(m) }
|
|
||||||
|
|
||||||
// === Вычисление ширин ===
|
|
||||||
|
|
||||||
func (m *EditorModel) editorWidth() int {
|
func (m *EditorModel) editorWidth() int {
|
||||||
w := m.width
|
w := m.width
|
||||||
if m.showFileTree {
|
|
||||||
w -= m.treeWidth
|
|
||||||
}
|
|
||||||
if m.showPreview && m.fileType == "md" {
|
if m.showPreview && m.fileType == "md" {
|
||||||
w -= m.previewWidth
|
w -= m.previewWidth
|
||||||
}
|
}
|
||||||
if w < minEditorWidth {
|
if w < 20 {
|
||||||
w = minEditorWidth
|
w = 20
|
||||||
}
|
}
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *EditorModel) clampPanelWidths() {
|
func (m *EditorModel) clampPanelWidths() {
|
||||||
if m.treeWidth < minTreeWidth {
|
|
||||||
m.treeWidth = minTreeWidth
|
|
||||||
}
|
|
||||||
if m.previewWidth < minPreviewWidth {
|
if m.previewWidth < minPreviewWidth {
|
||||||
m.previewWidth = minPreviewWidth
|
m.previewWidth = minPreviewWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
needed := minEditorWidth
|
|
||||||
if m.showFileTree {
|
|
||||||
needed += m.treeWidth
|
|
||||||
}
|
|
||||||
if m.showPreview && m.fileType == "md" {
|
if m.showPreview && m.fileType == "md" {
|
||||||
needed += m.previewWidth
|
needed := 20 + m.previewWidth
|
||||||
}
|
|
||||||
|
|
||||||
if needed > m.width && m.width > 0 {
|
if needed > m.width && m.width > 0 {
|
||||||
available := m.width - minEditorWidth
|
m.previewWidth = m.width - 20
|
||||||
if available < 0 {
|
|
||||||
available = 0
|
|
||||||
}
|
|
||||||
if m.showFileTree && m.showPreview && m.fileType == "md" {
|
|
||||||
total := m.treeWidth + m.previewWidth
|
|
||||||
if total > 0 {
|
|
||||||
m.treeWidth = available * m.treeWidth / total
|
|
||||||
m.previewWidth = available - m.treeWidth
|
|
||||||
}
|
|
||||||
if m.treeWidth < minTreeWidth {
|
|
||||||
m.treeWidth = minTreeWidth
|
|
||||||
}
|
|
||||||
if m.previewWidth < minPreviewWidth {
|
|
||||||
m.previewWidth = minPreviewWidth
|
|
||||||
}
|
|
||||||
} else if m.showFileTree {
|
|
||||||
m.treeWidth = available
|
|
||||||
if m.treeWidth < minTreeWidth {
|
|
||||||
m.treeWidth = minTreeWidth
|
|
||||||
}
|
|
||||||
} else if m.showPreview {
|
|
||||||
m.previewWidth = available
|
|
||||||
if m.previewWidth < minPreviewWidth {
|
if m.previewWidth < minPreviewWidth {
|
||||||
m.previewWidth = minPreviewWidth
|
m.previewWidth = minPreviewWidth
|
||||||
}
|
}
|
||||||
|
|
@ -302,35 +243,18 @@ func (m *EditorModel) clampPanelWidths() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *EditorModel) resizeTree(delta int) {
|
|
||||||
m.treeWidth += delta
|
|
||||||
if m.treeWidth < minTreeWidth {
|
|
||||||
m.treeWidth = minTreeWidth
|
|
||||||
}
|
|
||||||
maxTree := m.width - minEditorWidth
|
|
||||||
if m.showPreview && m.fileType == "md" {
|
|
||||||
maxTree -= m.previewWidth
|
|
||||||
}
|
|
||||||
if m.treeWidth > maxTree {
|
|
||||||
m.treeWidth = maxTree
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *EditorModel) resizePreview(delta int) {
|
func (m *EditorModel) resizePreview(delta int) {
|
||||||
m.previewWidth += delta
|
m.previewWidth += delta
|
||||||
if m.previewWidth < minPreviewWidth {
|
if m.previewWidth < minPreviewWidth {
|
||||||
m.previewWidth = minPreviewWidth
|
m.previewWidth = minPreviewWidth
|
||||||
}
|
}
|
||||||
maxPreview := m.width - minEditorWidth
|
max := m.width - 20
|
||||||
if m.showFileTree {
|
if m.previewWidth > max {
|
||||||
maxPreview -= m.treeWidth
|
m.previewWidth = max
|
||||||
}
|
|
||||||
if m.previewWidth > maxPreview {
|
|
||||||
m.previewWidth = maxPreview
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Открытие файла из дерева ===
|
// === Открытие файла ===
|
||||||
|
|
||||||
func (m *EditorModel) openFile(path string) {
|
func (m *EditorModel) openFile(path string) {
|
||||||
content, err := LoadFile(path)
|
content, err := LoadFile(path)
|
||||||
|
|
@ -357,6 +281,7 @@ func (m *EditorModel) openFile(path string) {
|
||||||
m.numPrefix = ""
|
m.numPrefix = ""
|
||||||
m.pendingKeys = ""
|
m.pendingKeys = ""
|
||||||
m.showPreview = fileType == "md" && m.cfgShowPreview
|
m.showPreview = fileType == "md" && m.cfgShowPreview
|
||||||
|
m.previewDirty = true
|
||||||
m.mode = NORMAL
|
m.mode = NORMAL
|
||||||
m.clampPanelWidths()
|
m.clampPanelWidths()
|
||||||
m.SetStatusMsg("Opened: " + path)
|
m.SetStatusMsg("Opened: " + path)
|
||||||
|
|
@ -366,10 +291,13 @@ func (m *EditorModel) openFile(path string) {
|
||||||
// === Вспомогательные методы ===
|
// === Вспомогательные методы ===
|
||||||
|
|
||||||
func (m *EditorModel) IsInsertMode() bool { return m.mode == INSERT }
|
func (m *EditorModel) IsInsertMode() bool { return m.mode == INSERT }
|
||||||
|
|
||||||
func (m *EditorModel) SetStatusMsg(msg string) { m.statusMsg = msg }
|
func (m *EditorModel) SetStatusMsg(msg string) { m.statusMsg = msg }
|
||||||
|
|
||||||
func (m *EditorModel) MarkDirty() { m.dirty = true }
|
func (m *EditorModel) MarkDirty() {
|
||||||
|
m.dirty = true
|
||||||
|
m.previewDirty = true
|
||||||
|
InvalidateHighlightCache()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *EditorModel) CursorWithinBounds() {
|
func (m *EditorModel) CursorWithinBounds() {
|
||||||
if m.cursorY < 0 {
|
if m.cursorY < 0 {
|
||||||
|
|
@ -379,7 +307,6 @@ func (m *EditorModel) CursorWithinBounds() {
|
||||||
m.cursorY = len(m.lines) - 1
|
m.cursorY = len(m.lines) - 1
|
||||||
}
|
}
|
||||||
maxX := len([]rune(m.lines[m.cursorY]))
|
maxX := len([]rune(m.lines[m.cursorY]))
|
||||||
// В NORMAL режиме курсор не может стоять за последним символом
|
|
||||||
if m.mode != INSERT && maxX > 0 {
|
if m.mode != INSERT && maxX > 0 {
|
||||||
maxX--
|
maxX--
|
||||||
}
|
}
|
||||||
|
|
@ -393,16 +320,30 @@ func (m *EditorModel) CursorWithinBounds() {
|
||||||
|
|
||||||
// === Undo / Redo ===
|
// === Undo / Redo ===
|
||||||
|
|
||||||
|
func (m *EditorModel) undoLimit() int {
|
||||||
|
n := len(m.lines)
|
||||||
|
switch {
|
||||||
|
case n > 50000:
|
||||||
|
return 5
|
||||||
|
case n > 5000:
|
||||||
|
return 20
|
||||||
|
default:
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *EditorModel) saveUndoSnapshot() {
|
func (m *EditorModel) saveUndoSnapshot() {
|
||||||
snapshot := undoSnapshot{
|
snap := undoSnapshot{
|
||||||
lines: make([]string, len(m.lines)),
|
lines: make([]string, len(m.lines)),
|
||||||
cursorX: m.cursorX,
|
cursorX: m.cursorX,
|
||||||
cursorY: m.cursorY,
|
cursorY: m.cursorY,
|
||||||
}
|
}
|
||||||
copy(snapshot.lines, m.lines)
|
copy(snap.lines, m.lines)
|
||||||
m.undoStack = append(m.undoStack, snapshot)
|
|
||||||
if len(m.undoStack) > 100 {
|
limit := m.undoLimit()
|
||||||
m.undoStack = m.undoStack[1:]
|
m.undoStack = append(m.undoStack, snap)
|
||||||
|
if len(m.undoStack) > limit {
|
||||||
|
m.undoStack = m.undoStack[len(m.undoStack)-limit:]
|
||||||
}
|
}
|
||||||
m.redoStack = nil
|
m.redoStack = nil
|
||||||
}
|
}
|
||||||
|
|
@ -422,12 +363,13 @@ func (m *EditorModel) Undo() {
|
||||||
|
|
||||||
snap := m.undoStack[len(m.undoStack)-1]
|
snap := m.undoStack[len(m.undoStack)-1]
|
||||||
m.undoStack = m.undoStack[:len(m.undoStack)-1]
|
m.undoStack = m.undoStack[:len(m.undoStack)-1]
|
||||||
m.lines = make([]string, len(snap.lines))
|
m.lines = snap.lines
|
||||||
copy(m.lines, snap.lines)
|
|
||||||
m.cursorX = snap.cursorX
|
m.cursorX = snap.cursorX
|
||||||
m.cursorY = snap.cursorY
|
m.cursorY = snap.cursorY
|
||||||
m.CursorWithinBounds()
|
m.CursorWithinBounds()
|
||||||
m.dirty = true
|
m.dirty = true
|
||||||
|
m.previewDirty = true
|
||||||
|
InvalidateHighlightCache()
|
||||||
m.SetStatusMsg("Undo")
|
m.SetStatusMsg("Undo")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -447,11 +389,12 @@ func (m *EditorModel) Redo() {
|
||||||
copy(undoSnap.lines, m.lines)
|
copy(undoSnap.lines, m.lines)
|
||||||
m.undoStack = append(m.undoStack, undoSnap)
|
m.undoStack = append(m.undoStack, undoSnap)
|
||||||
|
|
||||||
m.lines = make([]string, len(snap.lines))
|
m.lines = snap.lines
|
||||||
copy(m.lines, snap.lines)
|
|
||||||
m.cursorX = snap.cursorX
|
m.cursorX = snap.cursorX
|
||||||
m.cursorY = snap.cursorY
|
m.cursorY = snap.cursorY
|
||||||
m.CursorWithinBounds()
|
m.CursorWithinBounds()
|
||||||
m.dirty = true
|
m.dirty = true
|
||||||
|
m.previewDirty = true
|
||||||
|
InvalidateHighlightCache()
|
||||||
m.SetStatusMsg("Redo")
|
m.SetStatusMsg("Redo")
|
||||||
}
|
}
|
||||||
0
pdf.go
Normal file → Executable file
0
pdf.go
Normal file → Executable file
0
pdf_font.go
Normal file → Executable file
0
pdf_font.go
Normal file → Executable file
178
render.go
Normal file → Executable file
178
render.go
Normal file → Executable file
|
|
@ -4,11 +4,21 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// === Кеш visibleWidth ===
|
||||||
|
// Большинство строк рендерятся повторно без изменений — кешируем результат.
|
||||||
|
|
||||||
|
var (
|
||||||
|
vwCache = map[string]int{}
|
||||||
|
vwCacheMu sync.RWMutex
|
||||||
|
vwCacheMax = 4000
|
||||||
|
)
|
||||||
|
|
||||||
// === UI стили ===
|
// === UI стили ===
|
||||||
var (
|
var (
|
||||||
colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
|
colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
|
||||||
|
|
@ -23,7 +33,7 @@ var (
|
||||||
colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
|
colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
|
||||||
)
|
)
|
||||||
|
|
||||||
const grimVersion = "grimoir v0.4"
|
const grimVersion = "grimoir v1.0"
|
||||||
|
|
||||||
// === Главная точка рендера ===
|
// === Главная точка рендера ===
|
||||||
|
|
||||||
|
|
@ -40,21 +50,7 @@ func renderSplit(m *EditorModel) string {
|
||||||
return joinRowColumns([][]string{edRows, prRows}, []int{edW, prW}, m.height)
|
return joinRowColumns([][]string{edRows, prRows}, []int{edW, prW}, m.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTreeEditor(m *EditorModel) string {
|
|
||||||
edW := m.editorWidth()
|
|
||||||
trRows := buildTreeRows(m, m.treeWidth, m.height)
|
|
||||||
edRows := buildEditorRows(m, edW, m.height)
|
|
||||||
return joinRowColumns([][]string{trRows, edRows}, []int{m.treeWidth, edW}, m.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderTreeEditorPreview(m *EditorModel) string {
|
|
||||||
edW := m.editorWidth()
|
|
||||||
prW := m.previewWidth
|
|
||||||
trRows := buildTreeRows(m, m.treeWidth, m.height)
|
|
||||||
edRows := buildEditorRows(m, edW, m.height)
|
|
||||||
prRows := buildPreviewRows(m, prW, m.height)
|
|
||||||
return joinRowColumns([][]string{trRows, edRows, prRows}, []int{m.treeWidth, edW, prW}, m.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Построители строк (возвращают []string, по одной на строку экрана) ===
|
// === Построители строк (возвращают []string, по одной на строку экрана) ===
|
||||||
|
|
||||||
|
|
@ -78,6 +74,12 @@ func buildEditorRows(m *EditorModel, width, height int) []string {
|
||||||
contentW = 1
|
contentW = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Диапазон выделения для VISUAL LINE режима (если активен)
|
||||||
|
var visStart, visEnd int = -1, -1
|
||||||
|
if m.mode == VISUAL {
|
||||||
|
visStart, visEnd = m.visualRange()
|
||||||
|
}
|
||||||
|
|
||||||
for lineNum := start; lineNum < end; lineNum++ {
|
for lineNum := start; lineNum < end; lineNum++ {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
if m.showLineNumbers {
|
if m.showLineNumbers {
|
||||||
|
|
@ -93,7 +95,12 @@ func buildEditorRows(m *EditorModel, width, height int) []string {
|
||||||
rendered = renderLine(line, m.fileType)
|
rendered = renderLine(line, m.fileType)
|
||||||
}
|
}
|
||||||
// Обрезаем по ширине контента
|
// Обрезаем по ширине контента
|
||||||
sb.WriteString(truncateVisible(rendered, contentW))
|
truncated := truncateVisible(rendered, contentW)
|
||||||
|
// Подсветка фона для выделенных строк VISUAL LINE
|
||||||
|
if visStart >= 0 && lineNum >= visStart && lineNum <= visEnd {
|
||||||
|
truncated = visualHighlight(truncated, contentW)
|
||||||
|
}
|
||||||
|
sb.WriteString(truncated)
|
||||||
rows = append(rows, sb.String())
|
rows = append(rows, sb.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,55 +115,7 @@ func buildEditorRows(m *EditorModel, width, height int) []string {
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTreeRows строит строки файлового дерева
|
|
||||||
func buildTreeRows(m *EditorModel, width, height int) []string {
|
|
||||||
ft := &m.fileTree
|
|
||||||
rows := make([]string, 0, height)
|
|
||||||
|
|
||||||
// Заголовок
|
|
||||||
title := truncateString(filepath_base(ft.root), width-4)
|
|
||||||
bgColor := "8"
|
|
||||||
if ft.focused {
|
|
||||||
bgColor = "4"
|
|
||||||
}
|
|
||||||
headerContent := " > " + title
|
|
||||||
rows = append(rows, ansiLine(
|
|
||||||
"\x1b[48;5;"+bgColor+"m\x1b[38;5;15m\x1b[1m"+headerContent+"\x1b[0m",
|
|
||||||
width,
|
|
||||||
))
|
|
||||||
|
|
||||||
listHeight := height - 2 // заголовок + подсказка
|
|
||||||
ft.adjustScroll(listHeight)
|
|
||||||
|
|
||||||
shown := 0
|
|
||||||
for i := ft.scroll; i < ft.scroll+listHeight && i < len(ft.nodes); i++ {
|
|
||||||
node := ft.nodes[i]
|
|
||||||
line := buildFileNodeLine(node, m.filepath)
|
|
||||||
|
|
||||||
if i == ft.cursor && ft.focused {
|
|
||||||
// Выделенный элемент — инвертируем фон
|
|
||||||
line = "\x1b[48;5;237m\x1b[38;5;15m\x1b[1m" + stripANSI(line) + "\x1b[0m"
|
|
||||||
} else if i == ft.cursor {
|
|
||||||
line = "\x1b[38;5;245m" + stripANSI(line) + "\x1b[0m"
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, line)
|
|
||||||
shown++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пустые строки
|
|
||||||
for shown < listHeight {
|
|
||||||
rows = append(rows, "")
|
|
||||||
shown++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подсказка
|
|
||||||
hint := " Enter:open Tab:focus </>:width"
|
|
||||||
hint = truncateString(hint, width)
|
|
||||||
rows = append(rows, "\x1b[38;5;8m"+hint+"\x1b[0m")
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildPreviewRows строит строки preview
|
// buildPreviewRows строит строки preview
|
||||||
func buildPreviewRows(m *EditorModel, width, height int) []string {
|
func buildPreviewRows(m *EditorModel, width, height int) []string {
|
||||||
|
|
@ -188,6 +147,7 @@ func buildPreviewRows(m *EditorModel, width, height int) []string {
|
||||||
func joinRowColumns(cols [][]string, widths []int, height int) string {
|
func joinRowColumns(cols [][]string, widths []int, height int) string {
|
||||||
var out strings.Builder
|
var out strings.Builder
|
||||||
for row := 0; row < height; row++ {
|
for row := 0; row < height; row++ {
|
||||||
|
out.WriteString(clearLine)
|
||||||
for ci, col := range cols {
|
for ci, col := range cols {
|
||||||
w := widths[ci]
|
w := widths[ci]
|
||||||
line := ""
|
line := ""
|
||||||
|
|
@ -210,7 +170,11 @@ func joinRowColumns(cols [][]string, widths []int, height int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rowsToString(rows []string) string {
|
func rowsToString(rows []string) string {
|
||||||
return strings.Join(rows, "\n") + "\n"
|
withClear := make([]string, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
withClear[i] = clearLine + r
|
||||||
|
}
|
||||||
|
return strings.Join(withClear, "\n") + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Построители отдельных элементов ===
|
// === Построители отдельных элементов ===
|
||||||
|
|
@ -252,9 +216,6 @@ func buildStatusBar(m *EditorModel, width int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
hints := []string{m.fileType}
|
hints := []string{m.fileType}
|
||||||
if m.showFileTree {
|
|
||||||
hints = append(hints, fmt.Sprintf("tree:%d", m.treeWidth))
|
|
||||||
}
|
|
||||||
if m.showPreview && m.fileType == "md" {
|
if m.showPreview && m.fileType == "md" {
|
||||||
hints = append(hints, fmt.Sprintf("prev:%d", m.previewWidth))
|
hints = append(hints, fmt.Sprintf("prev:%d", m.previewWidth))
|
||||||
}
|
}
|
||||||
|
|
@ -272,25 +233,6 @@ func buildStatusBar(m *EditorModel, width int) string {
|
||||||
"\x1b[38;5;11m" + right + "\x1b[0m"
|
"\x1b[38;5;11m" + right + "\x1b[0m"
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildFileNodeLine(node *FileNode, currentFile string) string {
|
|
||||||
indent := ""
|
|
||||||
if node.depth > 0 {
|
|
||||||
indent = strings.Repeat(" ", node.depth-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if node.isDir {
|
|
||||||
icon := "▸ "
|
|
||||||
if node.expanded {
|
|
||||||
icon = "▾ "
|
|
||||||
}
|
|
||||||
return indent + "\x1b[1;38;5;33m" + icon + node.name + "\x1b[0m"
|
|
||||||
}
|
|
||||||
|
|
||||||
if node.path == currentFile {
|
|
||||||
return indent + " \x1b[38;5;214m● " + node.name + "\x1b[0m"
|
|
||||||
}
|
|
||||||
return indent + " \x1b[38;5;252m" + node.name + "\x1b[0m"
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Рендер строк текста ===
|
// === Рендер строк текста ===
|
||||||
|
|
||||||
|
|
@ -333,7 +275,20 @@ func renderLineWithCursor(line string, cursorX int, fileType string) string {
|
||||||
|
|
||||||
// visibleWidth возвращает визуальную ширину строки в терминале,
|
// visibleWidth возвращает визуальную ширину строки в терминале,
|
||||||
// учитывая ANSI escape-коды и double-width символы через go-runewidth.
|
// учитывая ANSI escape-коды и double-width символы через go-runewidth.
|
||||||
|
// Результат кешируется — большинство строк повторяются между кадрами.
|
||||||
func visibleWidth(s string) int {
|
func visibleWidth(s string) int {
|
||||||
|
// Быстрый путь для коротких строк без ANSI
|
||||||
|
if len(s) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
vwCacheMu.RLock()
|
||||||
|
if v, ok := vwCache[s]; ok {
|
||||||
|
vwCacheMu.RUnlock()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
vwCacheMu.RUnlock()
|
||||||
|
|
||||||
w := 0
|
w := 0
|
||||||
inEsc := false
|
inEsc := false
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
|
|
@ -349,9 +304,23 @@ func visibleWidth(s string) int {
|
||||||
}
|
}
|
||||||
w += runewidth.RuneWidth(r)
|
w += runewidth.RuneWidth(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vwCacheMu.Lock()
|
||||||
|
if len(vwCache) < vwCacheMax {
|
||||||
|
vwCache[s] = w
|
||||||
|
}
|
||||||
|
vwCacheMu.Unlock()
|
||||||
|
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidateVWCache сбрасывает кеш visibleWidth
|
||||||
|
func InvalidateVWCache() {
|
||||||
|
vwCacheMu.Lock()
|
||||||
|
vwCache = map[string]int{}
|
||||||
|
vwCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// stripANSI удаляет все ANSI escape-последовательности
|
// stripANSI удаляет все ANSI escape-последовательности
|
||||||
func stripANSI(s string) string {
|
func stripANSI(s string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
@ -373,6 +342,31 @@ func stripANSI(s string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncateVisible обрезает строку до maxWidth видимых столбцов терминала
|
// truncateVisible обрезает строку до maxWidth видимых столбцов терминала
|
||||||
|
// visualHighlight оборачивает строку в фон VISUAL LINE выделения и
|
||||||
|
// добивает её пробелами с тем же фоном до полной ширины колонки,
|
||||||
|
// чтобы подсветка визуально покрывала всю строку, а не только текст.
|
||||||
|
func visualHighlight(s string, width int) string {
|
||||||
|
vis := visibleWidth(s)
|
||||||
|
pad := width - vis
|
||||||
|
if pad < 0 {
|
||||||
|
pad = 0
|
||||||
|
}
|
||||||
|
return "\x1b[48;5;24m" + s + "\x1b[0m\x1b[48;5;24m" + strings.Repeat(" ", pad) + "\x1b[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateVisible обрезает строку до maxWidth видимых столбцов терминала.
|
||||||
|
// ВСЕГДА возвращает строку ровно maxWidth видимой ширины — добивает пробелами
|
||||||
|
// если короче. Это критично: если просто обрезать без добивки, при замене
|
||||||
|
// длинной строки на более короткую между кадрами (например при скролле)
|
||||||
|
// "хвост" предыдущей строки остаётся не стёртым в терминальном буфере,
|
||||||
|
// что выглядит как дублирование/застревание старого контента.
|
||||||
|
// clearLine — ANSI escape для полной очистки текущей строки терминала.
|
||||||
|
// Используется как защита от artifacts в терминальных мультиплексорах
|
||||||
|
// (Zellij, tmux), которые могут кешировать содержимое строки построчно
|
||||||
|
// и не полностью перерисовывать её, если новая строка короче предыдущей
|
||||||
|
// даже при наличии добивки пробелами с другим цветом фона.
|
||||||
|
const clearLine = "\x1b[2K"
|
||||||
|
|
||||||
func truncateVisible(s string, maxWidth int) string {
|
func truncateVisible(s string, maxWidth int) string {
|
||||||
if maxWidth <= 0 {
|
if maxWidth <= 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -401,6 +395,11 @@ func truncateVisible(s string, maxWidth int) string {
|
||||||
vis += rw
|
vis += rw
|
||||||
}
|
}
|
||||||
sb.WriteString("\x1b[0m")
|
sb.WriteString("\x1b[0m")
|
||||||
|
// Добиваем пробелами до точной ширины — без этого короткие строки
|
||||||
|
// не перекрывают остатки предыдущего, более длинного контента.
|
||||||
|
if vis < maxWidth {
|
||||||
|
sb.WriteString(strings.Repeat(" ", maxWidth-vis))
|
||||||
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -500,7 +499,12 @@ func (m *EditorModel) updatePreview() {
|
||||||
m.previewBuf = ""
|
m.previewBuf = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Пересчитываем только если контент изменился
|
||||||
|
if !m.previewDirty {
|
||||||
|
return
|
||||||
|
}
|
||||||
m.previewBuf = renderMarkdown(m.lines)
|
m.previewBuf = renderMarkdown(m.lines)
|
||||||
|
m.previewDirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMarkdown(lines []string) string {
|
func renderMarkdown(lines []string) string {
|
||||||
|
|
|
||||||
26
search.go
Normal file → Executable file
26
search.go
Normal file → Executable file
|
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -82,6 +83,10 @@ func (m *EditorModel) startSearch() {
|
||||||
m.SetStatusMsg("/")
|
m.SetStatusMsg("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxSearchMatches — максимум совпадений при поиске
|
||||||
|
// Предотвращает зависание на файлах с сотнями тысяч строк
|
||||||
|
const maxSearchMatches = 1000
|
||||||
|
|
||||||
func (m *EditorModel) performSearch(pattern string) {
|
func (m *EditorModel) performSearch(pattern string) {
|
||||||
m.lastSearchTerm = pattern
|
m.lastSearchTerm = pattern
|
||||||
m.searchMatches = nil
|
m.searchMatches = nil
|
||||||
|
|
@ -91,10 +96,11 @@ func (m *EditorModel) performSearch(pattern string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for lineNum, line := range m.lines {
|
|
||||||
// Ищем в рунах чтобы позиции были корректными
|
|
||||||
lineRunes := []rune(line)
|
|
||||||
patRunes := []rune(pattern)
|
patRunes := []rune(pattern)
|
||||||
|
truncated := false
|
||||||
|
|
||||||
|
for lineNum, line := range m.lines {
|
||||||
|
lineRunes := []rune(line)
|
||||||
for i := 0; i <= len(lineRunes)-len(patRunes); i++ {
|
for i := 0; i <= len(lineRunes)-len(patRunes); i++ {
|
||||||
match := true
|
match := true
|
||||||
for j, r := range patRunes {
|
for j, r := range patRunes {
|
||||||
|
|
@ -105,16 +111,28 @@ func (m *EditorModel) performSearch(pattern string) {
|
||||||
}
|
}
|
||||||
if match {
|
if match {
|
||||||
m.searchMatches = append(m.searchMatches, struct{ line, col int }{lineNum, i})
|
m.searchMatches = append(m.searchMatches, struct{ line, col int }{lineNum, i})
|
||||||
|
if len(m.searchMatches) >= maxSearchMatches {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if truncated {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(m.searchMatches) > 0 {
|
if len(m.searchMatches) > 0 {
|
||||||
match := m.searchMatches[0]
|
match := m.searchMatches[0]
|
||||||
m.cursorY = match.line
|
m.cursorY = match.line
|
||||||
m.cursorX = match.col
|
m.cursorX = match.col
|
||||||
m.searchIdx = 0
|
m.searchIdx = 0
|
||||||
m.SetStatusMsg("Found: " + pattern)
|
if truncated {
|
||||||
|
m.SetStatusMsg(fmt.Sprintf("Found %d+ matches (showing first %d): %s",
|
||||||
|
maxSearchMatches, maxSearchMatches, pattern))
|
||||||
|
} else {
|
||||||
|
m.SetStatusMsg(fmt.Sprintf("Found %d match(es): %s", len(m.searchMatches), pattern))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
m.SetStatusMsg("Pattern not found: " + pattern)
|
m.SetStatusMsg("Pattern not found: " + pattern)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
203
syntax.go
Normal file → Executable file
203
syntax.go
Normal file → Executable file
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
@ -91,16 +92,134 @@ var languageConfigs = map[string]LanguageConfig{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Кеш скомпилированных regexp ===
|
||||||
|
|
||||||
|
// compiledLangPatterns — скомпилированные паттерны для одного языка
|
||||||
|
type compiledLangPatterns struct {
|
||||||
|
strings []*regexp.Regexp // по одному на каждый StringDelimiter
|
||||||
|
keywords []*regexp.Regexp // по одному на каждое ключевое слово
|
||||||
|
numbers *regexp.Regexp // nil если !HasNumbers
|
||||||
|
varPattern *regexp.Regexp // nil если VarPrefix == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
patternCache = map[string]*compiledLangPatterns{}
|
||||||
|
patternCacheMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// getPatterns возвращает скомпилированные паттерны для языка (из кеша или компилирует)
|
||||||
|
func getPatterns(fileType string, cfg LanguageConfig) *compiledLangPatterns {
|
||||||
|
patternCacheMu.RLock()
|
||||||
|
if p, ok := patternCache[fileType]; ok {
|
||||||
|
patternCacheMu.RUnlock()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
patternCacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Компилируем один раз
|
||||||
|
p := &compiledLangPatterns{}
|
||||||
|
|
||||||
|
for _, delim := range cfg.StringDelimiters {
|
||||||
|
var re *regexp.Regexp
|
||||||
|
switch delim {
|
||||||
|
case `"`:
|
||||||
|
re = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
|
||||||
|
case `'`:
|
||||||
|
re = regexp.MustCompile(`'(?:[^'\\]|\\.)*'`)
|
||||||
|
case "`":
|
||||||
|
re = regexp.MustCompile("`[^`]*`")
|
||||||
|
}
|
||||||
|
p.strings = append(p.strings, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kw := range cfg.Keywords {
|
||||||
|
var re *regexp.Regexp
|
||||||
|
if cfg.CaseInsensitive {
|
||||||
|
re = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`)
|
||||||
|
} else {
|
||||||
|
re = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`)
|
||||||
|
}
|
||||||
|
p.keywords = append(p.keywords, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.HasNumbers {
|
||||||
|
p.numbers = regexp.MustCompile(`\b\d+(\.\d+)?\b`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VarPrefix != "" {
|
||||||
|
p.varPattern = regexp.MustCompile(regexp.QuoteMeta(cfg.VarPrefix) + `\w+`)
|
||||||
|
}
|
||||||
|
|
||||||
|
patternCacheMu.Lock()
|
||||||
|
patternCache[fileType] = p
|
||||||
|
patternCacheMu.Unlock()
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Кеш подсвеченных строк ===
|
||||||
|
|
||||||
|
// highlightCacheKey — ключ кеша подсветки
|
||||||
|
type highlightCacheKey struct {
|
||||||
|
line string
|
||||||
|
fileType string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
highlightCache = map[highlightCacheKey]string{}
|
||||||
|
highlightCacheMu sync.RWMutex
|
||||||
|
// Ограничиваем размер кеша — не кешируем больше N строк
|
||||||
|
highlightCacheMax = 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
// HighlightLine подсвечивает одну строку кода
|
||||||
|
func HighlightLine(line, fileType string) string {
|
||||||
|
cfg, ok := languageConfigs[fileType]
|
||||||
|
if !ok {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем кеш
|
||||||
|
key := highlightCacheKey{line, fileType}
|
||||||
|
highlightCacheMu.RLock()
|
||||||
|
if cached, ok := highlightCache[key]; ok {
|
||||||
|
highlightCacheMu.RUnlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
highlightCacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Вычисляем
|
||||||
|
patterns := getPatterns(fileType, cfg)
|
||||||
|
tokens := tokenizeWithPatterns(line, cfg, patterns)
|
||||||
|
result := renderTokens(tokens, cfg)
|
||||||
|
|
||||||
|
// Сохраняем в кеш
|
||||||
|
highlightCacheMu.Lock()
|
||||||
|
if len(highlightCache) < highlightCacheMax {
|
||||||
|
highlightCache[key] = result
|
||||||
|
}
|
||||||
|
highlightCacheMu.Unlock()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateHighlightCache сбрасывает кеш подсветки (вызывается при изменении файла)
|
||||||
|
func InvalidateHighlightCache() {
|
||||||
|
highlightCacheMu.Lock()
|
||||||
|
highlightCache = map[highlightCacheKey]string{}
|
||||||
|
highlightCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// tokenKind — тип токена
|
// tokenKind — тип токена
|
||||||
type tokenKind int
|
type tokenKind int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tokenPlain tokenKind = iota
|
tokenPlain tokenKind = iota
|
||||||
tokenKeyword // ключевое слово
|
tokenKeyword
|
||||||
tokenString // строковый литерал
|
tokenString
|
||||||
tokenComment // комментарий
|
tokenComment
|
||||||
tokenNumber // число
|
tokenNumber
|
||||||
tokenVar // переменная ($VAR)
|
tokenVar
|
||||||
)
|
)
|
||||||
|
|
||||||
// token — фрагмент строки с типом
|
// token — фрагмент строки с типом
|
||||||
|
|
@ -109,23 +228,13 @@ type token struct {
|
||||||
kind tokenKind
|
kind tokenKind
|
||||||
}
|
}
|
||||||
|
|
||||||
// HighlightLine подсвечивает одну строку кода
|
// tokenizeWithPatterns разбивает строку на токены используя предкомпилированные regexp
|
||||||
func HighlightLine(line, fileType string) string {
|
func tokenizeWithPatterns(line string, cfg LanguageConfig, p *compiledLangPatterns) []token {
|
||||||
cfg, ok := languageConfigs[fileType]
|
|
||||||
if !ok {
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
tokens := tokenize(line, cfg)
|
|
||||||
return renderTokens(tokens, cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tokenize разбивает строку на токены не применяя стили
|
|
||||||
func tokenize(line string, cfg LanguageConfig) []token {
|
|
||||||
if line == "" {
|
if line == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шаг 1: находим комментарий — всё после него plain (красим как comment целиком)
|
// Находим комментарий
|
||||||
commentStart := -1
|
commentStart := -1
|
||||||
if cfg.LineCommentPrefix != "" {
|
if cfg.LineCommentPrefix != "" {
|
||||||
commentStart = strings.Index(line, cfg.LineCommentPrefix)
|
commentStart = strings.Index(line, cfg.LineCommentPrefix)
|
||||||
|
|
@ -138,27 +247,15 @@ func tokenize(line string, cfg LanguageConfig) []token {
|
||||||
comment = line[commentStart:]
|
comment = line[commentStart:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шаг 2: разбиваем code на токены через span-маппинг
|
|
||||||
// Каждая позиция байта получает тип; потом склеиваем в токены
|
|
||||||
n := len(code)
|
n := len(code)
|
||||||
kinds := make([]tokenKind, n)
|
kinds := make([]tokenKind, n)
|
||||||
|
|
||||||
// Строковые литералы
|
// Строки
|
||||||
for _, delim := range cfg.StringDelimiters {
|
for _, re := range p.strings {
|
||||||
var re *regexp.Regexp
|
|
||||||
switch delim {
|
|
||||||
case `"`:
|
|
||||||
re = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
|
|
||||||
case `'`:
|
|
||||||
re = regexp.MustCompile(`'(?:[^'\\]|\\.)*'`)
|
|
||||||
case "`":
|
|
||||||
re = regexp.MustCompile("`[^`]*`")
|
|
||||||
}
|
|
||||||
if re == nil {
|
if re == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, loc := range re.FindAllStringIndex(code, -1) {
|
for _, loc := range re.FindAllStringIndex(code, -1) {
|
||||||
// Только если это место ещё не занято
|
|
||||||
already := false
|
already := false
|
||||||
for i := loc[0]; i < loc[1]; i++ {
|
for i := loc[0]; i < loc[1]; i++ {
|
||||||
if kinds[i] != tokenPlain {
|
if kinds[i] != tokenPlain {
|
||||||
|
|
@ -175,15 +272,8 @@ func tokenize(line string, cfg LanguageConfig) []token {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ключевые слова
|
// Ключевые слова
|
||||||
for _, kw := range cfg.Keywords {
|
for _, re := range p.keywords {
|
||||||
var re *regexp.Regexp
|
|
||||||
if cfg.CaseInsensitive {
|
|
||||||
re = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`)
|
|
||||||
} else {
|
|
||||||
re = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`)
|
|
||||||
}
|
|
||||||
for _, loc := range re.FindAllStringIndex(code, -1) {
|
for _, loc := range re.FindAllStringIndex(code, -1) {
|
||||||
// Не перекрываем строки
|
|
||||||
inString := false
|
inString := false
|
||||||
for i := loc[0]; i < loc[1]; i++ {
|
for i := loc[0]; i < loc[1]; i++ {
|
||||||
if kinds[i] == tokenString {
|
if kinds[i] == tokenString {
|
||||||
|
|
@ -200,45 +290,33 @@ func tokenize(line string, cfg LanguageConfig) []token {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Числа
|
// Числа
|
||||||
if cfg.HasNumbers {
|
if p.numbers != nil {
|
||||||
re := regexp.MustCompile(`\b\d+(\.\d+)?\b`)
|
for _, loc := range p.numbers.FindAllStringIndex(code, -1) {
|
||||||
for _, loc := range re.FindAllStringIndex(code, -1) {
|
skip := false
|
||||||
inString := false
|
|
||||||
for i := loc[0]; i < loc[1]; i++ {
|
for i := loc[0]; i < loc[1]; i++ {
|
||||||
if kinds[i] == tokenString {
|
if kinds[i] != tokenPlain {
|
||||||
inString = true
|
skip = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !inString {
|
if !skip {
|
||||||
// Не красим если уже keyword
|
|
||||||
isKW := false
|
|
||||||
for i := loc[0]; i < loc[1]; i++ {
|
|
||||||
if kinds[i] == tokenKeyword {
|
|
||||||
isKW = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isKW {
|
|
||||||
for i := loc[0]; i < loc[1]; i++ {
|
for i := loc[0]; i < loc[1]; i++ {
|
||||||
kinds[i] = tokenNumber
|
kinds[i] = tokenNumber
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Переменные ($VAR)
|
// Переменные
|
||||||
if cfg.VarPrefix != "" {
|
if p.varPattern != nil {
|
||||||
re := regexp.MustCompile(regexp.QuoteMeta(cfg.VarPrefix) + `\w+`)
|
for _, loc := range p.varPattern.FindAllStringIndex(code, -1) {
|
||||||
for _, loc := range re.FindAllStringIndex(code, -1) {
|
|
||||||
for i := loc[0]; i < loc[1]; i++ {
|
for i := loc[0]; i < loc[1]; i++ {
|
||||||
kinds[i] = tokenVar
|
kinds[i] = tokenVar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шаг 3: склеиваем соседние байты одного типа в токены
|
// Склеиваем в токены
|
||||||
var tokens []token
|
var tokens []token
|
||||||
i := 0
|
i := 0
|
||||||
for i < n {
|
for i < n {
|
||||||
|
|
@ -251,7 +329,6 @@ func tokenize(line string, cfg LanguageConfig) []token {
|
||||||
i = j
|
i = j
|
||||||
}
|
}
|
||||||
|
|
||||||
// Комментарий добавляем в конец
|
|
||||||
if comment != "" {
|
if comment != "" {
|
||||||
tokens = append(tokens, token{text: comment, kind: tokenComment})
|
tokens = append(tokens, token{text: comment, kind: tokenComment})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
269
tzeentch.go
Normal file
269
tzeentch.go
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// === Пасхалка: Just as planned ===
|
||||||
|
|
||||||
|
var tzeentchQuotes = []struct {
|
||||||
|
ru string
|
||||||
|
en string
|
||||||
|
source string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
ru: `Тзинч — это перемены. Тзинч — это надежда.
|
||||||
|
Тзинч — это движение вперёд, к лучшему будущему,
|
||||||
|
которое всегда маячит впереди, всегда достижимо,
|
||||||
|
но никогда не достигнуто. Ибо достижение цели —
|
||||||
|
это конец перемен, а конец перемен — это смерть.`,
|
||||||
|
en: `Tzeentch is change. Tzeentch is hope.
|
||||||
|
Tzeentch is the drive forward, toward a better future
|
||||||
|
that always beckons, always within reach,
|
||||||
|
yet never grasped. For to reach one's goal
|
||||||
|
is the end of change, and the end of change is death.`,
|
||||||
|
source: "Книга Тзинча / The Book of Tzeentch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ru: `Всё свершается по его плану. Каждое предательство,
|
||||||
|
каждый союз, каждая победа и каждое поражение —
|
||||||
|
лишь фигуры на доске Изменника Бога.
|
||||||
|
Вы думаете, что действуете по своей воле?
|
||||||
|
Именно этого он и хотел, чтобы вы думали.`,
|
||||||
|
en: `All unfolds according to his plan. Every betrayal,
|
||||||
|
every alliance, every victory and every defeat —
|
||||||
|
mere pieces on the Changer of Ways' board.
|
||||||
|
You believe you act of your own free will?
|
||||||
|
That is precisely what he wanted you to think.`,
|
||||||
|
source: "Еретические письмена, авт. неизв. / Heretical Writings, author unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ru: `Говорят, что Тзинч знает каждую нить судьбы
|
||||||
|
каждого существа во Вселенной.
|
||||||
|
Говорят, что он плетёт их по своему усмотрению.
|
||||||
|
Говорят многое.
|
||||||
|
Тзинч позаботился об этом.`,
|
||||||
|
en: `They say Tzeentch knows every thread of fate
|
||||||
|
of every being in the Universe.
|
||||||
|
They say he weaves them as he sees fit.
|
||||||
|
They say many things.
|
||||||
|
Tzeentch made sure of that.`,
|
||||||
|
source: "Apocrypha of Amon Sul",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ru: `Мы — его пешки. Его рыцари. Его ладьи.
|
||||||
|
Но никто из нас не знает своей роли на доске.
|
||||||
|
Только он видит партию целиком.
|
||||||
|
И он никогда не проигрывает.
|
||||||
|
Ибо проигрыш — тоже часть плана.`,
|
||||||
|
en: `We are his pawns. His knights. His rooks.
|
||||||
|
But none of us knows our role upon the board.
|
||||||
|
Only he sees the game entire.
|
||||||
|
And he never loses.
|
||||||
|
For defeat, too, is part of the plan.`,
|
||||||
|
source: "Из признаний чародея Эгримм Вар Гольда / Confessions of Sorcerer Egrimm van Horstmann",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ru: `Судьба — не нить, которую можно оборвать.
|
||||||
|
Это сеть, бесконечно сотканная рукой Тзинча.
|
||||||
|
Каждый узел — это жизнь. Каждый разрыв —
|
||||||
|
рождение новой нити, более сложной и прекрасной.
|
||||||
|
Смерть — лишь ещё один узор в его гобелене.`,
|
||||||
|
en: `Fate is not a thread that can be severed.
|
||||||
|
It is a web, endlessly woven by the hand of Tzeentch.
|
||||||
|
Every knot is a life. Every break
|
||||||
|
births a new thread, more intricate and beautiful.
|
||||||
|
Death is merely another pattern in his tapestry.`,
|
||||||
|
source: "Liber Malefic, фрагмент XVII / Liber Malefic, Fragment XVII",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ru: `«Зачем служить Тзинчу?» — спросил послушник.
|
||||||
|
«Потому что ты уже служишь ему», — ответил магистр.
|
||||||
|
«Как это возможно? Я ещё не давал клятвы».
|
||||||
|
«Ты задал этот вопрос. Этого достаточно».`,
|
||||||
|
en: `"Why serve Tzeentch?" asked the novice.
|
||||||
|
"Because you already do," replied the magister.
|
||||||
|
"How can that be? I have sworn no oath."
|
||||||
|
"You asked that question. That is enough."`,
|
||||||
|
source: "Диалоги Тысячи Сынов / Dialogues of the Thousand Sons",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ru: `Магнус видел всё.
|
||||||
|
Видел падение Просперо. Видел предательство.
|
||||||
|
Видел собственное проклятие.
|
||||||
|
И всё равно сделал именно то,
|
||||||
|
что от него ожидал Тзинч.
|
||||||
|
Всё идёт по плану.`,
|
||||||
|
en: `Magnus saw everything.
|
||||||
|
He saw the fall of Prospero. Saw the betrayal.
|
||||||
|
Saw his own damnation.
|
||||||
|
And still he did precisely what
|
||||||
|
Tzeentch had always intended.
|
||||||
|
All is proceeding as planned.`,
|
||||||
|
source: "Ересь Хоруса, том XI / Horus Heresy, Vol. XI",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ru: `Не бойся перемен, смертный.
|
||||||
|
Бойся их отсутствия.
|
||||||
|
Ибо то, что не меняется — уже мертво.
|
||||||
|
А мёртвое принадлежит другому богу.`,
|
||||||
|
en: `Fear not change, mortal.
|
||||||
|
Fear its absence.
|
||||||
|
For that which does not change is already dead.
|
||||||
|
And the dead belong to another god.`,
|
||||||
|
source: "Слова Аримана / Words of Ahriman of the Thousand Sons",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ru: `В начале было Слово.
|
||||||
|
Слово было «Изменись».
|
||||||
|
И Вселенная изменилась.
|
||||||
|
Так было. Так есть. Так будет.
|
||||||
|
Тзинч доволен.`,
|
||||||
|
en: `In the beginning was the Word.
|
||||||
|
The Word was "Change".
|
||||||
|
And the Universe changed.
|
||||||
|
So it was. So it is. So it shall be.
|
||||||
|
Tzeentch is pleased.`,
|
||||||
|
source: "Книга Первичного Знания, утеряна / The Book of Primordial Knowledge, lost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ru: `Каждый чародей думает, что контролирует варп.
|
||||||
|
Варп думает иначе.
|
||||||
|
Тзинч смеётся над обоими.`,
|
||||||
|
en: `Every sorcerer believes he controls the warp.
|
||||||
|
The warp disagrees.
|
||||||
|
Tzeentch laughs at both.`,
|
||||||
|
source: "Предупреждения инквизиции, том DCCXII / Inquisitorial Warnings, Vol. DCCXII",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Рендер попапа Тзинча ===
|
||||||
|
//
|
||||||
|
// Реализован как часть основной модели (m.showTzeentch), а не отдельная
|
||||||
|
// Bubble Tea программа. Запуск вложенного tea.NewProgram внутри уже
|
||||||
|
// работающей программы ломает alt-screen и оставляет терминал в
|
||||||
|
// неконсистентном состоянии после выхода.
|
||||||
|
|
||||||
|
// renderTzeentch рендерит попап с цитатой поверх текущего экрана
|
||||||
|
func renderTzeentch(m *EditorModel) string {
|
||||||
|
if m.width == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
q := tzeentchQuotes[m.tzeentchIdx]
|
||||||
|
|
||||||
|
borderStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.DoubleBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("129")).
|
||||||
|
Padding(1, 3)
|
||||||
|
|
||||||
|
titleStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("129")).
|
||||||
|
Bold(true).Italic(true)
|
||||||
|
|
||||||
|
eyeStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("93")).Bold(true)
|
||||||
|
|
||||||
|
ruStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("183"))
|
||||||
|
|
||||||
|
enStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("60")).Italic(true)
|
||||||
|
|
||||||
|
sourceStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("8")).Italic(true)
|
||||||
|
|
||||||
|
divStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("54"))
|
||||||
|
|
||||||
|
hintStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("8"))
|
||||||
|
|
||||||
|
div := divStyle.Render(strings.Repeat("─", 48))
|
||||||
|
|
||||||
|
ruLines := strings.Split(q.ru, "\n")
|
||||||
|
var ruRendered []string
|
||||||
|
for _, l := range ruLines {
|
||||||
|
ruRendered = append(ruRendered, ruStyle.Render(l))
|
||||||
|
}
|
||||||
|
|
||||||
|
enLines := strings.Split(q.en, "\n")
|
||||||
|
var enRendered []string
|
||||||
|
for _, l := range enLines {
|
||||||
|
enRendered = append(enRendered, enStyle.Render(l))
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.Join([]string{
|
||||||
|
eyeStyle.Render(" ⌬"),
|
||||||
|
titleStyle.Render("✦ Tzeentch speaks... ✦"),
|
||||||
|
div,
|
||||||
|
"",
|
||||||
|
strings.Join(ruRendered, "\n"),
|
||||||
|
"",
|
||||||
|
divStyle.Render(strings.Repeat("·", 48)),
|
||||||
|
"",
|
||||||
|
strings.Join(enRendered, "\n"),
|
||||||
|
"",
|
||||||
|
div,
|
||||||
|
sourceStyle.Render("— " + q.source),
|
||||||
|
"",
|
||||||
|
hintStyle.Render("[ any key ] Just as planned."),
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
box := borderStyle.Render(content)
|
||||||
|
|
||||||
|
boxLines := strings.Split(box, "\n")
|
||||||
|
boxW := 0
|
||||||
|
for _, l := range boxLines {
|
||||||
|
if w := visibleWidth(l); w > boxW {
|
||||||
|
boxW = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hPad := (m.width - boxW) / 2
|
||||||
|
if hPad < 0 {
|
||||||
|
hPad = 0
|
||||||
|
}
|
||||||
|
vPad := (m.height - len(boxLines)) / 2
|
||||||
|
if vPad < 1 {
|
||||||
|
vPad = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
for i := 0; i < vPad; i++ {
|
||||||
|
out.WriteString("\n")
|
||||||
|
}
|
||||||
|
for _, l := range boxLines {
|
||||||
|
out.WriteString(strings.Repeat(" ", hPad))
|
||||||
|
out.WriteString(l)
|
||||||
|
out.WriteString("\n")
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tzeentchRand — единый генератор на весь процесс.
|
||||||
|
// Пересоздание rand.New(rand.NewSource(time.Now().UnixNano())) при каждом
|
||||||
|
// вызове даёт одинаковый seed при быстрых повторных нажатиях (разрешение
|
||||||
|
// UnixNano недостаточно при интервалах < 1мкс между вызовами), из-за чего
|
||||||
|
// показывается одна и та же цитата. Единый генератор инициализируется
|
||||||
|
// один раз и просто продолжает свою последовательность.
|
||||||
|
var tzeentchRand = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
|
// triggerTzeentch выбирает случайную цитату (отличную от предыдущей) и включает попап
|
||||||
|
func (m *EditorModel) triggerTzeentch() {
|
||||||
|
if len(tzeentchQuotes) <= 1 {
|
||||||
|
m.tzeentchIdx = 0
|
||||||
|
m.showTzeentch = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := tzeentchRand.Intn(len(tzeentchQuotes))
|
||||||
|
// Избегаем повтора той же цитаты что была показана прошлый раз
|
||||||
|
for next == m.tzeentchIdx {
|
||||||
|
next = tzeentchRand.Intn(len(tzeentchQuotes))
|
||||||
|
}
|
||||||
|
m.tzeentchIdx = next
|
||||||
|
m.showTzeentch = true
|
||||||
|
}
|
||||||
2
untitled.md
Normal file
2
untitled.md
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
rwerwer
|
||||||
|
werwerdawdawd wDdwdadd daddwd wdwad `w
|
||||||
29
v-curl-ispravili-uyazvimost-25-letnej-davnosti.md
Normal file
29
v-curl-ispravili-uyazvimost-25-letnej-davnosti.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# В curl исправили уязвимость 25-летней давности
|
||||||
|
|
||||||
|
> Источник: [xakep.ru](https://xakep.ru/2026/06/29/curl-old-bug/)
|
||||||
|
> Дата: 30.06.2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[
|
||||||
|
|
||||||
|
Рекомендуем почитать:[[
|
||||||
|
|
||||||
|
### Хакер #326. Router from Hell
|
||||||
|
|
||||||
|
|
||||||
|
- [Содержание выпуска
|
||||||
|
- [Подписка на «Хакер»-60%
|
||||||
|
|
||||||
|
Разработчики curl выпустили[версию 8.21.0, в которой исправили сразу восемнадцать уязвимостей. Это рекордное число проблем, устраненных в одном обновлении. При этом один из багов присутствовал в коде более 25 лет.
|
||||||
|
|
||||||
|
Четырем уязвимостям был присвоен средний уровень опасности, а остальным четырнадцати — низкий. Самой старой проблемой в этом релизе оказалась[CVE-2026-8932, присутствовавшая в коде проекта более 25 лет. Появившись еще в curl версии 7.7, выпущенной 22 марта 2001 года, уязвимость затрагивала все версии до 8.20.0 включительно.
|
||||||
|
|
||||||
|
|
||||||
|
Проблему CVE-2026-8932 обнаружили специалисты компании[Aisleс помощью собственной ИИ-платформы. Они сообщают, что баг был связан с повторным использованием mTLS-соединений. Дело в том, что libcurl хранит открытые соединения в пуле, чтобы по возможности задействовать их при следующих запросах. Однако при проверке, подходит ли существующее соединение для нового запроса, библиотека учитывала не все параметры клиентского сертификата и приватного ключа.
|
||||||
|
|
||||||
|
В результате libcurl могла повторно использовать уже установленное соединение, даже если приложение изменило сертификат или приватный ключ. Новый запрос при этом отправлялся через соединение, аутентифицированное с прежними параметрами, и фактически выполнялся от имени прежнего клиента.
|
||||||
|
|
||||||
|
Подчеркивается, что уязвимость затрагивает приложения, в которые встроена libcurl, но не распространяется на консольную утилиту curl.
|
||||||
|
|
||||||
|
Аналитики Aisle отмечают, что все простые уязвимости в curl давно нашли и исправили. Теперь исследователям приходится искать баги в старых имплементациях протоколов, логике переиспользования соединений, обработчиках обратных вызовов и других редко используемых участках кода.
|
||||||
Loading…
Add table
Reference in a new issue