Added new funcs

This commit is contained in:
Magnus Root 2026-07-04 13:04:33 +03:00
parent 42bc698976
commit 68b3d1834a
25 changed files with 1139 additions and 860 deletions

0
.gitignore vendored Normal file → Executable file
View file

0
ARCHITECTURE.md Normal file → Executable file
View file

0
LICENSE Normal file → Executable file
View file

0
README.md Normal file → Executable file
View file

54
commands.go Normal file → Executable file
View 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
View 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
View file

0
file.go Normal file → Executable file
View file

0
file_io.go Normal file → Executable file
View file

View file

@ -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
View file

0
go.sum Normal file → Executable file
View file

28
help.go Normal file → Executable file
View 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
View 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
View 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
View 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
View 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
View file

0
pdf_font.go Normal file → Executable file
View file

178
render.go Normal file → Executable file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
rwerwer
werwerdawdawd wDdwdadd daddwd wdwad `w

View file

@ -0,0 +1,29 @@
# В curl исправили уязвимость 25-летней давности
> Источник: [xakep.ru](https://xakep.ru/2026/06/29/curl-old-bug/)
> Дата: 30.06.2026
---
[![PENTEST AWARD 2026](http://static.xakep.ru/advert/2026/pentest-award-2026.png)
Рекомендуем почитать:[![](https://xakep.ru/wp-content/uploads/2026/05/575151/326-210x280.jpg)[
### Хакер #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 давно нашли и исправили. Теперь исследователям приходится искать баги в старых имплементациях протоколов, логике переиспользования соединений, обработчиках обратных вызовов и других редко используемых участках кода.