Added new funcs
This commit is contained in:
parent
42bc698976
commit
68b3d1834a
25 changed files with 1139 additions and 860 deletions
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
ARCHITECTURE.md
Normal file → Executable file
0
ARCHITECTURE.md
Normal file → Executable file
0
LICENSE
Normal file → Executable file
0
LICENSE
Normal file → Executable file
0
README.md
Normal file → Executable file
0
README.md
Normal file → Executable file
56
commands.go
Normal file → Executable file
56
commands.go
Normal file → Executable 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
39
config.go
Normal file → Executable 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
0
fetch.go
Normal file → Executable file
0
file.go
Normal file → Executable file
0
file.go
Normal file → Executable file
0
file_io.go
Normal file → Executable file
0
file_io.go
Normal file → Executable file
305
filetree.go
305
filetree.go
|
|
@ -1,305 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// === Стили файлового дерева ===
|
||||
var (
|
||||
styleTreeDir = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Bold(true)
|
||||
styleTreeFile = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
||||
styleTreeSelected = lipgloss.NewStyle().Background(lipgloss.Color("237")).Foreground(lipgloss.Color("15")).Bold(true)
|
||||
styleTreeHeader = lipgloss.NewStyle().Background(lipgloss.Color("8")).Foreground(lipgloss.Color("15")).Bold(true)
|
||||
styleTreeCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
|
||||
)
|
||||
|
||||
// FileNode — узел файлового дерева
|
||||
type FileNode struct {
|
||||
name string
|
||||
path string
|
||||
isDir bool
|
||||
expanded bool
|
||||
depth int
|
||||
children []*FileNode
|
||||
}
|
||||
|
||||
// FileTree — состояние панели файлового дерева
|
||||
type FileTree struct {
|
||||
root string // корневая директория
|
||||
nodes []*FileNode // плоский список видимых узлов
|
||||
cursor int // индекс выбранного узла
|
||||
scroll int // смещение прокрутки
|
||||
focused bool // фокус на этой панели
|
||||
}
|
||||
|
||||
// NewFileTree создаёт новое дерево для директории rootDir
|
||||
func NewFileTree(rootDir string) FileTree {
|
||||
ft := FileTree{
|
||||
root: rootDir,
|
||||
cursor: 0,
|
||||
scroll: 0,
|
||||
focused: false,
|
||||
}
|
||||
ft.reload()
|
||||
return ft
|
||||
}
|
||||
|
||||
// reload перечитывает директорию и обновляет плоский список
|
||||
func (ft *FileTree) reload() {
|
||||
rootNode := &FileNode{
|
||||
name: filepath.Base(ft.root),
|
||||
path: ft.root,
|
||||
isDir: true,
|
||||
expanded: true,
|
||||
depth: 0,
|
||||
}
|
||||
loadChildren(rootNode)
|
||||
ft.nodes = flattenTree(rootNode)
|
||||
}
|
||||
|
||||
// loadChildren рекурсивно загружает дочерние узлы
|
||||
func loadChildren(node *FileNode) {
|
||||
if !node.isDir {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(node.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Сортируем: сначала папки, потом файлы, алфавитно
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].IsDir() != entries[j].IsDir() {
|
||||
return entries[i].IsDir()
|
||||
}
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
node.children = nil
|
||||
for _, e := range entries {
|
||||
// Пропускаем скрытые файлы и типичный мусор
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
if name == "node_modules" || name == "vendor" || name == "__pycache__" {
|
||||
continue
|
||||
}
|
||||
|
||||
child := &FileNode{
|
||||
name: name,
|
||||
path: filepath.Join(node.path, name),
|
||||
isDir: e.IsDir(),
|
||||
depth: node.depth + 1,
|
||||
}
|
||||
node.children = append(node.children, child)
|
||||
}
|
||||
}
|
||||
|
||||
// flattenTree возвращает плоский список видимых узлов
|
||||
func flattenTree(node *FileNode) []*FileNode {
|
||||
result := []*FileNode{node}
|
||||
if node.isDir && node.expanded {
|
||||
for _, child := range node.children {
|
||||
result = append(result, flattenTree(child)...)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toggle разворачивает/сворачивает директорию под курсором
|
||||
func (ft *FileTree) toggle() {
|
||||
if ft.cursor >= len(ft.nodes) {
|
||||
return
|
||||
}
|
||||
node := ft.nodes[ft.cursor]
|
||||
if !node.isDir {
|
||||
return
|
||||
}
|
||||
node.expanded = !node.expanded
|
||||
if node.expanded && len(node.children) == 0 {
|
||||
loadChildren(node)
|
||||
}
|
||||
ft.rebuildFlat()
|
||||
}
|
||||
|
||||
// rebuildFlat пересобирает плоский список из корня
|
||||
func (ft *FileTree) rebuildFlat() {
|
||||
if len(ft.nodes) == 0 {
|
||||
return
|
||||
}
|
||||
root := ft.nodes[0]
|
||||
ft.nodes = flattenTree(root)
|
||||
if ft.cursor >= len(ft.nodes) {
|
||||
ft.cursor = len(ft.nodes) - 1
|
||||
}
|
||||
}
|
||||
|
||||
// moveUp перемещает курсор вверх
|
||||
func (ft *FileTree) moveUp(n int) {
|
||||
ft.cursor -= n
|
||||
if ft.cursor < 0 {
|
||||
ft.cursor = 0
|
||||
}
|
||||
ft.adjustScroll(0)
|
||||
}
|
||||
|
||||
// moveDown перемещает курсор вниз
|
||||
func (ft *FileTree) moveDown(n int) {
|
||||
ft.cursor += n
|
||||
if ft.cursor >= len(ft.nodes) {
|
||||
ft.cursor = len(ft.nodes) - 1
|
||||
}
|
||||
ft.adjustScroll(0)
|
||||
}
|
||||
|
||||
// adjustScroll корректирует scroll чтобы cursor был виден
|
||||
func (ft *FileTree) adjustScroll(height int) {
|
||||
if height <= 0 {
|
||||
return
|
||||
}
|
||||
if ft.cursor < ft.scroll {
|
||||
ft.scroll = ft.cursor
|
||||
}
|
||||
if ft.cursor >= ft.scroll+height {
|
||||
ft.scroll = ft.cursor - height + 1
|
||||
}
|
||||
}
|
||||
|
||||
// selectedPath возвращает путь выбранного узла (или "" если курсор вне границ)
|
||||
func (ft *FileTree) selectedPath() string {
|
||||
if ft.cursor < 0 || ft.cursor >= len(ft.nodes) {
|
||||
return ""
|
||||
}
|
||||
return ft.nodes[ft.cursor].path
|
||||
}
|
||||
|
||||
// selectedIsDir возвращает true если выбранный узел — директория
|
||||
func (ft *FileTree) selectedIsDir() bool {
|
||||
if ft.cursor < 0 || ft.cursor >= len(ft.nodes) {
|
||||
return false
|
||||
}
|
||||
return ft.nodes[ft.cursor].isDir
|
||||
}
|
||||
|
||||
// renderFileTree рендерит панель файлового дерева
|
||||
func renderFileTree(m *EditorModel) string {
|
||||
ft := &m.fileTree
|
||||
width := m.treeWidth
|
||||
height := m.height - 3
|
||||
|
||||
var output strings.Builder
|
||||
|
||||
// Заголовок
|
||||
title := filepath.Base(ft.root)
|
||||
if len(title) > width-4 {
|
||||
title = title[:width-4]
|
||||
}
|
||||
headerStyle := styleTreeHeader.Copy().Width(width).Padding(0, 1)
|
||||
if ft.focused {
|
||||
headerStyle = headerStyle.Background(lipgloss.Color("4"))
|
||||
}
|
||||
output.WriteString(headerStyle.Render(" 📁 " + title))
|
||||
output.WriteString("\n")
|
||||
|
||||
ft.adjustScroll(height)
|
||||
|
||||
// Список файлов
|
||||
for i := ft.scroll; i < ft.scroll+height && i < len(ft.nodes); i++ {
|
||||
node := ft.nodes[i]
|
||||
line := renderFileNode(node, m.filepath, width)
|
||||
|
||||
if i == ft.cursor && ft.focused {
|
||||
line = styleTreeSelected.Render(line)
|
||||
} else if i == ft.cursor {
|
||||
line = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render(line)
|
||||
}
|
||||
// plain строки — без Width() рендера, joinColumns дополнит пробелами
|
||||
|
||||
output.WriteString(line)
|
||||
output.WriteString("\n")
|
||||
}
|
||||
|
||||
// Заполнить пустые строки
|
||||
rendered := min(height, max(0, len(ft.nodes)-ft.scroll))
|
||||
for i := rendered; i < height; i++ {
|
||||
output.WriteString("\n")
|
||||
}
|
||||
|
||||
// Нижняя строка с подсказкой
|
||||
hint := " Enter:открыть Tab:фокус </>:ширина"
|
||||
if len(hint) > width {
|
||||
hint = hint[:width]
|
||||
}
|
||||
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
|
||||
output.WriteString(hintStyle.Render(hint))
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// renderFileNode рендерит одну строку узла
|
||||
func renderFileNode(node *FileNode, currentFile string, width int) string {
|
||||
// Отступ по глубине (корень не отступаем)
|
||||
indent := ""
|
||||
if node.depth > 0 {
|
||||
indent = strings.Repeat(" ", node.depth-1)
|
||||
}
|
||||
|
||||
var icon, name string
|
||||
|
||||
if node.isDir {
|
||||
if node.expanded {
|
||||
icon = "▾ "
|
||||
} else {
|
||||
icon = "▸ "
|
||||
}
|
||||
name = styleTreeDir.Render(node.name)
|
||||
} else {
|
||||
icon = " "
|
||||
// Выделяем текущий открытый файл
|
||||
if node.path == currentFile {
|
||||
name = styleTreeCurrent.Render("● " + node.name)
|
||||
} else {
|
||||
name = styleTreeFile.Render(node.name)
|
||||
}
|
||||
}
|
||||
|
||||
line := indent + icon + name
|
||||
|
||||
// Обрезаем если не влезает (с учётом ANSI-кодов это приблизительно)
|
||||
visibleLen := len(indent) + len(icon) + len(node.name) + 2
|
||||
if visibleLen > width {
|
||||
// Обрезаем имя
|
||||
maxName := width - len(indent) - len(icon) - 1
|
||||
if maxName < 3 {
|
||||
maxName = 3
|
||||
}
|
||||
truncated := node.name
|
||||
if len(truncated) > maxName {
|
||||
truncated = truncated[:maxName-1] + "…"
|
||||
}
|
||||
if node.isDir {
|
||||
name = styleTreeDir.Render(truncated)
|
||||
} else if node.path == currentFile {
|
||||
name = styleTreeCurrent.Render("● " + truncated)
|
||||
} else {
|
||||
name = styleTreeFile.Render(truncated)
|
||||
}
|
||||
line = indent + icon + name
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
0
go.mod
Normal file → Executable file
0
go.mod
Normal file → Executable file
0
go.sum
Normal file → Executable file
0
go.sum
Normal file → Executable file
30
help.go
Normal file → Executable file
30
help.go
Normal file → Executable 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
512
keybindings.go
Normal file → Executable 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
21
main.go
Normal file → Executable 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
76
menu.go
Normal file → Executable 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
247
model.go
Normal file → Executable 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
0
pdf.go
Normal file → Executable file
0
pdf_font.go
Normal file → Executable file
0
pdf_font.go
Normal file → Executable file
180
render.go
Normal file → Executable file
180
render.go
Normal file → Executable 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
26
search.go
Normal file → Executable 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
207
syntax.go
Normal file → Executable 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
269
tzeentch.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// === Пасхалка: Just as planned ===
|
||||
|
||||
var tzeentchQuotes = []struct {
|
||||
ru string
|
||||
en string
|
||||
source string
|
||||
}{
|
||||
{
|
||||
ru: `Тзинч — это перемены. Тзинч — это надежда.
|
||||
Тзинч — это движение вперёд, к лучшему будущему,
|
||||
которое всегда маячит впереди, всегда достижимо,
|
||||
но никогда не достигнуто. Ибо достижение цели —
|
||||
это конец перемен, а конец перемен — это смерть.`,
|
||||
en: `Tzeentch is change. Tzeentch is hope.
|
||||
Tzeentch is the drive forward, toward a better future
|
||||
that always beckons, always within reach,
|
||||
yet never grasped. For to reach one's goal
|
||||
is the end of change, and the end of change is death.`,
|
||||
source: "Книга Тзинча / The Book of Tzeentch",
|
||||
},
|
||||
{
|
||||
ru: `Всё свершается по его плану. Каждое предательство,
|
||||
каждый союз, каждая победа и каждое поражение —
|
||||
лишь фигуры на доске Изменника Бога.
|
||||
Вы думаете, что действуете по своей воле?
|
||||
Именно этого он и хотел, чтобы вы думали.`,
|
||||
en: `All unfolds according to his plan. Every betrayal,
|
||||
every alliance, every victory and every defeat —
|
||||
mere pieces on the Changer of Ways' board.
|
||||
You believe you act of your own free will?
|
||||
That is precisely what he wanted you to think.`,
|
||||
source: "Еретические письмена, авт. неизв. / Heretical Writings, author unknown",
|
||||
},
|
||||
{
|
||||
ru: `Говорят, что Тзинч знает каждую нить судьбы
|
||||
каждого существа во Вселенной.
|
||||
Говорят, что он плетёт их по своему усмотрению.
|
||||
Говорят многое.
|
||||
Тзинч позаботился об этом.`,
|
||||
en: `They say Tzeentch knows every thread of fate
|
||||
of every being in the Universe.
|
||||
They say he weaves them as he sees fit.
|
||||
They say many things.
|
||||
Tzeentch made sure of that.`,
|
||||
source: "Apocrypha of Amon Sul",
|
||||
},
|
||||
{
|
||||
ru: `Мы — его пешки. Его рыцари. Его ладьи.
|
||||
Но никто из нас не знает своей роли на доске.
|
||||
Только он видит партию целиком.
|
||||
И он никогда не проигрывает.
|
||||
Ибо проигрыш — тоже часть плана.`,
|
||||
en: `We are his pawns. His knights. His rooks.
|
||||
But none of us knows our role upon the board.
|
||||
Only he sees the game entire.
|
||||
And he never loses.
|
||||
For defeat, too, is part of the plan.`,
|
||||
source: "Из признаний чародея Эгримм Вар Гольда / Confessions of Sorcerer Egrimm van Horstmann",
|
||||
},
|
||||
{
|
||||
ru: `Судьба — не нить, которую можно оборвать.
|
||||
Это сеть, бесконечно сотканная рукой Тзинча.
|
||||
Каждый узел — это жизнь. Каждый разрыв —
|
||||
рождение новой нити, более сложной и прекрасной.
|
||||
Смерть — лишь ещё один узор в его гобелене.`,
|
||||
en: `Fate is not a thread that can be severed.
|
||||
It is a web, endlessly woven by the hand of Tzeentch.
|
||||
Every knot is a life. Every break
|
||||
births a new thread, more intricate and beautiful.
|
||||
Death is merely another pattern in his tapestry.`,
|
||||
source: "Liber Malefic, фрагмент XVII / Liber Malefic, Fragment XVII",
|
||||
},
|
||||
{
|
||||
ru: `«Зачем служить Тзинчу?» — спросил послушник.
|
||||
«Потому что ты уже служишь ему», — ответил магистр.
|
||||
«Как это возможно? Я ещё не давал клятвы».
|
||||
«Ты задал этот вопрос. Этого достаточно».`,
|
||||
en: `"Why serve Tzeentch?" asked the novice.
|
||||
"Because you already do," replied the magister.
|
||||
"How can that be? I have sworn no oath."
|
||||
"You asked that question. That is enough."`,
|
||||
source: "Диалоги Тысячи Сынов / Dialogues of the Thousand Sons",
|
||||
},
|
||||
{
|
||||
ru: `Магнус видел всё.
|
||||
Видел падение Просперо. Видел предательство.
|
||||
Видел собственное проклятие.
|
||||
И всё равно сделал именно то,
|
||||
что от него ожидал Тзинч.
|
||||
Всё идёт по плану.`,
|
||||
en: `Magnus saw everything.
|
||||
He saw the fall of Prospero. Saw the betrayal.
|
||||
Saw his own damnation.
|
||||
And still he did precisely what
|
||||
Tzeentch had always intended.
|
||||
All is proceeding as planned.`,
|
||||
source: "Ересь Хоруса, том XI / Horus Heresy, Vol. XI",
|
||||
},
|
||||
{
|
||||
ru: `Не бойся перемен, смертный.
|
||||
Бойся их отсутствия.
|
||||
Ибо то, что не меняется — уже мертво.
|
||||
А мёртвое принадлежит другому богу.`,
|
||||
en: `Fear not change, mortal.
|
||||
Fear its absence.
|
||||
For that which does not change is already dead.
|
||||
And the dead belong to another god.`,
|
||||
source: "Слова Аримана / Words of Ahriman of the Thousand Sons",
|
||||
},
|
||||
{
|
||||
ru: `В начале было Слово.
|
||||
Слово было «Изменись».
|
||||
И Вселенная изменилась.
|
||||
Так было. Так есть. Так будет.
|
||||
Тзинч доволен.`,
|
||||
en: `In the beginning was the Word.
|
||||
The Word was "Change".
|
||||
And the Universe changed.
|
||||
So it was. So it is. So it shall be.
|
||||
Tzeentch is pleased.`,
|
||||
source: "Книга Первичного Знания, утеряна / The Book of Primordial Knowledge, lost",
|
||||
},
|
||||
{
|
||||
ru: `Каждый чародей думает, что контролирует варп.
|
||||
Варп думает иначе.
|
||||
Тзинч смеётся над обоими.`,
|
||||
en: `Every sorcerer believes he controls the warp.
|
||||
The warp disagrees.
|
||||
Tzeentch laughs at both.`,
|
||||
source: "Предупреждения инквизиции, том DCCXII / Inquisitorial Warnings, Vol. DCCXII",
|
||||
},
|
||||
}
|
||||
|
||||
// === Рендер попапа Тзинча ===
|
||||
//
|
||||
// Реализован как часть основной модели (m.showTzeentch), а не отдельная
|
||||
// Bubble Tea программа. Запуск вложенного tea.NewProgram внутри уже
|
||||
// работающей программы ломает alt-screen и оставляет терминал в
|
||||
// неконсистентном состоянии после выхода.
|
||||
|
||||
// renderTzeentch рендерит попап с цитатой поверх текущего экрана
|
||||
func renderTzeentch(m *EditorModel) string {
|
||||
if m.width == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
q := tzeentchQuotes[m.tzeentchIdx]
|
||||
|
||||
borderStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.DoubleBorder()).
|
||||
BorderForeground(lipgloss.Color("129")).
|
||||
Padding(1, 3)
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("129")).
|
||||
Bold(true).Italic(true)
|
||||
|
||||
eyeStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("93")).Bold(true)
|
||||
|
||||
ruStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("183"))
|
||||
|
||||
enStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("60")).Italic(true)
|
||||
|
||||
sourceStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("8")).Italic(true)
|
||||
|
||||
divStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("54"))
|
||||
|
||||
hintStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("8"))
|
||||
|
||||
div := divStyle.Render(strings.Repeat("─", 48))
|
||||
|
||||
ruLines := strings.Split(q.ru, "\n")
|
||||
var ruRendered []string
|
||||
for _, l := range ruLines {
|
||||
ruRendered = append(ruRendered, ruStyle.Render(l))
|
||||
}
|
||||
|
||||
enLines := strings.Split(q.en, "\n")
|
||||
var enRendered []string
|
||||
for _, l := range enLines {
|
||||
enRendered = append(enRendered, enStyle.Render(l))
|
||||
}
|
||||
|
||||
content := strings.Join([]string{
|
||||
eyeStyle.Render(" ⌬"),
|
||||
titleStyle.Render("✦ Tzeentch speaks... ✦"),
|
||||
div,
|
||||
"",
|
||||
strings.Join(ruRendered, "\n"),
|
||||
"",
|
||||
divStyle.Render(strings.Repeat("·", 48)),
|
||||
"",
|
||||
strings.Join(enRendered, "\n"),
|
||||
"",
|
||||
div,
|
||||
sourceStyle.Render("— " + q.source),
|
||||
"",
|
||||
hintStyle.Render("[ any key ] Just as planned."),
|
||||
}, "\n")
|
||||
|
||||
box := borderStyle.Render(content)
|
||||
|
||||
boxLines := strings.Split(box, "\n")
|
||||
boxW := 0
|
||||
for _, l := range boxLines {
|
||||
if w := visibleWidth(l); w > boxW {
|
||||
boxW = w
|
||||
}
|
||||
}
|
||||
hPad := (m.width - boxW) / 2
|
||||
if hPad < 0 {
|
||||
hPad = 0
|
||||
}
|
||||
vPad := (m.height - len(boxLines)) / 2
|
||||
if vPad < 1 {
|
||||
vPad = 1
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
for i := 0; i < vPad; i++ {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
for _, l := range boxLines {
|
||||
out.WriteString(strings.Repeat(" ", hPad))
|
||||
out.WriteString(l)
|
||||
out.WriteString("\n")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// tzeentchRand — единый генератор на весь процесс.
|
||||
// Пересоздание rand.New(rand.NewSource(time.Now().UnixNano())) при каждом
|
||||
// вызове даёт одинаковый seed при быстрых повторных нажатиях (разрешение
|
||||
// UnixNano недостаточно при интервалах < 1мкс между вызовами), из-за чего
|
||||
// показывается одна и та же цитата. Единый генератор инициализируется
|
||||
// один раз и просто продолжает свою последовательность.
|
||||
var tzeentchRand = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
// triggerTzeentch выбирает случайную цитату (отличную от предыдущей) и включает попап
|
||||
func (m *EditorModel) triggerTzeentch() {
|
||||
if len(tzeentchQuotes) <= 1 {
|
||||
m.tzeentchIdx = 0
|
||||
m.showTzeentch = true
|
||||
return
|
||||
}
|
||||
next := tzeentchRand.Intn(len(tzeentchQuotes))
|
||||
// Избегаем повтора той же цитаты что была показана прошлый раз
|
||||
for next == m.tzeentchIdx {
|
||||
next = tzeentchRand.Intn(len(tzeentchQuotes))
|
||||
}
|
||||
m.tzeentchIdx = next
|
||||
m.showTzeentch = true
|
||||
}
|
||||
2
untitled.md
Normal file
2
untitled.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
rwerwer
|
||||
werwerdawdawd wDdwdadd daddwd wdwad `w
|
||||
29
v-curl-ispravili-uyazvimost-25-letnej-davnosti.md
Normal file
29
v-curl-ispravili-uyazvimost-25-letnej-davnosti.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# В curl исправили уязвимость 25-летней давности
|
||||
|
||||
> Источник: [xakep.ru](https://xakep.ru/2026/06/29/curl-old-bug/)
|
||||
> Дата: 30.06.2026
|
||||
|
||||
---
|
||||
|
||||
[
|
||||
|
||||
Рекомендуем почитать:[[
|
||||
|
||||
### Хакер #326. Router from Hell
|
||||
|
||||
|
||||
- [Содержание выпуска
|
||||
- [Подписка на «Хакер»-60%
|
||||
|
||||
Разработчики curl выпустили[версию 8.21.0, в которой исправили сразу восемнадцать уязвимостей. Это рекордное число проблем, устраненных в одном обновлении. При этом один из багов присутствовал в коде более 25 лет.
|
||||
|
||||
Четырем уязвимостям был присвоен средний уровень опасности, а остальным четырнадцати — низкий. Самой старой проблемой в этом релизе оказалась[CVE-2026-8932, присутствовавшая в коде проекта более 25 лет. Появившись еще в curl версии 7.7, выпущенной 22 марта 2001 года, уязвимость затрагивала все версии до 8.20.0 включительно.
|
||||
|
||||
|
||||
Проблему CVE-2026-8932 обнаружили специалисты компании[Aisleс помощью собственной ИИ-платформы. Они сообщают, что баг был связан с повторным использованием mTLS-соединений. Дело в том, что libcurl хранит открытые соединения в пуле, чтобы по возможности задействовать их при следующих запросах. Однако при проверке, подходит ли существующее соединение для нового запроса, библиотека учитывала не все параметры клиентского сертификата и приватного ключа.
|
||||
|
||||
В результате libcurl могла повторно использовать уже установленное соединение, даже если приложение изменило сертификат или приватный ключ. Новый запрос при этом отправлялся через соединение, аутентифицированное с прежними параметрами, и фактически выполнялся от имени прежнего клиента.
|
||||
|
||||
Подчеркивается, что уязвимость затрагивает приложения, в которые встроена libcurl, но не распространяется на консольную утилиту curl.
|
||||
|
||||
Аналитики Aisle отмечают, что все простые уязвимости в curl давно нашли и исправили. Теперь исследователям приходится искать баги в старых имплементациях протоколов, логике переиспользования соединений, обработчиках обратных вызовов и других редко используемых участках кода.
|
||||
Loading…
Add table
Reference in a new issue