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

56
commands.go Normal file → Executable file
View file

@ -76,21 +76,6 @@ func (m *EditorModel) executeCommand(cmd string) tea.Cmd {
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
if strings.HasPrefix(cmd, "previewwidth ") || strings.HasPrefix(cmd, "pw ") {
parts := strings.Fields(cmd)
@ -112,6 +97,10 @@ func (m *EditorModel) executeCommand(cmd string) tea.Cmd {
m.showHelp = true
return nil
case "tzeentch":
m.triggerTzeentch()
return nil
case "w":
if err := m.save(); err != nil {
m.SetStatusMsg("Error: " + err.Error())
@ -143,21 +132,6 @@ func (m *EditorModel) executeCommand(cmd string) tea.Cmd {
m.Undo()
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":
return m.cmdNew("")
@ -264,6 +238,7 @@ func (m *EditorModel) cmdNew(name string) tea.Cmd {
m.redoStack = nil
m.searchMatches = nil
m.showPreview = m.cfgShowPreview
m.previewDirty = true
m.mode = NORMAL
m.updatePreview()
m.SetStatusMsg("New file (save with :w <name>)")
@ -371,8 +346,8 @@ func (m *EditorModel) handleConfigCommand(sub string) {
switch sub {
case "", "show":
m.SetStatusMsg(fmt.Sprintf(
"Config: %s | tw=%d pw=%d nu=%v tab=%d autosave=%ds",
m.config.LoadedFrom, m.treeWidth, m.previewWidth,
"Config: %s | pw=%d nu=%v tab=%d autosave=%ds",
m.config.LoadedFrom, m.previewWidth,
m.showLineNumbers, m.tabWidth, m.autosaveInterval,
))
case "paths":
@ -440,9 +415,9 @@ func (m *EditorModel) handleSetCommand(cmd string) {
rest := strings.TrimSpace(strings.TrimPrefix(cmd, "set"))
if rest == "" {
m.SetStatusMsg(fmt.Sprintf(
"nu=%v preview=%v tree=%v tw=%d pw=%d tab=%d autosave=%d",
m.showLineNumbers, m.showPreview, m.showFileTree,
m.treeWidth, m.previewWidth, m.tabWidth, m.autosaveInterval,
"nu=%v preview=%v pw=%d tab=%d autosave=%d",
m.showLineNumbers, m.showPreview,
m.previewWidth, m.tabWidth, m.autosaveInterval,
))
return
}
@ -490,15 +465,6 @@ func (m *EditorModel) handleSetCommand(cmd string) {
} else {
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":
n, err := strconv.Atoi(val)
if err != nil {
@ -522,4 +488,4 @@ func min3(a, b int) int {
return a
}
return b
}
}

39
config.go Normal file → Executable file
View file

@ -13,18 +13,15 @@ import (
// Config хранит все настройки редактора
type Config struct {
// [settings]
TreeWidth int
PreviewWidth int
LineNumbers bool
TabWidth int
AutosaveInterval int // секунды (0 = выключен)
ShowPreview bool
ShowTree bool
Theme string
// [keys] — map: действие → клавиша
// Например: "toggle_tree" → "ctrl+b"
Keys map[string]string
Keys map[string]string
// Путь откуда загружен конфиг (для отображения в :set)
LoadedFrom string
@ -33,13 +30,11 @@ type Config struct {
// DefaultConfig возвращает конфиг со значениями по умолчанию
func DefaultConfig() Config {
return Config{
TreeWidth: 25,
PreviewWidth: 40,
LineNumbers: true,
TabWidth: 4,
AutosaveInterval: 60,
ShowPreview: false, // включается автоматически для .md
ShowTree: false,
Theme: "default",
Keys: defaultKeys(),
}
@ -51,7 +46,6 @@ func defaultKeys() map[string]string {
"quit": "ctrl+c",
"quit_alt": "ctrl+d",
"redo": "ctrl+r",
"toggle_tree": "ctrl+b",
"focus_switch": "tab",
"panel_shrink": "<",
"panel_grow": ">",
@ -241,13 +235,6 @@ func parseConfig(data []byte, cfg *Config) error {
// applySettingKey применяет одну настройку из секции [settings]
func applySettingKey(cfg *Config, key, value string, line int) error {
switch key {
case "tree_width", "treewidth":
n, err := parseInt(value, line)
if err != nil {
return err
}
cfg.TreeWidth = n
case "preview_width", "previewwidth":
n, err := parseInt(value, line)
if err != nil {
@ -286,13 +273,6 @@ func applySettingKey(cfg *Config, key, value string, line int) error {
}
cfg.ShowPreview = b
case "show_tree", "tree":
b, err := parseBool(value, line)
if err != nil {
return err
}
cfg.ShowTree = b
case "theme":
cfg.Theme = value
@ -309,7 +289,7 @@ func applyKeyBinding(cfg *Config, action, key string, line int) error {
// Проверяем что действие известно
known := []string{
"quit", "quit_alt", "redo", "toggle_tree", "focus_switch",
"quit", "quit_alt", "redo",
"panel_shrink", "panel_grow", "toggle_preview",
}
for _, a := range known {
@ -356,9 +336,6 @@ func WriteDefaultConfig(path string) error {
[settings]
# Ширина панели файлового дерева (символы)
tree_width = 25
# Ширина панели preview (символы)
preview_width = 40
@ -374,8 +351,6 @@ autosave = 60
# Показывать preview при открытии .md файлов (true/false)
show_preview = false
# Показывать файловое дерево при запуске (true/false)
show_tree = false
# Тема (default единственная пока)
theme = default
@ -393,8 +368,6 @@ quit_alt = ctrl+d
# Redo
redo = ctrl+r
# Показать/скрыть файловое дерево
toggle_tree = ctrl+b
# Переключение фокуса между панелями
focus_switch = tab
@ -413,20 +386,14 @@ toggle_preview = " "
// ApplyConfig применяет загруженный конфиг к EditorModel
func (m *EditorModel) ApplyConfig(cfg Config) {
m.treeWidth = cfg.TreeWidth
m.previewWidth = cfg.PreviewWidth
m.showLineNumbers = cfg.LineNumbers
m.tabWidth = cfg.TabWidth
m.autosaveInterval = cfg.AutosaveInterval
if cfg.ShowTree {
m.showFileTree = true
m.fileTree.reload()
}
// ShowPreview из конфига применяется только если тип файла md —
// это проверяется при открытии файла, здесь только сохраняем флаг
m.cfgShowPreview = cfg.ShowPreview
m.config = cfg
m.clampPanelWidths()
}
}

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

30
help.go Normal file → Executable file
View file

@ -57,12 +57,24 @@ var helpSections = []helpSection{
items: []helpItem{
{"x", "удалить символ под курсором"},
{"dd / 3dd", "удалить строку / 3 строки"},
{"dw / de / db", "удалить до слова / конца слова / назад"},
{"yy", "скопировать строку"},
{"p / P", "вставить ниже / выше"},
{"u", "отменить (undo)"},
{"Ctrl+R", "повторить (redo)"},
},
},
{
title: "ВЫДЕЛЕНИЕ (VISUAL LINE)",
items: []helpItem{
{"V", "войти в режим выделения строк"},
{"j / k", "расширить выделение вниз / вверх"},
{"gg / G", "выделить до начала / конца файла"},
{"d / x", "удалить выделенные строки"},
{"y", "скопировать выделенные строки"},
{"Esc / V", "выйти без действия"},
},
},
{
title: "ФАЙЛ",
items: []helpItem{
@ -81,25 +93,11 @@ var helpSections = []helpSection{
{
title: "ПАНЕЛИ",
items: []helpItem{
{"Ctrl+B", "показать / скрыть файловое дерево"},
{"Tab", "переключить фокус между панелями"},
{"< / >", "уменьшить / увеличить ширину панели"},
{"< / >", "уменьшить / увеличить ширину preview"},
{"Пробел", "показать / скрыть preview (только .md)"},
{":treewidth N", "установить ширину дерева"},
{":previewwidth N", "установить ширину preview"},
},
},
{
title: "ФАЙЛОВОЕ ДЕРЕВО (при фокусе)",
items: []helpItem{
{"j / k", "вниз / вверх"},
{"l / Enter", "открыть файл / развернуть папку"},
{"h", "свернуть папку / к родителю"},
{"gg / G", "начало / конец списка"},
{"r", "обновить дерево"},
{"Esc", "вернуть фокус редактору"},
},
},
{
title: "ЭКСПОРТ И ЗАГРУЗКА",
items: []helpItem{
@ -254,4 +252,4 @@ func renderHelp(m *EditorModel) string {
return out.String()
}
// visibleLen определена в render.go
// visibleLen определена в render.go

512
keybindings.go Normal file → Executable file
View file

@ -1,83 +1,51 @@
package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// handleKey диспетчеризирует по фокусу и режиму
func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
// === Help экран — обрабатываем первым ===
// Попап Тзинча — любая клавиша закрывает
if m.showTzeentch {
m.showTzeentch = false
return m, nil
}
// Help экран
if m.showHelp {
if key == "q" || key == "esc" || key == "?" || key == ":help" {
if key == "q" || key == "esc" || key == "?" {
m.showHelp = false
}
return m, nil
}
// === Глобальные клавиши ===
// Глобальные клавиши
if key == m.config.KeyForAction("quit") || key == m.config.KeyForAction("quit_alt") {
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") {
delta := 2
if key == m.config.KeyForAction("panel_shrink") {
delta = -2
}
if m.focus == FocusFileTree && m.showFileTree {
m.resizeTree(delta)
} else if m.showPreview && m.fileType == "md" {
if m.showPreview && m.fileType == "md" {
delta := 2
if key == m.config.KeyForAction("panel_shrink") {
delta = -2
}
m.resizePreview(delta)
m.updatePreview()
}
m.updatePreview()
return m, nil
}
// === Фокус на дереве ===
if m.focus == FocusFileTree {
return m.handleFileTreeKey(msg)
}
// === Фокус на редакторе ===
switch m.mode {
case NORMAL:
return m.handleNormalMode(msg)
case VISUAL:
return m.handleVisualMode(msg)
case INSERT:
return m.handleInsertMode(msg)
case COMMAND:
@ -88,74 +56,110 @@ func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// handleFileTreeKey — клавиши при фокусе на дереве
func (m *EditorModel) handleFileTreeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// handleVisualMode — VISUAL LINE режим (построчное выделение, вход через V).
// Курсор и visualStartY задают диапазон выделенных строк.
// Движение по строкам расширяет/сужает выделение, d/y применяются к диапазону,
// Esc выходит без действия.
func (m *EditorModel) handleVisualMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
ft := &m.fileTree
switch key {
case "esc", "V":
m.mode = NORMAL
m.SetStatusMsg("")
return m, nil
case "j", "down":
ft.moveDown(m.parseNumberPrefix())
m.moveByLines(m.parseNumberPrefix(), 1)
case "k", "up":
ft.moveUp(m.parseNumberPrefix())
m.moveByLines(m.parseNumberPrefix(), -1)
case "g":
if m.pendingKeys == "g" {
ft.cursor = 0
ft.scroll = 0
m.cursorY = 0
m.pendingKeys = ""
} else {
m.pendingKeys = "g"
}
return m, nil
case "G":
ft.cursor = len(ft.nodes) - 1
ft.adjustScroll(m.height - 3)
m.pendingKeys = ""
case "enter", "l":
if ft.selectedIsDir() {
ft.toggle()
} else if path := ft.selectedPath(); path != "" {
m.openFile(path)
m.focus = FocusEditor
m.fileTree.focused = false
count := m.parseNumberPrefix()
if count == 1 && m.numPrefix == "" {
m.cursorY = len(m.lines) - 1
} else if count > 0 && count <= len(m.lines) {
m.cursorY = count - 1
}
case "h":
if ft.cursor < len(ft.nodes) {
node := ft.nodes[ft.cursor]
if node.isDir && node.expanded {
ft.toggle()
} else if node.depth > 0 {
for i := ft.cursor - 1; i >= 0; i-- {
if ft.nodes[i].depth < node.depth {
ft.cursor = i
break
}
}
}
case "d", "x":
start, end := m.visualRange()
m.saveUndoSnapshot()
for i := start; i <= end; i++ {
m.deleteLine(start)
}
case "r":
ft.reload()
m.SetStatusMsg("File tree refreshed")
case "esc":
m.focus = FocusEditor
m.fileTree.focused = false
m.SetStatusMsg("")
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 "1", "2", "3", "4", "5", "6", "7", "8", "9":
m.numPrefix += key
m.SetStatusMsg(m.numPrefix)
return m, nil
case "0":
if m.numPrefix != "" {
m.numPrefix += key
m.SetStatusMsg(m.numPrefix)
}
return m, nil
}
m.updateVisualStatus()
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) {
key := msg.String()
// Числовые префиксы
if key >= "1" && key <= "9" && m.pendingKeys == "" {
m.numPrefix += key
m.SetStatusMsg(m.numPrefix)
@ -179,6 +183,11 @@ func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "?":
m.showHelp = true
return m, nil
case "V":
m.mode = VISUAL
m.visualStartY = m.cursorY
m.SetStatusMsg("")
return m, nil
case m.config.KeyForAction("toggle_preview"):
if m.fileType == "md" {
m.showPreview = !m.showPreview
@ -194,6 +203,15 @@ func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updatePreview()
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.handleEditing(key)
m.handleSearchKeys(key)
@ -207,23 +225,22 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
switch key {
case "i":
m.mode = INSERT
m.SetStatusMsg("-- INSERT --")
m.SetStatusMsg("")
case "a":
m.mode = INSERT
// 'a' — после курсора: сдвигаем на одну руну вправо
line := []rune(m.lines[m.cursorY])
if m.cursorX < len(line) {
m.cursorX++
}
m.SetStatusMsg("-- INSERT --")
m.SetStatusMsg("")
case "A":
m.mode = INSERT
m.cursorX = len([]rune(m.lines[m.cursorY]))
m.SetStatusMsg("-- INSERT --")
m.SetStatusMsg("")
case "I":
m.mode = INSERT
m.cursorX = 0
m.SetStatusMsg("-- INSERT --")
m.SetStatusMsg("")
case "o":
m.saveUndoSnapshot()
m.insertLine(m.cursorY + 1)
@ -231,14 +248,14 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
m.cursorX = 0
m.mode = INSERT
m.MarkDirty()
m.SetStatusMsg("-- INSERT --")
m.SetStatusMsg("")
case "O":
m.saveUndoSnapshot()
m.insertLine(m.cursorY)
m.cursorX = 0
m.mode = INSERT
m.MarkDirty()
m.SetStatusMsg("-- INSERT --")
m.SetStatusMsg("")
default:
return nil
}
@ -248,16 +265,14 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
func (m *EditorModel) handleMovement(key string) {
switch key {
case "h", "left":
count := m.parseNumberPrefix()
m.cursorX -= count
m.cursorX -= m.parseNumberPrefix()
m.CursorWithinBounds()
case "j", "down":
m.moveByLines(m.parseNumberPrefix(), 1)
case "k", "up":
m.moveByLines(m.parseNumberPrefix(), -1)
case "l", "right":
count := m.parseNumberPrefix()
m.cursorX += count
m.cursorX += m.parseNumberPrefix()
m.CursorWithinBounds()
case "w":
m.moveByWords(m.parseNumberPrefix(), true)
@ -274,9 +289,17 @@ func (m *EditorModel) handleMovement(key string) {
case "$":
m.numPrefix = ""
m.cursorX = len([]rune(m.lines[m.cursorY]))
case "^", "0":
case "0":
m.numPrefix = ""
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":
if m.pendingKeys == "g" {
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) {
switch key {
case "x":
@ -320,24 +577,15 @@ func (m *EditorModel) handleEditing(key string) {
m.CursorWithinBounds()
m.MarkDirty()
case "d":
if m.pendingKeys == "d" {
count := m.parseNumberPrefix()
m.saveUndoSnapshot()
for i := 0; i < count; i++ {
m.deleteLine(m.cursorY)
}
m.CursorWithinBounds()
m.MarkDirty()
m.pendingKeys = ""
} else if m.pendingKeys == "" {
// dd/dw/de/d$ и т.д. обрабатываются в handleOperatorMotion
// (перехватывается раньше handleMovement/handleEditing).
// Сюда попадаем только при первом "d" — устанавливаем pending.
if m.pendingKeys == "" {
m.pendingKeys = "d"
}
case "y":
if m.pendingKeys == "y" {
m.clipboard = m.lines[m.cursorY]
m.SetStatusMsg("Line yanked")
m.pendingKeys = ""
} else if m.pendingKeys == "" {
// yy/yw/ye и т.д. — аналогично, через handleOperatorMotion.
if m.pendingKeys == "" {
m.pendingKeys = "y"
}
case "p":
@ -361,7 +609,6 @@ func (m *EditorModel) handleEditing(key string) {
func (m *EditorModel) handleSearchKeys(key string) {
if m.pendingKeys == "f" || m.pendingKeys == "F" || m.pendingKeys == "t" || m.pendingKeys == "T" {
// Берём первую руну из нажатой клавиши
runes := []rune(key)
if len(runes) == 1 {
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) {
key := msg.String()
switch key {
case "esc":
m.mode = NORMAL
// В NORMAL курсор не может стоять после последнего символа
line := []rune(m.lines[m.cursorY])
if m.cursorX > 0 && m.cursorX >= len(line) {
m.cursorX = len(line) - 1
@ -396,7 +640,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.cursorX = 0
}
m.SetStatusMsg("")
case "up":
m.moveByLines(1, -1)
case "down":
@ -405,7 +648,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.cursorX > 0 {
m.cursorX--
} else if m.cursorY > 0 {
// Переходим на конец предыдущей строки
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) {
m.cursorX++
} else if m.cursorY < len(m.lines)-1 {
// Переходим на начало следующей строки
m.cursorY++
m.cursorX = 0
}
@ -422,14 +663,12 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.cursorX = 0
case "end":
m.cursorX = len([]rune(m.lines[m.cursorY]))
case "enter":
m.saveUndoSnapshot()
m.splitLineRune(m.cursorY, m.cursorX)
m.cursorY++
m.cursorX = 0
m.MarkDirty()
case "backspace":
m.saveUndoSnapshot()
if m.cursorX > 0 {
@ -437,7 +676,6 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.cursorX--
m.MarkDirty()
} else if m.cursorY > 0 {
// Merge с предыдущей строкой
prevLen := len([]rune(m.lines[m.cursorY-1]))
m.lines[m.cursorY-1] += m.lines[m.cursorY]
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.MarkDirty()
}
case "delete":
m.saveUndoSnapshot()
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.MarkDirty()
} else if m.cursorY < len(m.lines)-1 {
// Merge со следующей строкой
m.lines[m.cursorY] += m.lines[m.cursorY+1]
m.lines = append(m.lines[:m.cursorY+1], m.lines[m.cursorY+2:]...)
m.MarkDirty()
}
case "tab":
m.saveUndoSnapshot()
m.insertRuneAt(m.cursorY, m.cursorX, []rune(strings.Repeat(" ", m.tabWidth)))
m.cursorX += m.tabWidth
m.MarkDirty()
default:
// Любой печатаемый символ — включая кириллицу и другие Unicode
// Пробел приходит как tea.KeySpace, остальные печатаемые — tea.KeyRunes
runes := []rune(msg.String())
isPrintable := msg.Type == tea.KeyRunes ||
msg.Type == tea.KeySpace
if len(runes) == 1 && runes[0] >= 32 && isPrintable {
if len(runes) == 1 && runes[0] >= 32 &&
(msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace) {
m.saveUndoSnapshot()
m.insertRuneAt(m.cursorY, m.cursorX, runes)
m.cursorX++
m.MarkDirty()
}
}
m.CursorWithinBounds()
m.updatePreview()
return m, nil
}
// handleCommandMode — режим команд
func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
@ -503,7 +732,6 @@ func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
m.SetStatusMsg(":" + m.commandBuffer)
default:
// Принимаем Unicode в командном буфере
runes := []rune(msg.String())
if len(runes) == 1 && (runes[0] >= 32 || msg.Type == tea.KeyRunes) {
m.commandBuffer += string(runes)
@ -513,7 +741,6 @@ func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// handleSearchMode — режим поиска
func (m *EditorModel) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
@ -564,9 +791,8 @@ func (m *EditorModel) movePrevWord() {
}
}
// === Редактирование (rune-safe) ===
// === Редактирование ===
// insertRuneAt вставляет руны в строку на позиции pos (в рунах)
func (m *EditorModel) insertRuneAt(lineIdx, pos int, runes []rune) {
line := []rune(m.lines[lineIdx])
if pos < 0 {
@ -582,7 +808,6 @@ func (m *EditorModel) insertRuneAt(lineIdx, pos int, runes []rune) {
m.lines[lineIdx] = string(newLine)
}
// deleteCharAt удаляет символ на позиции pos (в рунах)
func (m *EditorModel) deleteCharAt(lineIdx, pos int) {
line := []rune(m.lines[lineIdx])
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:]...))
}
// splitLineRune разбивает строку на позиции pos (в рунах)
func (m *EditorModel) splitLineRune(lineIdx, pos int) {
line := []rune(m.lines[lineIdx])
if pos < 0 {
@ -636,4 +860,4 @@ func (m *EditorModel) deleteLine(idx int) {
} else {
m.lines = []string{""}
}
}
}

21
main.go Normal file → Executable file
View file

@ -80,7 +80,10 @@ func main() {
return
case menuNewFile:
filePath = "untitled.md"
// Гарантированно пустой буфер — даже если untitled.md уже существует
// на диске от прошлой сессии, не подгружаем его содержимое.
runNewEditor(cfg)
return
case menuOpenFile:
filePath = result.FilePath
@ -110,6 +113,20 @@ func main() {
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 загружает файл и запускает редактор.
// showHelp=true открывает справку сразу при старте.
func runEditor(filePath string, cfg Config, showHelp bool) {
@ -130,4 +147,4 @@ func runEditor(filePath string, cfg Config, showHelp bool) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
}

76
menu.go Normal file → Executable file
View file

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@ -60,6 +61,13 @@ type menuModel struct {
input string // буфер для ввода пути/URL
inputMode string // "" / "file" / "url"
err string // сообщение об ошибке
// Детектор аномально быстрого ввода (вставка большого текста посимвольно).
// Терминал может разбивать paste на отдельные KeyMsg без bracketed-paste
// маркеров — тогда обычная защита по длине срабатывает слишком поздно,
// так как Ctrl+C встаёт в конец уже накопленной очереди событий.
lastKeyTime time.Time
rapidKeyCount int
}
// doneMsg сигнализирует о завершении меню
@ -131,11 +139,15 @@ func (m menuModel) selectItem(action menuAction) (tea.Model, tea.Cmd) {
m.inputMode = "file"
m.input = ""
m.err = ""
m.rapidKeyCount = 0
m.lastKeyTime = time.Time{}
return m, nil
case menuFetchURL:
m.inputMode = "url"
m.input = ""
m.err = ""
m.rapidKeyCount = 0
m.lastKeyTime = time.Time{}
return m, nil
case 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) {
// Детектор вставки большого текста посимвольно: если терминал не
// поддерживает 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() {
case "ctrl+c":
// Гарантированный выход даже если что-то пошло не так с вводом
// (например случайная вставка большого текста)
return m, tea.Quit
case "esc":
m.inputMode = ""
m.input = ""
m.err = ""
m.rapidKeyCount = 0
case "enter":
if m.input == "" {
m.err = "Введите значение"
@ -173,7 +214,13 @@ func (m menuModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.err = ""
default:
if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace {
m.input += msg.String()
// Лимит защищает от случайной вставки большого текста
// (например содержимого файла через paste), которая иначе
// засоряет поле ввода и делает интерфейс нечитаемым.
const maxInputLen = 500
if len([]rune(m.input)) < maxInputLen {
m.input += msg.String()
}
m.err = ""
}
}
@ -291,7 +338,20 @@ func (m menuModel) View() string {
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)
promptPad := (m.width - promptVis) / 2
if promptPad < 0 {
@ -310,7 +370,17 @@ func (m menuModel) View() string {
}
out.WriteString(strings.Repeat(" ", errPad))
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")
} else {
hint := dimStyle.Render("↑↓ навигация Enter выбор q выйти")
@ -344,4 +414,4 @@ func RunMenu() *MenuResult {
return fm.result
}
return nil
}
}

247
model.go Normal file → Executable file
View file

@ -1,7 +1,6 @@
package main
import (
"path/filepath"
"strings"
"time"
@ -20,14 +19,6 @@ const (
REPLACE
)
// PanelFocus — какая панель принимает клавиши
type PanelFocus int
const (
FocusEditor PanelFocus = iota
FocusFileTree
)
// undoSnapshot — снимок состояния для undo/redo
type undoSnapshot struct {
lines []string
@ -35,12 +26,8 @@ type undoSnapshot struct {
cursorY int
}
// Минимальные ширины панелей
const (
minEditorWidth = 20
minTreeWidth = 15
minPreviewWidth = 20
)
// Минимальная ширина панели preview
const minPreviewWidth = 20
type EditorModel struct {
// Контент
@ -49,12 +36,9 @@ type EditorModel struct {
cursorY int
// Vim режимы
mode VimMode
pendingKeys string
selection struct {
startX, startY int
endX, endY int
}
mode VimMode
pendingKeys string
visualStartY int // строка где начато VISUAL LINE выделение
// Файл
filepath string
@ -63,28 +47,24 @@ type EditorModel struct {
lastSave time.Time
statusMsg string
// UI — размеры
// UI
width int
height int
treeWidth int
previewWidth int
// Видимость панелей
showFileTree bool
showPreview bool
showPreview bool
// Фокус
focus PanelFocus
// Настройки (из конфига или :set)
// Настройки
showLineNumbers bool
tabWidth int
autosaveInterval int // секунды
cfgShowPreview bool // дефолт для preview из конфига
autosaveInterval int
cfgShowPreview bool
// Viewport
viewport viewport.Model
previewBuf string
viewport viewport.Model
previewBuf string
previewDirty bool
// Команда
commandBuffer string
@ -111,31 +91,27 @@ type EditorModel struct {
undoStack []undoSnapshot
redoStack []undoSnapshot
// Файловое дерево
fileTree FileTree
// Help экран
showHelp bool
// Конфиг (хранится для :config show)
// Пасхалка
showTzeentch bool
tzeentchIdx int
// Конфиг
config Config
}
// Типы событий
type TickMsg time.Time
// NewEditor создаёт новый редактор с применением конфига
// NewEditor создаёт новый редактор
func NewEditor(path, fileType, content string, cfg Config) *EditorModel {
lines := strings.Split(content, "\n")
if len(lines) == 0 {
lines = []string{""}
}
rootDir := filepath.Dir(path)
if rootDir == "" || rootDir == "." {
rootDir, _ = filepath.Abs(".")
}
m := &EditorModel{
lines: lines,
filepath: path,
@ -148,27 +124,14 @@ func NewEditor(path, fileType, content string, cfg Config) *EditorModel {
tabWidth: cfg.TabWidth,
autosaveInterval: cfg.AutosaveInterval,
cfgShowPreview: cfg.ShowPreview,
showPreview: cfg.ShowPreview && fileType == "md" || fileType == "md" && cfg.ShowPreview,
showFileTree: cfg.ShowTree,
focus: FocusEditor,
showPreview: cfg.ShowPreview && fileType == "md",
lastSave: time.Now(),
viewport: viewport.New(0, 0),
width: 120,
height: 30,
treeWidth: cfg.TreeWidth,
previewWidth: cfg.PreviewWidth,
previewDirty: true,
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()
@ -176,7 +139,6 @@ func NewEditor(path, fileType, content string, cfg Config) *EditorModel {
return m
}
// Init инициализирует модель
func (m *EditorModel) Init() tea.Cmd {
return m.tickCmd()
}
@ -184,14 +146,13 @@ func (m *EditorModel) Init() tea.Cmd {
func (m *EditorModel) tickCmd() tea.Cmd {
interval := time.Duration(m.autosaveInterval) * time.Second
if interval <= 0 {
interval = 60 * time.Second // fallback
interval = 60 * time.Second
}
return tea.Tick(interval, func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
// Update обрабатывает сообщения
func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@ -203,7 +164,31 @@ func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
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:
if m.autosaveInterval > 0 &&
@ -217,84 +202,40 @@ func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// View возвращает строку для вывода
func (m *EditorModel) View() string {
if m.showTzeentch {
return renderTzeentch(m)
}
if m.showHelp {
return renderHelp(m)
}
switch {
case m.showFileTree && m.showPreview && m.fileType == "md":
return m.viewTreeEditorPreview()
case m.showFileTree:
return m.viewTreeEditor()
case m.showPreview && m.fileType == "md":
return m.viewEditorPreview()
default:
return m.viewEditorOnly()
if m.showPreview && m.fileType == "md" {
return renderSplit(m)
}
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 {
w := m.width
if m.showFileTree {
w -= m.treeWidth
}
if m.showPreview && m.fileType == "md" {
w -= m.previewWidth
}
if w < minEditorWidth {
w = minEditorWidth
if w < 20 {
w = 20
}
return w
}
func (m *EditorModel) clampPanelWidths() {
if m.treeWidth < minTreeWidth {
m.treeWidth = minTreeWidth
}
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
needed := minEditorWidth
if m.showFileTree {
needed += m.treeWidth
}
if m.showPreview && m.fileType == "md" {
needed += m.previewWidth
}
if needed > m.width && m.width > 0 {
available := m.width - minEditorWidth
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
needed := 20 + m.previewWidth
if needed > m.width && m.width > 0 {
m.previewWidth = m.width - 20
if 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) {
m.previewWidth += delta
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
maxPreview := m.width - minEditorWidth
if m.showFileTree {
maxPreview -= m.treeWidth
}
if m.previewWidth > maxPreview {
m.previewWidth = maxPreview
max := m.width - 20
if m.previewWidth > max {
m.previewWidth = max
}
}
// === Открытие файла из дерева ===
// === Открытие файла ===
func (m *EditorModel) openFile(path string) {
content, err := LoadFile(path)
@ -357,6 +281,7 @@ func (m *EditorModel) openFile(path string) {
m.numPrefix = ""
m.pendingKeys = ""
m.showPreview = fileType == "md" && m.cfgShowPreview
m.previewDirty = true
m.mode = NORMAL
m.clampPanelWidths()
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) 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() {
if m.cursorY < 0 {
@ -379,7 +307,6 @@ func (m *EditorModel) CursorWithinBounds() {
m.cursorY = len(m.lines) - 1
}
maxX := len([]rune(m.lines[m.cursorY]))
// В NORMAL режиме курсор не может стоять за последним символом
if m.mode != INSERT && maxX > 0 {
maxX--
}
@ -393,16 +320,30 @@ func (m *EditorModel) CursorWithinBounds() {
// === 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() {
snapshot := undoSnapshot{
snap := undoSnapshot{
lines: make([]string, len(m.lines)),
cursorX: m.cursorX,
cursorY: m.cursorY,
}
copy(snapshot.lines, m.lines)
m.undoStack = append(m.undoStack, snapshot)
if len(m.undoStack) > 100 {
m.undoStack = m.undoStack[1:]
copy(snap.lines, m.lines)
limit := m.undoLimit()
m.undoStack = append(m.undoStack, snap)
if len(m.undoStack) > limit {
m.undoStack = m.undoStack[len(m.undoStack)-limit:]
}
m.redoStack = nil
}
@ -422,12 +363,13 @@ func (m *EditorModel) Undo() {
snap := m.undoStack[len(m.undoStack)-1]
m.undoStack = m.undoStack[:len(m.undoStack)-1]
m.lines = make([]string, len(snap.lines))
copy(m.lines, snap.lines)
m.lines = snap.lines
m.cursorX = snap.cursorX
m.cursorY = snap.cursorY
m.CursorWithinBounds()
m.dirty = true
m.previewDirty = true
InvalidateHighlightCache()
m.SetStatusMsg("Undo")
}
@ -447,11 +389,12 @@ func (m *EditorModel) Redo() {
copy(undoSnap.lines, m.lines)
m.undoStack = append(m.undoStack, undoSnap)
m.lines = make([]string, len(snap.lines))
copy(m.lines, snap.lines)
m.lines = snap.lines
m.cursorX = snap.cursorX
m.cursorY = snap.cursorY
m.CursorWithinBounds()
m.dirty = true
m.previewDirty = true
InvalidateHighlightCache()
m.SetStatusMsg("Redo")
}
}

0
pdf.go Normal file → Executable file
View file

0
pdf_font.go Normal file → Executable file
View file

180
render.go Normal file → Executable file
View file

@ -4,11 +4,21 @@ import (
"fmt"
"regexp"
"strings"
"sync"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
// === Кеш visibleWidth ===
// Большинство строк рендерятся повторно без изменений — кешируем результат.
var (
vwCache = map[string]int{}
vwCacheMu sync.RWMutex
vwCacheMax = 4000
)
// === UI стили ===
var (
colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
@ -23,7 +33,7 @@ var (
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)
}
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, по одной на строку экрана) ===
@ -78,6 +74,12 @@ func buildEditorRows(m *EditorModel, width, height int) []string {
contentW = 1
}
// Диапазон выделения для VISUAL LINE режима (если активен)
var visStart, visEnd int = -1, -1
if m.mode == VISUAL {
visStart, visEnd = m.visualRange()
}
for lineNum := start; lineNum < end; lineNum++ {
var sb strings.Builder
if m.showLineNumbers {
@ -93,7 +95,12 @@ func buildEditorRows(m *EditorModel, width, height int) []string {
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())
}
@ -108,55 +115,7 @@ func buildEditorRows(m *EditorModel, width, height int) []string {
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
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 {
var out strings.Builder
for row := 0; row < height; row++ {
out.WriteString(clearLine)
for ci, col := range cols {
w := widths[ci]
line := ""
@ -210,7 +170,11 @@ func joinRowColumns(cols [][]string, widths []int, height int) 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}
if m.showFileTree {
hints = append(hints, fmt.Sprintf("tree:%d", m.treeWidth))
}
if m.showPreview && m.fileType == "md" {
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"
}
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 возвращает визуальную ширину строки в терминале,
// учитывая ANSI escape-коды и double-width символы через go-runewidth.
// Результат кешируется — большинство строк повторяются между кадрами.
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
inEsc := false
for _, r := range s {
@ -349,9 +304,23 @@ func visibleWidth(s string) int {
}
w += runewidth.RuneWidth(r)
}
vwCacheMu.Lock()
if len(vwCache) < vwCacheMax {
vwCache[s] = w
}
vwCacheMu.Unlock()
return w
}
// InvalidateVWCache сбрасывает кеш visibleWidth
func InvalidateVWCache() {
vwCacheMu.Lock()
vwCache = map[string]int{}
vwCacheMu.Unlock()
}
// stripANSI удаляет все ANSI escape-последовательности
func stripANSI(s string) string {
var sb strings.Builder
@ -373,6 +342,31 @@ func stripANSI(s string) string {
}
// 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 {
if maxWidth <= 0 {
return ""
@ -401,6 +395,11 @@ func truncateVisible(s string, maxWidth int) string {
vis += rw
}
sb.WriteString("\x1b[0m")
// Добиваем пробелами до точной ширины — без этого короткие строки
// не перекрывают остатки предыдущего, более длинного контента.
if vis < maxWidth {
sb.WriteString(strings.Repeat(" ", maxWidth-vis))
}
return sb.String()
}
@ -500,7 +499,12 @@ func (m *EditorModel) updatePreview() {
m.previewBuf = ""
return
}
// Пересчитываем только если контент изменился
if !m.previewDirty {
return
}
m.previewBuf = renderMarkdown(m.lines)
m.previewDirty = false
}
func renderMarkdown(lines []string) string {
@ -642,4 +646,4 @@ func max(a, b int) int {
return a
}
return b
}
}

26
search.go Normal file → Executable file
View file

@ -1,6 +1,7 @@
package main
import (
"fmt"
"strings"
)
@ -82,6 +83,10 @@ func (m *EditorModel) startSearch() {
m.SetStatusMsg("/")
}
// maxSearchMatches — максимум совпадений при поиске
// Предотвращает зависание на файлах с сотнями тысяч строк
const maxSearchMatches = 1000
func (m *EditorModel) performSearch(pattern string) {
m.lastSearchTerm = pattern
m.searchMatches = nil
@ -91,10 +96,11 @@ func (m *EditorModel) performSearch(pattern string) {
return
}
patRunes := []rune(pattern)
truncated := false
for lineNum, line := range m.lines {
// Ищем в рунах чтобы позиции были корректными
lineRunes := []rune(line)
patRunes := []rune(pattern)
for i := 0; i <= len(lineRunes)-len(patRunes); i++ {
match := true
for j, r := range patRunes {
@ -105,8 +111,15 @@ func (m *EditorModel) performSearch(pattern string) {
}
if match {
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 {
@ -114,7 +127,12 @@ func (m *EditorModel) performSearch(pattern string) {
m.cursorY = match.line
m.cursorX = match.col
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 {
m.SetStatusMsg("Pattern not found: " + pattern)
}
@ -281,4 +299,4 @@ func (m *EditorModel) findMatchingBracket() {
}
}
m.SetStatusMsg("No matching bracket")
}
}

207
syntax.go Normal file → Executable file
View file

@ -3,6 +3,7 @@ package main
import (
"regexp"
"strings"
"sync"
"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 — тип токена
type tokenKind int
const (
tokenPlain tokenKind = iota
tokenKeyword // ключевое слово
tokenString // строковый литерал
tokenComment // комментарий
tokenNumber // число
tokenVar // переменная ($VAR)
tokenKeyword
tokenString
tokenComment
tokenNumber
tokenVar
)
// token — фрагмент строки с типом
@ -109,23 +228,13 @@ type token struct {
kind tokenKind
}
// HighlightLine подсвечивает одну строку кода
func HighlightLine(line, fileType string) string {
cfg, ok := languageConfigs[fileType]
if !ok {
return line
}
tokens := tokenize(line, cfg)
return renderTokens(tokens, cfg)
}
// tokenize разбивает строку на токены не применяя стили
func tokenize(line string, cfg LanguageConfig) []token {
// tokenizeWithPatterns разбивает строку на токены используя предкомпилированные regexp
func tokenizeWithPatterns(line string, cfg LanguageConfig, p *compiledLangPatterns) []token {
if line == "" {
return nil
}
// Шаг 1: находим комментарий — всё после него plain (красим как comment целиком)
// Находим комментарий
commentStart := -1
if cfg.LineCommentPrefix != "" {
commentStart = strings.Index(line, cfg.LineCommentPrefix)
@ -138,27 +247,15 @@ func tokenize(line string, cfg LanguageConfig) []token {
comment = line[commentStart:]
}
// Шаг 2: разбиваем code на токены через span-маппинг
// Каждая позиция байта получает тип; потом склеиваем в токены
n := len(code)
kinds := make([]tokenKind, n)
// Строковые литералы
for _, delim := range cfg.StringDelimiters {
var re *regexp.Regexp
switch delim {
case `"`:
re = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
case `'`:
re = regexp.MustCompile(`'(?:[^'\\]|\\.)*'`)
case "`":
re = regexp.MustCompile("`[^`]*`")
}
// Строки
for _, re := range p.strings {
if re == nil {
continue
}
for _, loc := range re.FindAllStringIndex(code, -1) {
// Только если это место ещё не занято
already := false
for i := loc[0]; i < loc[1]; i++ {
if kinds[i] != tokenPlain {
@ -175,15 +272,8 @@ func tokenize(line string, cfg LanguageConfig) []token {
}
// Ключевые слова
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`)
}
for _, re := range p.keywords {
for _, loc := range re.FindAllStringIndex(code, -1) {
// Не перекрываем строки
inString := false
for i := loc[0]; i < loc[1]; i++ {
if kinds[i] == tokenString {
@ -200,45 +290,33 @@ func tokenize(line string, cfg LanguageConfig) []token {
}
// Числа
if cfg.HasNumbers {
re := regexp.MustCompile(`\b\d+(\.\d+)?\b`)
for _, loc := range re.FindAllStringIndex(code, -1) {
inString := false
if p.numbers != nil {
for _, loc := range p.numbers.FindAllStringIndex(code, -1) {
skip := false
for i := loc[0]; i < loc[1]; i++ {
if kinds[i] == tokenString {
inString = true
if kinds[i] != tokenPlain {
skip = true
break
}
}
if !inString {
// Не красим если уже keyword
isKW := false
if !skip {
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++ {
kinds[i] = tokenNumber
}
kinds[i] = tokenNumber
}
}
}
}
// Переменные ($VAR)
if cfg.VarPrefix != "" {
re := regexp.MustCompile(regexp.QuoteMeta(cfg.VarPrefix) + `\w+`)
for _, loc := range re.FindAllStringIndex(code, -1) {
// Переменные
if p.varPattern != nil {
for _, loc := range p.varPattern.FindAllStringIndex(code, -1) {
for i := loc[0]; i < loc[1]; i++ {
kinds[i] = tokenVar
}
}
}
// Шаг 3: склеиваем соседние байты одного типа в токены
// Склеиваем в токены
var tokens []token
i := 0
for i < n {
@ -251,7 +329,6 @@ func tokenize(line string, cfg LanguageConfig) []token {
i = j
}
// Комментарий добавляем в конец
if comment != "" {
tokens = append(tokens, token{text: comment, kind: tokenComment})
}
@ -283,4 +360,4 @@ func renderTokens(tokens []token, cfg LanguageConfig) string {
}
}
return sb.String()
}
}

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 давно нашли и исправили. Теперь исследователям приходится искать баги в старых имплементациях протоколов, логике переиспользования соединений, обработчиках обратных вызовов и других редко используемых участках кода.