diff --git a/.claude/commands/make-release.md b/.claude/commands/make-release.md new file mode 100644 index 00000000..cd036812 --- /dev/null +++ b/.claude/commands/make-release.md @@ -0,0 +1,31 @@ +Create a release from the develop branch. Follow these steps exactly: + +## Pre-flight checks +1. Ensure you are on the `develop` branch +2. Run `cargo fmt && cargo clippy -- -D warnings && cargo build` +3. Run `cargo test --no-fail-fast` and report the total passing test count +4. Confirm there are no uncommitted changes (`git status`) + +## Version bump +1. Read current version from `Cargo.toml` +2. Ask the user whether this is a **minor** (new features) or **patch** (bug fixes only) release +3. Bump the version in `Cargo.toml` accordingly +4. Commit with message: `chore: bump version to X.Y.Z for release` +5. Push to `origin develop` + +## Flatpak sources +1. Check if `Cargo.lock` changed vs `main`: `git diff main -- Cargo.lock | head -5` +2. If changed, warn the user: "Cargo.lock changed since last release. The Flatpak CI build may fail if `flatpak/cargo-sources.json` is stale. You may need to regenerate it with `python3 flatpak-cargo-generator.py Cargo.lock -o flatpak/cargo-sources.json` from the flatpak-builder-tools repo." + +## Create PR +1. Run `git log main..develop --oneline` to see all commits going into the release +2. Create a PR from `develop` to `main` using `gh pr create` with: + - Title: `Release vX.Y.Z` + - Body: Summary of changes (grouped by category), test plan with cargo check results +3. Print the PR URL + +## After PR +Tell the user: +- Merging the PR to `main` triggers `release.yml` which creates a GitHub Release tagged `vX.Y.Z` +- Never push directly to `main` — always merge from `develop` via PR +- Monitor CI on the PR for any failures before merging diff --git a/BUGS.md b/BUGS.md index 3f63e386..0905005f 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,18 +1,23 @@ # Known Bugs -- **(Low) GTK Explorer: Enter requires two presses after arrow-key navigation** — After using arrow keys to navigate to a folder in the GTK TreeView, the first Enter press doesn't expand/collapse; the second does. Likely a GTK4 TreeView cursor/selection sync issue or interaction between GTK's built-in key bindings and the `row_activated` signal. Works correctly after the first activation. TUI explorer is unaffected. +- **(Intermittent) TUI rendering artifacts** — Stale characters from a previous view sometimes linger on screen. Mitigated in Session 244: `terminal.clear()` on resize events and on popup dismiss (picker/folder picker transition to hidden). Root cause: ratatui's incremental diff can miss cells when the physical terminal state diverges from its buffer tracking. Workaround for any remaining cases: Ctrl+L forces a full screen redraw. -- **(Intermittent) TUI rendering artifacts** — Stale characters from a previous view sometimes linger on screen. Not reliably reproducible yet. Workaround: Ctrl+L forces a full screen redraw. +- **GTK terminal panel toggle requires two clicks** — The `[P]` layout toggle button in the GTK status bar requires two clicks to show the terminal panel on the first use. Subsequent toggles work with a single click. Likely a timing issue between the `EngineAction::OpenTerminal` dispatch and the GTK layout recomputation. -- **(Low) Hardcoded colors in rendering code — 59 instances across 5 files.** These colors don't adapt to the user's chosen theme. Should all use `Theme` struct fields instead. Breakdown: - - `src/gtk/css.rs` (23) — Button, dialog, toggle, and find bar CSS colors are hardcoded hex. Should be interpolated from the theme via `make_theme_css()`. - - `src/gtk/draw.rs` (12) — Scrollbar track/thumb RGBA, tooltip popup bg/fg, terminal pane background, extension section headers. Should use theme fields. - - `src/tui_main/panels.rs` (15) — Git status colors (`RColor::Rgb(90,180,90)` etc.) ignore existing `theme.git_added/modified/deleted`; activity bar icon colors, scrollbar thumb, terminal background/find match colors all hardcoded. - - `src/tui_main/render_impl.rs` (3) — Scrollbar thumb color `RColor::Rgb(128,128,128)` repeated 3 times. Should be a theme field (e.g. `theme.scrollbar_thumb`). - - `src/gtk/mod.rs` (3) — Cursor indicator box RGBA, search result markup hex colors. ## Resolved +- **TUI spell underlines bleed into fuzzy finder** — `set_cell()` and `set_cell_wide()` only reset character/fg/bg but not `cell.modifier` or `cell.underline_color`, so `Modifier::UNDERLINED` from spell rendering survived into the picker overlay. Fixed by resetting both fields in `set_cell()`, `set_cell_wide()`, and `set_cell_styled()` (which left stale `underline_color` when passed `None`). + +- **Marksman LSP status indicator stuck on "initializing"** — `mark_server_responded()` was only called on non-empty hover/definition responses, so servers like `marksman` that don't support semantic tokens (and may return empty hover content for many positions) stayed stuck at "Initializing". Fixed by marking the server as responsive on `Initialized` event (handshake completion is sufficient proof of readiness), and removing the empty-result guards on hover/definition responses. + +- **Spell check underline misaligned** — GTK backend called `layout.set_attributes(None)` before computing underline/cursor positions via `index_to_pos`, stripping `font_scale` attributes (1.1–1.4× on markdown headings). Positions were calculated at normal font width while text was rendered scaled, causing underlines to start before the word and end in the middle. Fixed by preserving Pango attributes (`build_pango_attrs(&rl.spans)`) for diagnostics, spell underlines, cursor, ghost text, and extra cursors. Also fixed spell checker not initializing when enabled via Settings sidebar or settings.json reload. +- **Inline rename cursor position tests failing on macOS CI** — `test_inline_rename_start` and `test_inline_rename_typing_and_cursor` expected cursor at full filename length, but `start_explorer_rename()` positions cursor at stem end (before extension). Tests updated to match. +- **Hardcoded colors in rendering code** — Added 4 new Theme fields (`scrollbar_thumb`, `scrollbar_track`, `terminal_bg`, `activity_bar_fg`) with values for all 6 built-in themes + VSCode JSON importer. Replaced hardcoded `RColor::Rgb(128,128,128)` scrollbar thumbs (3 in render_impl.rs, 4 in panels.rs), `RColor::Rgb(90/220,...)` git status colors → `theme.git_added/modified/deleted`, `RColor::Rgb(100,100,110)` activity bar icons → `theme.activity_bar_fg`, `rgb(30,30,30)` terminal bg → `theme.terminal_bg` (GTK + TUI), debug button colors → `theme.git_added`/`theme.diagnostic_error`, terminal find-match colors → `theme.search_match_*`, search result markup → `theme.function`/`theme.foreground`, cursor indicator → `theme.scrollbar_thumb`, tab drag overlay → `theme.cursor`/`theme.background`/`theme.foreground`, ext panel secondary bg → `theme.status_bg.darken(0.15)`. GTK CSS: scrollbar slider, h-editor-scrollbar, find dialog, find-match-count colors now theme-aware via `make_theme_css()` overrides. Remaining STATIC_CSS hex values are either close-button platform convention or dead fallbacks already overridden by `make_theme_css()`. +- **TUI: Settings button in activity bar not clickable** — The status bar click handler (`row + 2 == term_height`) and command line guard (`row + 1 >= term_height`) in `mouse.rs` intercepted ALL clicks on the bottom two terminal rows regardless of column, before the activity bar handler could process them. The settings button is rendered at the bottom of the activity bar, which coincides with the command line row. Fixed by adding `col >= ab_width` guards so those checks only apply to clicks outside the activity bar column. +- **GTK Explorer: first click/Enter on folder required two presses** — `tree_row_expanded()` removed the dummy placeholder child before populating real children, leaving the directory with zero children momentarily. GTK auto-collapsed the row when its last child was removed. Fixed by populating real children first, then removing the dummy. Also fixed Enter after arrow-key navigation to use `ExplorerActivateSelected` (syncs cursor→selection) instead of native `row_activated`. +- **GTK: Inline rename in explorer disappears immediately** — Root cause: periodic `update_tree_indicators` (every 1s) called `set_value` on TreeStore rows, cancelling the active GTK cell editor. Also `RefreshFileTree` could clear the store during editing. Fixed by skipping indicator updates and tree refreshes while `name_cell.is_editing()` is true. Also fixed related SIGSEGV from `__NEW_FILE__`/`__NEW_FOLDER__` marker rows in the indicator walk, context menu popover stealing focus (explicit `popdown()` + 50ms delay), and GTK rename pre-selecting entire filename instead of stem only (`connect_editing_started` + `Entry::select_region()`). +- **LineEnding::detect() crash on multi-byte chars** — `&text[..8192]` panicked when byte 8192 landed inside a multi-byte character (e.g. `─` at bytes 8190..8193). Fixed by backing up to nearest char boundary via `is_char_boundary()` loop. - **VSCode mode undo granularity** — Every character typed created its own undo entry. Fixed by keeping the undo group open across consecutive character insertions in `handle_vscode_key()`, breaking only on non-character actions (cursor movement, Backspace, Return, Ctrl+* commands) or external cursor moves (mouse clicks). `vscode_undo_group_open` + `vscode_undo_cursor` fields on Engine. 5 new tests. - **Search `/` results land at viewport bottom** — `jump_to_search_match()` called `ensure_cursor_visible()` which with `scrolloff=0` placed the match at the absolute bottom edge. Fixed by centering the match when it lands in the bottom quarter of the viewport. - **Tab bar hides tabs when there's room** — `tab_visible_count` feedback loop: TUI renderer returned tab **count** but `set_tab_visible_count()` stored it as `tab_bar_width` (column width). With 5 tabs visible, engine thought it had 5 columns of space, causing a death spiral where each frame hid more tabs. Fixed by returning actual available width in columns (`tab_end_for_content - area.x`), matching the GTK backend. Also fixed `tab_display_width()` off-by-one (+3→+2 for close+separator). diff --git a/Cargo.lock b/Cargo.lock index 838508a0..ee86e480 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2285,7 +2285,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vimcode" -version = "0.6.0" +version = "0.7.0" dependencies = [ "cc", "copypasta-ext", diff --git a/Cargo.toml b/Cargo.toml index 6d538c8c..88d3948f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vimcode" -version = "0.7.0" +version = "0.8.0" edition = "2021" description = "Vim-like code editor with GTK4 and tree-sitter" license = "MIT" diff --git a/PLAN.md b/PLAN.md index c55b0dc7..f449399c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -23,19 +23,12 @@ --- ## Recently Completed -- **Session 240**: Cursorline highlight (default on, `cursorline_bg` derived from theme background via `Color::cursorline_tint()`), breadcrumb picker pre-selects current function, GTK tab bar padding (1.6× line_height, 14px horizontal pad, wider Command Center search bar 280px min), hardcoded colors audit (59 instances filed in BUGS.md), tree-sitter highlight query expansion added to roadmap. -- **Session 239**: Tree-style symbol drill-down — added `hierarchicalDocumentSymbolSupport` LSP capability (root cause: server was returning flat `SymbolInformation` instead of hierarchical `DocumentSymbol`); `parse_document_symbols_hierarchical` preserves children; `rebuild_tree_from_containers` reconstructs tree from flat `container` field; `PickerItem` gains `depth`/`expandable`/`expanded`; `SymbolKind::sort_order()`; Enter/Right/Left expand/collapse; click-to-toggle-expand in GTK+TUI; `▼`/`▷` arrows + indentation; picker jumps center viewport via `scroll_cursor_center()`; `breadcrumb_scoped_parent` cleared in `open_picker()`; 14 new tests (5080 total). -- **Session 236**: ratatui 0.27→0.29 upgrade — colored underlines for TUI tab accent (`tab_active_accent` theme color), diagnostics (severity-colored), and spell errors; migrated all deprecated APIs (`buf.get_mut()`→`buf[(x,y)]`, `frame.size()`→`frame.area()`, `frame.set_cursor()`→`frame.set_cursor_position()`); `set_cell_styled()` gains `underline_color` parameter; `Size` replaces `Rect` for terminal size params. Bug fix: TUI tab bar scroll feedback loop — `render_tab_bar()` returned tab count instead of available width in columns, causing death spiral where each frame showed fewer tabs; also fixed `tab_display_width()` off-by-one (+3→+2). -- **Session 235**: Active tab accent indicator — `tab_active_accent` theme color; GTK 2px colored top border on active tab in focused group (drawn inside `draw_tab_bar()`); TUI underlined text on focused group's active tab; 6 built-in theme accents + VSCode JSON importer (`tab.activeBorderTop`). -- **Session 234**: Nerd Font icon handling — centralized ~45 icon constants in `icons.rs` with `Icon { nerd, fallback }` struct; replaced ~90+ hardcoded `\u{...}` escapes across 11 files; `use_nerd_fonts` setting (`:set nerdfonts`/`:set nonerdfonts`) toggles ASCII fallback at runtime; bundled 13KB Nerd Font subset (`data/fonts/vimcode-icons.ttf`) auto-installed at GTK startup; CSS + file tree icon renderer prefer bundled font; extension panel `fallback_icon` Lua API with auto-fallback to first title letter; `PanelRegistration.resolved_icon()` used by both backends. Also fixed drag-to-select text leaking across editor groups (`mouse_drag_origin_window` locks drag to originating window). 1 new test. -- **Session 233**: Explorer focus UX polish — stronger `sidebar_sel_bg` and `explorer_active_bg` colors across all 6 themes; suppress current-file highlight when explorer has focus (TUI); TUI click on explorer sets `explorer_has_focus`; Ctrl-W h focuses explorer (GTK `window_nav_overflow` handling + TUI Explorer case in overflow match); `OpenFileFromSidebar` clears focus; GTK `row_activated` handles directory expand/collapse; fixed GTK j/k/arrow key passthrough for TreeView navigation. Known bug: GTK Enter on folder after arrow-key nav requires two presses (filed in BUGS.md). -- **Session 232**: Inline new file/folder in explorer tree — `ExplorerNewEntryState` struct with inline editing; replaced status-line prompt (TUI) and modal dialog (GTK) with inline editable row in tree; GTK bordered text field via CSS `treeview entry` styling; TUI inverted-cursor rendering with virtual row interleaving; generic file/folder icons during input; `start_explorer_new_file/folder()`, `handle_explorer_new_entry_key()`; removed `PromptKind::NewFile/NewFolder` and `show_name_prompt_dialog()`; `find_tree_iter_for_path()` + `remove_new_entry_rows()` tree helpers; 10 new tests. -- **Session 231**: Git branch switcher in status bar — clickable branch name in status bar opens `PickerSource::GitBranches` picker; ahead/behind counts (`↑N ↓N`) displayed; `status_branch_range` on `ScreenLayout`; GTK + TUI click handlers; `:Gbranches` command; fixed `Gcheckout` → `Gswitch` in picker confirm; 6 new tests. -- **Session 230**: Command Center enhancements + `sw` — `%` grep prefix, `debug` keyword (launch configs), `task` keyword (tasks.json), placeholder hints dropdown (9 mode items on empty query). `sw` greps word under cursor; `:GrepWord` command; palette entry. 30 new tests. -- **Session 229**: Command Center — clickable search box in menu bar opens unified picker with prefix routing: _(none)_ fuzzy files, `>` command palette, `@` document symbols, `#` workspace symbols, `:` go to line, `?` help. LSP `documentSymbol`/`workspaceSymbol` integration. `:CommandCenter` ex command. GTK + TUI click-to-open. 11 new tests. -> Sessions 228 and earlier in **SESSION_HISTORY.md**. +- **Session 253**: Notification / progress indicator — spinner/bell in per-window status bar for background ops (LSP install, project search/replace); auto-dismiss; click-to-clear; 9 tests. +- **Session 252**: TUI spell underline bleed fix — `set_cell()`/`set_cell_wide()`/`set_cell_styled()` now reset `modifier` and `underline_color` so spell underlines don't bleed into picker overlays. Added remote editing research item. +> Sessions 251 and earlier in **SESSION_HISTORY.md**. ### Bug Fixes +- [x] TUI spell underlines bleed into fuzzy finder — `set_cell()`/`set_cell_wide()`/`set_cell_styled()` now reset `modifier` + `underline_color` - [x] GTK core dump from panic in extern "C" draw callback — `catch_unwind` + `.ok()` on Cairo operations - [x] GStrInteriorNulError crash from NUL byte in dialog button hotkey - [x] Lightbulb code action icon duplicated on wrapped lines @@ -227,25 +220,52 @@ ### Status Bar Enhancements - [x] **Git branch switcher in status bar** — Make the git branch name in the status bar clickable. Clicking opens the unified picker in `PickerSource::GitBranches` mode to switch branches. Show ahead/behind counts next to the branch name. Both GTK (click handler on status bar DA) and TUI (mouse click detection on status bar row). -- [ ] **Per-window status lines (Vim-style)** — Replace the single global status line with per-window status lines, matching Neovim/Vim behavior. Each window in a split gets its own status line drawn at its bottom edge. The **active window** shows a rich, colorful status line with segments: mode indicator (colored `NORMAL`/`INSERT`/`VISUAL`), git branch, filename (relative path or short name), modified flag, encoding (`utf-8`), file format (`unix`/`dos`), filetype/language (`rust`, `json`, etc.), and cursor position (`Ln:Col`). **Inactive windows** show a dimmed/minimal status line (just filename + cursor position). The status line content should be **user-configurable** via a format string in `settings.json` (similar to Vim's `statusline` option or lualine segments) — e.g. `"statusline": "%m %f %{branch} %l:%c %{filetype}"`. Render in both GTK (per-window Cairo strip below each editor pane) and TUI (per-window row at bottom of each window rect). The current global status bar at the very bottom becomes a "global status bar" showing workspace-level info (like VSCode's bottom bar) or can be hidden. This is a significant layout change: `RenderedWindow` gains a `status_line: Vec` field, `WindowRect` heights shrink by one row to accommodate per-window bars, and `build_screen_layout` computes per-window status content. -- [ ] **Clickable status bar segments** — Make status bar sections interactive: click line/col to open "Go to Line" (Command Center with `:` prefix), click language name to change syntax highlighting mode, click indentation to toggle tabs/spaces and set width, click encoding to change file encoding. Each click opens either a picker or a small settings popup. Both GTK and TUI backends. -- [ ] **LSP status indicator** — Show LSP server status in the status bar: spinning/pulsing indicator during initialization, server name when ready, error icon on crash. Clicking opens `:LspInfo`. Replaces the transient "LSP server initializing..." message with a persistent, unobtrusive indicator. +- [x] **Per-window status lines (Vim-style)** — Each window gets its own status line at its bottom edge, replacing the global status bar (Vim behavior). Active window: bold mode name (colored text tint for Insert/Visual/Replace), filename, dirty flag, git branch, filetype, encoding, cursor position. Inactive windows: dimmed filename + cursor. Colors derived from `theme.background.lighten(0.10)` — no hardcoded hex. `window_status_line` setting (default on); `:set nowindowstatusline` reverts to global bar. `StatusSegment`/`WindowStatusLine` structs in render.rs; `build_window_status_line()` builder; both GTK + TUI backends; horizontal separator suppression; per-window click handling. 6 new tests. +- [x] **Clickable status bar segments** — All status bar segments are interactive in both GTK and TUI. Click Ln:Col → Command Center go-to-line; click filetype → Language Mode picker (37 languages); click indent → Indentation picker (Spaces: 2/4/8, Tabs: 2/4/8); click LF/CRLF → Line Ending picker; click utf-8 → info message; click git branch → Branch picker. `StatusAction` enum on `StatusSegment`; `handle_status_action()` on Engine; `PickerSource::Languages/Indentation/LineEndings`; `LineEnding` enum with detection/conversion on `BufferState`; `SyntaxLanguage::from_language_id()`; segment hit-testing in both backends. 4 new tests. +- [x] **LSP status indicator** — Persistent LSP status in per-window status bar: server binary name shown (e.g. `rust-analyzer`); `…` suffix while indexing (initializing/no semantic tokens yet); `✗` on crash; hidden when no LSP for filetype. Readiness based on `BufferState.semantic_tokens` presence (aligns with hover/definition availability). `LspStatus` enum on `LspManager` with `server_has_responded` tracking; `lsp_status_for_buffer()` on Engine; `StatusAction::LspInfo` click opens `:LspInfo`. Both GTK and TUI. ### Tab Bar Enhancements -- [ ] **Editor action menu (`...`) button** — Add a `...` (more actions) button at the right edge of each tab bar group. Clicking opens a dropdown with common editor actions: Close All, Close Others, Close Saved, Close Tabs to the Right/Left, Toggle Word Wrap, Change Language Mode, Reveal in Explorer. Reuses existing engine commands. Both GTK and TUI backends. -- [ ] **Pinned tabs** — Allow pinning tabs via right-click context menu or `:tabpin` command. Pinned tabs shrink to just an icon (file type icon) and stay at the left of the tab bar. They cannot be closed by `:q` (require `:q!` or explicit unpin). Pinned state persists in session. Both GTK and TUI backends. +- [x] **Editor action menu (`...`) button** — `…` button at right edge of each tab bar group; dropdown with Close All, Close Others, Close Saved, Close Tabs to Right/Left, Toggle Word Wrap, Change Language Mode, Reveal in File Explorer. `ContextMenuTarget::EditorActionMenu` + `open_editor_action_menu()` + `close_all_tabs()`; TUI `TAB_ACTION_BTN_COLS` reserved + click handling; GTK Pango-measured button + `ActionBtnMap` + `PopoverMenu` popover. 5 new tests. ### Layout & Chrome -- [ ] **Layout toggle buttons** — Add small clickable icons to toggle sidebar visibility, bottom panel (terminal) visibility, and editor layout from the menu bar or activity bar. VSCode puts these in the top-right corner of the title bar. Could also be exposed as status bar segments. Reuses existing toggle commands. -- [ ] **Notification / progress indicator** — Show a subtle indicator in the status bar or menu bar during background operations: LSP indexing, extension install, git operations, project search. Bell icon for completed notifications. Clicking opens an output log or dismisses. Prevents "is it working?" uncertainty during long operations. +- [x] **Layout toggle buttons** — Clickable nerd-font icon segments (󰘖/󰆍/󰍜 with `[S]`/`[P]`/`[M]` ASCII fallbacks) in per-window status bar (right side, after Ln:Col) toggle sidebar and terminal panel visibility; dim when inactive. Menu bar toggle only shown in TUI (`menu_bar_toggleable` field). GTK click detection uses Pango-measured `StatusSegmentMap` cache instead of `char_width` approximation. 4 new tests. +- [x] **Notification / progress indicator** — Spinner (⠋⠙⠹…) in per-window status bar during background operations (LSP install, project search/replace); bell icon (󰂞/*) for completed; auto-dismiss after 5s; click bell to clear; `NotificationKind` enum + `Notification` struct on Engine; `notify()`/`notify_done_by_kind()`/`tick_notifications()` lifecycle; both GTK + TUI backends (animated via poll tick). 9 tests. ### Editor Features -- [ ] **Richer tree-sitter highlight queries** — Expand all 20 language grammars with comprehensive highlight queries matching VSCode's coverage. Current Rust query only captures 11 keywords. Missing: punctuation/braces (`{`, `}`, `(`, `)`, `;`, `,`), operators (`=`, `=>`, `->`, `::`, `&`, `*`), ~25 additional keywords (`return`, `for`, `while`, `loop`, `break`, `continue`, `const`, `static`, `trait`, `type`, `where`, `as`, `in`, `mut`, `ref`, `self`, `super`, `crate`, `async`, `await`, `move`, `unsafe`, `extern`, `dyn`), macro invocations, method calls, field access, lifetimes, attributes (`#[...]`). Add corresponding Theme fields (`punctuation`, `operator`, `macro_call`, `attribute`, `lifetime`) and scope_color mappings. Apply same treatment to Python, JS/TS, Go, C/C++, Java, Ruby, etc. +- [x] **Richer tree-sitter highlight queries** — Expanded all 20 language grammars with comprehensive highlight queries. 11 new Theme fields (`operator`, `punctuation`, `macro_call`, `attribute`, `lifetime`, `constant`, `escape`, `boolean`, `property`, `parameter`, `module`) with colors for all 6 built-in themes + VSCode JSON importer. `scope_color()` now handles 22 capture names (was 8). Rust: macros, attributes, lifetimes, field access, method calls, numbers, booleans, operators, punctuation, escape sequences, 30+ keywords. Python: decorators, method calls, parameters, operators, booleans, numbers. JS/TS: method calls, properties, parameters, regex, operators, punctuation. Go: method calls, field access, package names, parameters, operators. C/C++: function calls, preprocessor macros, field access, operators, punctuation. Java: annotations, method calls, field access, operators. Ruby: method calls, symbols, operators. C#: operators, punctuation, booleans→boolean, nulls→constant. JSON/TOML/YAML: keys→property, booleans→boolean, nulls→constant, punctuation. Bash: command calls, variables, operators. Lua: method calls, properties, operators. HTML: attributes, punctuation. CSS: property names, color/number values, punctuation. +- [x] **Externalize highlight queries into extensions** — `highlights: Option` field on `ExtensionManifest`; `highlight_overrides: HashMap` on Engine populated from installed extensions at init; `Syntax::new_for_language_with_query()` / `new_from_path_with_overrides()` / `new_from_language_id_with_overrides()` accept override queries with fallback to built-in; `populate_highlight_overrides()` re-applies to open buffers; `language_id()` method on `SyntaxLanguage` for reverse lookup. Built-in queries remain as compile-time fallbacks. 4 new tests. - [ ] **Minimap** — Code overview minimap on the right edge of each editor pane, showing a scaled-down rendering of the entire file with the viewport highlighted. Click/drag to scroll. Syntax-highlighted. Toggleable via `:set minimap` / settings. Both GTK (Cairo scaled rendering) and TUI (braille/block character approximation). +### Debugger +- [ ] **Debug target selection on startup** — When the configured debug target binary is not found at the default location, prompt the user to select the correct binary instead of silently failing. Explore what VSCode does: it shows a file picker or lets the user edit `launch.json` `program` field. Could show a file picker filtered to executables, or a dialog with the failed path and an option to browse. Should also handle missing `launch.json` gracefully. + +### Explorer +- [x] **Remove explorer toolbar** — Remove the New File / New Folder / Refresh / Collapse All toolbar buttons at the top of the explorer panel. These are redundant now that right-click context menus provide the same actions. Reclaims a row of vertical space. +- [x] **Right-click in empty explorer space** — Right-clicking in the blank area below the last tree entry should open the context menu as if the root folder was clicked (New File, New Folder, etc.). Currently only tree rows are clickable. +- [x] **Inline rename improvements** — When renaming a file in the explorer: (1) the filename portion (without extension) should be pre-selected so Backspace removes just the name, leaving the extension; (2) if the name is longer than the available width, the input should scroll horizontally (like VSCode); (3) Ctrl-C/Ctrl-V should work in the rename input field. +- [x] **Copy filename during inline rename** — Ctrl-C and Ctrl-V should work in the inline rename text input in the explorer panel, allowing the user to copy the current filename before editing it. Currently these keys may not be handled in the rename input field. +- [ ] **GTK explorer indent guide lines** — TUI has vertical `│` indent guides; GTK TreeView doesn't support vertical-only guides (built-in `enable_tree_lines` draws horizontal connectors too). Needs custom rendering — either a cell data function with guide characters or Cairo custom drawing in a separate column. + +### Picker / Fuzzy Finder +- [x] **Search history in picker dialogs** — Up arrow at top of results recalls previous searches from the current session. Per-source history stack (`picker_history: HashMap>`); Down navigates forward or restores the in-progress query; typing/backspace/paste exits history mode; consecutive duplicates deduplicated; capped at 100 entries; session-scoped (not persisted). 7 new tests. + ### CI & Distribution - [x] **macOS builds via GitHub Actions + Homebrew tap** — Add a macOS build target to the GitHub Actions CI/release workflow (build on `macos-latest` with `cargo build --release`). Produce a universal or arch-specific binary artifact. Create a Homebrew tap repository (e.g. `homebrew-vimcode`) with a formula that installs the release binary. Ensure the release workflow updates the tap formula (SHA256 + version) on each release. Test the full `brew install` → launch cycle in CI. - [ ] **Windows portable builds + code signing** — Add a Windows build target to the GitHub Actions CI/release workflow (build on `windows-latest` with `cargo build --release`). Package as a portable app (self-contained `.zip` with `vimcode.exe` + any required DLLs, no installer needed — just extract and run). Attach the `.zip` as a release artifact. Investigate code signing (Authenticode) so the binary doesn't trigger SmartScreen warnings and can be installed/run on corporate machines with restricted execution policies; document the signing process and certificate options (self-signed for testing, trusted CA for production). +### Website & SEO (vimcode.org) +- [x] **Project website** — Static landing page on GitHub Pages (`JDonaghy/vimcode-website`), OneDark-derived dark theme, responsive, SEO meta tags + JSON-LD structured data + sitemap. Custom domain `vimcode.org` with HTTPS. +- [ ] **Screenshots & OG image** — Add real editor screenshots to the landing page (replace placeholder). Create a 1200x630px Open Graph image for social media previews; add `og:image` and `twitter:image` meta tags. +- [ ] **Google Search Console** — Add `https://vimcode.org/` as a property, verify with HTML meta tag, submit `sitemap.xml`. Use URL Inspection to request initial indexing. +- [ ] **Bing Webmaster Tools** — Add site at https://www.bing.com/webmasters/, verify, submit sitemap (or import from Google Search Console). +- [ ] **Submit to awesome-rust** — PR to https://github.com/rust-unofficial/awesome-rust adding VimCode under "Text editors". +- [ ] **Submit to alternativeto.com** — List VimCode as an alternative to VS Code, Neovim, Helix, Zed. +- [ ] **Announce on Reddit & Hacker News** — "Show HN: VimCode — Vim+VSCode hybrid editor in Rust"; post to r/rust, r/vim, r/neovim. Wait until the editor feels stable enough for first impressions. +- [ ] **Publish on Flathub** — Submit the Flatpak to Flathub for discoverability and a trusted backlink. +- [ ] **Additional website pages** — Getting Started guide, screenshots gallery, extension registry browser. Add each to sitemap. + +### Remote Editing +- [ ] **Remote editing over SSH** — Research and design a remote editing story for VimCode. Key questions: (1) **Neovim's approach** — Neovim supports `--remote`, `--server`, and `--headless` modes with a msgpack RPC API; clients connect over stdio/TCP/Unix socket; how much of this is worth emulating? (2) **SSH tunneling** — can VimCode run headless on a remote host with the TUI/GTK frontend on the local machine, forwarding over SSH (à la VS Code Remote SSH)? What's the protocol between frontend and engine? (3) **Headless VimCode** — a `vcd --headless` mode that exposes the engine over a socket/pipe for scripting, testing, or remote frontends; what API surface is needed? (4) **sshfs / FUSE alternative** — simpler approach: open remote files via sshfs mount; what are the LSP/git/terminal implications? (5) **Latency** — how to handle input latency, optimistic rendering, reconnection. Study Neovim's `--headless` + `nvim --listen`, VS Code Remote SSH extension architecture, and Mosh's approach to latency compensation. + ### Documentation - [x] **GitHub Wiki** — 9 pages: Home, Getting Started, Key Remapping, Settings Reference, Extension Development, Lua Plugin API, Theme Customization, DAP Debugger Setup, LSP Configuration; README Documentation section links to wiki diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 22fd140a..575e1904 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,9 +1,9 @@ # VimCode Project State -**Last updated:** Apr 1, 2026 (Session 240 — cursorline highlight, GTK tab padding, breadcrumb picker pre-selection) | **Tests:** 5199 +**Last updated:** Apr 5, 2026 (Session 253 — Notification / progress indicator) | **Tests:** 5313 > Feature documentation lives in **README.md**. -> Per-session implementation notes through Session 240 are in **SESSION_HISTORY.md**. +> Per-session implementation notes through Session 253 are in **SESSION_HISTORY.md**. --- @@ -26,4 +26,8 @@ When implementing a new key/command, add tests covering: ## Recent Work -> All sessions through 240 archived in **SESSION_HISTORY.md**. +> All sessions through 253 archived in **SESSION_HISTORY.md**. + +- **Session 253**: Notification / progress indicator — `Notification` struct + `NotificationKind` enum on Engine; `notify()`/`notify_done()`/`notify_done_by_kind()`/`tick_notifications()` lifecycle; spinner animation (⠋⠙⠹…) for in-progress, bell icon (󰂞) for completed; auto-dismiss after 5s; `StatusAction::DismissNotifications` click-to-clear; rendered as `StatusSegment` in per-window status bar (between Ln:Col and layout toggles); GTK+TUI backends animate via poll tick + short poll timeout; wired up for LSP install, project search, project replace; 9 new tests. + +- **Session 252**: TUI spell underline bleed fix — `set_cell()`/`set_cell_wide()` in TUI backend only reset char/fg/bg but not `cell.modifier` or `cell.underline_color`, so spell check underlines bled through into picker/fuzzy finder overlays. Fixed by resetting `modifier` to `Modifier::empty()` and `underline_color` to `RColor::Reset` in all three cell-setting functions. Added remote editing research item to PLAN.md. diff --git a/README.md b/README.md index 028c92ec..1fd76b5a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # VimCode +**[vimcode.org](https://vimcode.org)** | [Documentation](https://github.com/JDonaghy/vimcode/wiki) | [Releases](https://github.com/JDonaghy/vimcode/releases) + High-performance Vim+VSCode hybrid editor in Rust. Modal editing meets modern UX, no GPU required. ### Who's this for? @@ -35,7 +37,7 @@ For detailed how-to guides and configuration references, see the **[VimCode Wiki - **First-class Vim mode** — deeply integrated, not a plugin - **Cross-platform** — GTK4 desktop UI + full terminal (TUI) backend - **CPU rendering** — Cairo/Pango (works in VMs, remote desktops, SSH) -- **Clean architecture** — platform-agnostic core, 5,199 tests, zero async runtime dependency +- **Clean architecture** — platform-agnostic core, 5,304 tests, zero async runtime dependency > **Note:** VimCode does not implement VimScript. Extension and scripting is handled via > the built-in Lua 5.4 plugin system. The goal is full Vim *keybinding* and *editing* @@ -283,7 +285,7 @@ VimCode has three spatial layers that combine Vim and VSCode concepts: - **Tabs** — pages within an editor group, like Vim tabs or browser tabs (`gt`/`gT`, `:tabnew`) - **Editor Groups** — VSCode-style side-by-side tab bars (`Ctrl+\`, `Ctrl-W e/E`), each with its own set of tabs -The tab context menu offers both: "Split Right/Down" creates a Vim window split inside the current tab, while "Split Right/Down to New Group" creates a new editor group with its own tab bar. +The tab context menu offers both: "Split Right/Down" creates a Vim window split inside the current tab, while "Split Right/Down to New Group" creates a new editor group with its own tab bar. Each tab bar also has a `…` (more actions) button at the right edge with Close All, Close Others, Close Saved, Close to Right/Left, Toggle Word Wrap, Change Language Mode, and Reveal in File Explorer. **Buffers** - `:bn` / `:bp` — next/previous buffer @@ -683,6 +685,7 @@ Runtime changes are written through to `~/.config/vimcode/settings.json` immedia | `smartcase` / `nosmartcase` | `scs` | off | Override `ignorecase` when pattern has uppercase | | `scrolloff=N` | `so` | 0 | Lines to keep above/below cursor when scrolling | | `cursorline` / `nocursorline` | `cul` | on | Highlight the line the cursor is on | +| `windowstatusline` / `nowindowstatusline` | `wsl` | on | Per-window status line instead of single global bar (includes layout toggle icons) | | `colorcolumn=N` | `cc` | "" | Comma-list of column guides to highlight | | `textwidth=N` | `tw` | 0 | Auto-wrap inserted text at column N (0=off) | | `wrap` / `nowrap` | | off | Soft-wrap long lines at viewport edge | @@ -693,6 +696,7 @@ Runtime changes are written through to `~/.config/vimcode/settings.json` immedia | `formatonsave` / `noformatonsave` | `fos` | off | Auto-format buffer via LSP before saving | | `spell` / `nospell` | | off | Enable spell checking (wavy underline on misspelled words) | | `spelllang=XX` | | `en_US` | Spell check language (currently only `en_US` is bundled) | +| `explorersortcaseinsensitive` / `noexplorersortcaseinsensitive` | `esci` | on | Case-insensitive sorting in the file explorer | | `mode=vim` / `mode=vscode` | | vim | Editor mode (see **VSCode Mode** below) | - `:set option?` — query current value; `:set option!` — toggle boolean; `:set` — show all @@ -778,6 +782,8 @@ All state lives in `~/.config/vimcode/`. Open files, cursor positions, command/s - Per-window horizontal scrollbar (shown when content is wider than viewport) - Scrollbar click-to-jump and drag support +**Per-window status line** — mode, filename, branch, filetype, indentation, encoding, line ending, Ln:Col, LSP status; clickable segments open pickers (language, indentation, line ending, branch); layout toggle icons (sidebar, terminal, menu bar) with Nerd Font glyphs or `[S]`/`[T]`/`[M]` fallbacks — dimmed when inactive + **Font** — configurable family and size via `settings.json` --- diff --git a/SESSION_HISTORY.md b/SESSION_HISTORY.md index a1346440..6d472280 100644 --- a/SESSION_HISTORY.md +++ b/SESSION_HISTORY.md @@ -1,10 +1,120 @@ # VimCode Session History Detailed per-session implementation notes archived from PROJECT_STATE.md. -All sessions through 240 archived here. Recent work summary in PROJECT_STATE.md. +All sessions through 253 archived here. Recent work summary in PROJECT_STATE.md. --- +**Session 253 — Notification / progress indicator (5313 tests):** + +New feature: background operation progress indicator in the per-window status bar. `Notification` struct + `NotificationKind` enum (LspInstall, LspIndexing, ExtensionInstall, GitOperation, ProjectSearch, ProjectReplace) on Engine. Lifecycle methods: `notify()` (push in-progress, returns ID), `notify_done(id, msg)` (mark complete by ID), `notify_done_by_kind(kind, msg)` (mark all of a kind complete), `dismiss_notification(id)` (remove by ID), `dismiss_done_notifications()` (remove all completed), `tick_notifications()` (auto-dismiss after 5s timeout). Rendered as `StatusSegment` in `build_window_status_line()` between Ln:Col and layout toggle buttons — spinner animation (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ braille frames at ~10fps) for in-progress ops using `theme.function` color, bell icon (󰂞 nerd / `*` ASCII) for completed ops using `theme.string_lit` color. `StatusAction::DismissNotifications` click-to-clear all done notifications. TUI: 100ms poll timeout when active notifications for smooth spinner; `needs_redraw = true` in idle loop when notifications present. GTK: `draw_needed.set(true)` on active notifications in poll tick handler. Wired up: LSP install start (lsp_ops.rs), LSP install complete (panels.rs via `notify_done_by_kind`), project search start/complete (search.rs), project replace start/complete (search.rs). Message truncated to 30 chars in status bar. 9 new tests covering lifecycle, auto-dismiss, click-to-dismiss, ID incrementing. Files changed: `engine/mod.rs` (+120), `engine/execute.rs` (+3), `engine/lsp_ops.rs` (+1), `engine/panels.rs` (+6), `engine/search.rs` (+4), `engine/tests.rs` (+100), `render.rs` (+49), `gtk/mod.rs` (+12), `tui_main/mod.rs` (+9). + +--- + +**Session 252 — TUI spell underline bleed fix (5304 tests):** + +Bug fix: TUI spell check underlines bled into fuzzy finder (picker) popup overlays. Root cause: `set_cell()` (346 call sites across TUI rendering) only reset character, fg, and bg colors but never cleared `cell.modifier` or `cell.underline_color`. When spell checking added `Modifier::UNDERLINED` + `underline_color` to editor cells, the picker overlay's clear pass via `set_cell()` left those attributes intact, causing horizontal underlines at the same screen positions in the popup. Fixed by resetting `modifier = Modifier::empty()` and `underline_color = RColor::Reset` in `set_cell()`, `set_cell_wide()` (both main and continuation cells), and `set_cell_styled()` (which left stale `underline_color` when passed `None`). Files changed: `src/tui_main/mod.rs`. Also added "Remote editing over SSH" research item to PLAN.md. + +--- + +**Session 251 — Layout toggle buttons (5304 tests):** + +Clickable nerd-font icon segments (󰘖 sidebar, 󰆍 panel, 󰍜 menu) with `[S]`/`[P]`/`[M]` ASCII fallbacks in per-window status bar (right side, after Ln:Col). Sidebar and terminal panel toggles in both GTK and TUI; menu bar toggle only in TUI (`menu_bar_toggleable` engine field — GTK menu bar is the window title bar and can't be hidden). Icons dim via `theme.status_inactive_fg` when panel is inactive. `StatusAction::ToggleSidebar` returns `EngineAction::ToggleSidebar` (backends manage sidebar visibility); `StatusAction::TogglePanel` returns `EngineAction::OpenTerminal` when no PTY panes exist, otherwise calls `toggle_terminal()` directly. GTK status bar click detection overhauled: `draw_window_status_bar` now populates a `StatusSegmentMap` cache with Pango-measured `(start_x, end_x, action)` zones per window; `pixel_to_click_target` uses cached zones instead of the old `gtk_status_segment_hit_test` (which used `chars().count() * char_width` and broke on variable-width nerd font glyphs). `handle_status_action` return type changed from `()` to `Option`; `handle_mouse_click` return type changed to `(Option, Option)`. Files changed: `engine/mod.rs`, `engine/execute.rs`, `engine/tests.rs`, `render.rs`, `gtk/mod.rs`, `gtk/draw.rs`, `gtk/click.rs`, `tui_main/mod.rs`, `tui_main/mouse.rs`. Known bug: GTK terminal panel toggle requires two clicks on first use. + +--- + +**Session 250 — Marksman LSP status indicator fix (5300 tests):** + +Bug fix: LSP status bar indicator stuck on "Initializing" for Marksman (Markdown LSP) and potentially other servers that don't support semantic tokens. Root cause: `mark_server_responded()` was only called on non-empty hover/definition responses. Marksman doesn't return semantic tokens and often returns empty hover content, so the readiness flag never got set. Fixed by: (1) calling `mark_server_responded()` on `LspEvent::Initialized` — the initialization handshake completing is sufficient proof of readiness; (2) removing `if !locations.is_empty()` guard on `DefinitionResponse` handler; (3) removing `if contents.is_some()` guard on `HoverResponse` handler. Files changed: `src/core/engine/panels.rs`, `src/core/lsp_manager.rs`. + +--- + +**Session 249 — Spell underline fix, spell checker init, CI rename test fix (5300 tests):** + +Bug fix: GTK spell check underline misaligned. Root cause: `draw_editor` in `draw.rs` called `layout.set_attributes(None)` before computing positions via `index_to_pos()` for diagnostic underlines, spell underlines, cursor, ghost text, and extra cursors. This stripped `font_scale` attributes (markdown headings use 1.1–1.4×), causing character positions to be computed at normal font width while text was rendered at scaled width. Underlines started before the word and ended in the middle. Fixed by restoring correct Pango attributes via `build_pango_attrs(&rl.spans)` before all `index_to_pos` calls. 5 sites fixed in `draw.rs`: line-level restore (diagnostics + spell), cursor, ghost text, extra cursors. + +Bug fix: Spell checker not initializing when `spell` setting enabled via Settings sidebar (Bool toggle or text entry in `ext_panel.rs`) or via `settings.json` file reload (both GTK `mod.rs` and TUI `mod.rs`). Added `ensure_spell_checker()` calls after `set_value_str` for the `spell` key and after settings reload from disk. + +Bug fix: CI failure — 2 inline rename integration tests (`test_inline_rename_start`, `test_inline_rename_typing_and_cursor`) in `tests/context_menu.rs` expected cursor at full filename length (e.g., 7 for "old.txt"), but `start_explorer_rename()` positions cursor at stem end (3 for "old"). Tests updated to match. Failure was on macOS ARM64 CI runner, also reproducible locally. + +Files changed: `src/gtk/draw.rs` (5960 lines, +3), `src/core/engine/ext_panel.rs`, `src/gtk/mod.rs`, `src/tui_main/mod.rs`, `src/core/engine/tests.rs` (+1 test), `tests/context_menu.rs` (2 test fixes). + +--- + +**Session 248 — TUI settings button fix, hardcoded colors cleanup, wide-char rendering fix (5299 tests):** + +Bug fix: TUI Settings button in activity bar not clickable. Four early-return click handlers in `mouse.rs` (command line input, message line selection, status bar branch click, bottom-row guard) intercepted ALL clicks on the bottom two terminal rows regardless of column, before the activity bar handler at line 1562 could process them. The settings button is rendered at the bottom of the activity bar, which coincides with the command line row. Fixed by adding `col >= ab_width` guards to all four checks. + +Hardcoded colors cleanup: Added 4 new Theme fields (`scrollbar_thumb`, `scrollbar_track`, `terminal_bg`, `activity_bar_fg`) with values for all 6 built-in themes (onedark, gruvbox_dark, tokyo_night, solarized_dark, vscode_dark, vscode_light) + VSCode JSON theme importer (`scrollbarSlider.background`, `terminal.background`, `activityBar.foreground`). Replaced ~50 hardcoded color values across 5 files: +- `render_impl.rs`: 3× `RColor::Rgb(128,128,128)` scrollbar thumbs → `theme.scrollbar_thumb` +- `panels.rs`: activity bar icons → `theme.activity_bar_fg`; git status colors → `theme.git_added/modified/deleted`; debug buttons → `theme.git_added`/`theme.diagnostic_error`; scrollbar thumbs → `theme.scrollbar_thumb`; terminal bg → `theme.terminal_bg`; terminal find-match → `theme.search_match_*`; ext panel secondary bg → `theme.status_bg.darken(0.15)` +- `mod.rs` (GTK): search result markup → `theme.function`/`theme.foreground`; cursor indicator → `theme.scrollbar_thumb` +- `draw.rs` (GTK): h-scrollbar track/thumb → `theme.scrollbar_track`/`theme.scrollbar_thumb`; tab drag overlay → `theme.cursor`/`theme.background`/`theme.foreground`; picker scrollbar track → `theme.scrollbar_track`; terminal bg → `theme.terminal_bg` +- `css.rs` (GTK): scrollbar slider, h-editor-scrollbar, find dialog, find-match-count colors now theme-aware via `make_theme_css()` overrides + +Wide-char rendering fix: `set_cell_wide()` in `tui_main/mod.rs` used `reset()` + `set_skip(true)` on the continuation cell of double-width Nerd Font glyphs. `set_skip(true)` prevented ratatui from emitting anything for that cell, leaving the terminal's default black background visible as a black rectangle next to wide glyphs. Fixed by using `set_symbol("")` + `set_fg(fg)` + `set_bg(bg)` instead — ratatui's convention for wide-char continuation cells that correctly emits the background color. + +Theme consistency fixes found during smoke testing: +- Git commit bar buttons used `theme.foreground` (dark on light themes) instead of `theme.status_fg` — illegible against blue `status_bg`. Fixed to use `hdr_fg` (status_fg). +- Debug sidebar active section header used `theme.tab_active_fg` (#333333 on light themes) — black on blue status_bg. Fixed to use `theme.status_fg.lighten(0.2)`. +- Debug "Start Debugging" button: full label was green (`theme.git_added`) which is hard to read on blue status_bg. Fixed: icon char gets semantic green/red, label text uses `hdr_fg` (status_fg) for readability. + +Files changed: `src/render.rs` (4 new Theme fields + 6 themes + VSCode importer), `src/tui_main/mouse.rs` (activity bar click fix), `src/tui_main/render_impl.rs` (scrollbar thumbs), `src/tui_main/panels.rs` (activity bar, git, debug, terminal, scrollbar colors), `src/tui_main/mod.rs` (set_cell_wide fix), `src/gtk/mod.rs` (search result markup, cursor indicator), `src/gtk/draw.rs` (scrollbar, tab drag, terminal bg), `src/gtk/css.rs` (theme-aware CSS overrides), `BUGS.md`. + +**Session 247 — GTK explorer first-click bug fix, picker search history (5299 tests):** + +Bug fix: GTK TreeView folders required two clicks/Enter presses to expand the first time. Root cause: `tree_row_expanded()` in `tree.rs` removed the dummy placeholder child (`__vimcode_loading__`) before calling `build_file_tree_shallow()` to populate real children. Between remove and populate, the directory had zero children, causing GTK to auto-collapse the row. Fix: populate real children first, then remove the dummy. Also fixed Enter after arrow-key navigation — intercept Return/KP_Enter in the TreeView key handler and send `Msg::ExplorerActivateSelected` (which syncs cursor→selection via `select_path()`) instead of relying on native `row_activated` (which uses the stale selection, not the arrow-key cursor position). + +Picker search history: `picker_history: HashMap>` on Engine (session-scoped, not persisted). `picker_push_history()` saves non-empty trimmed query on confirm; consecutive duplicates deduplicated; capped at 100 entries. `picker_history_index: Option` + `picker_history_typing_buffer: String` for browsing state. Up at `picker_selected == 0` enters history mode (saves current query, recalls most recent entry); subsequent Ups go to older entries; `saturating_sub(1)` at oldest. Down in history mode navigates newer; past newest restores original typed query and exits history mode. Typing, backspace, paste all call `picker_exit_history()` to reset. History state reset on `open_picker()`. Added `Eq + Hash` derives to `PickerSource` for HashMap key usage. 7 new tests. + +New bug filed: Marksman (Markdown LSP) status bar indicator stuck on "initializing" — greyed out with `…` suffix persists. Likely because marksman doesn't support `textDocument/semanticTokens`, and the render-side readiness heuristic downgrades `Running` to `Initializing` when `BufferState.semantic_tokens` is empty. + +Files changed: `src/gtk/tree.rs` (lazy-load ordering), `src/gtk/mod.rs` (Enter key handler), `src/core/engine/mod.rs` (PickerSource derives + history fields), `src/core/engine/picker.rs` (history helpers + key handler modifications), `src/core/engine/tests.rs` (7 new tests), `BUGS.md`, `PLAN.md`. + +**Session 246 — Explorer overhaul, diagnostic filtering, tree UX (5292 tests):** + +Removed explorer toolbar (New File/New Folder/Delete buttons) from both TUI (`EXPLORER_TOOLBAR_LEN` constant, toolbar rendering in `panels.rs`, click handling in `mouse.rs`) and GTK (`explorer-toolbar` Box widget + CSS). Removed TUI "EXPLORER" header row — tree rows now start at `area.y` instead of `area.y + 1`; `tree_height` uses full `area.height`; mouse click/scrollbar calculations adjusted (no header offset). + +Right-click in empty explorer space opens root folder context menu. TUI: when `tree_row >= sidebar.rows.len()`, uses `sidebar.root` as directory target. GTK: when `path_at_pos()` returns None and no selection, falls back to `engine.cwd`. + +Inline rename improvements: `ExplorerRenameState.selection_anchor: Option` field for text selection. `start_explorer_rename()` pre-selects filename stem (rfind `.` excluding position 0 for dotfiles). `handle_explorer_rename_key()` rewritten: selection-aware Backspace/Delete/typing (delete selection first); Ctrl-A select all, Ctrl-C/X copy/cut via `clipboard_write`, Ctrl-V paste via `clipboard_read`; arrow keys clear selection; single Escape cancels (no two-press). TUI rendering: `sel_bg` (fuzzy_selected_bg) for selected text; horizontal scroll offset when cursor exceeds available width. New-entry input also gets Ctrl-V paste and scroll. GTK: `connect_editing_started` handler on `CellRendererText` — downcasts editable to `Entry`, calls `select_region(0, stem_end)` via idle callback. + +Bug fixes: (1) GTK inline rename/new-file disappears immediately — `update_tree_indicators` (every 1s) called `set_value` on TreeStore rows, cancelling active GTK cell editor; `RefreshFileTree` could `store.clear()` during editing. Fix: `cell_editing` guard via `name_cell.is_editing()` skips both operations. (2) GTK SIGSEGV in `gtk_tree_store_set_value` — `__NEW_FILE__`/`__NEW_FOLDER__` marker rows in TreeStore caused crash; fix: skip marker rows in `update_tree_indicators::walk`. (3) GTK context menu new file/folder popover steals focus — explicit `popover.popdown()` before sending messages; `timeout_add_local_once(50ms)` instead of `idle_add_local_once` for all inline edit start operations. (4) TUI context menu "New File" from empty space did nothing — `handle_explorer_context_action` got path from `sidebar.rows[sidebar.selected]` instead of context menu target; fix: new `Engine::context_menu_target_path()` method; callers extract target before `context_menu_confirm()` consumes menu. + +Diagnostic source filtering: `ignore_error_sources` from extension manifests now filters error-severity diagnostics at storage time in `poll_lsp()` (not just explorer counts). `refilter_diagnostics()` method retroactively filters when registry updates. `ext_refresh()` called at startup in both GTK and TUI to fetch fresh registry. `initialization_options: Option` field added to `LspConfig` (extensions.rs) and `LspServerConfig` (lsp.rs); merged into LSP `initialize` request's `initializationOptions`. Rust extension in registry declares `ignore_error_sources: ["rust-analyzer"]` to suppress rust-analyzer's native type-check false positives (real errors come from `rustc` via cargo check). + +Explorer tree UX: `explorer_file_fg` theme field on all 6 built-in themes (muted grey for file names, distinct from bright `foreground`). VSCode JSON importer reads `sideBar.foreground`. TUI indent guide lines: `│` drawn at each indent level > 0 using `line_number_fg` (dim grey). TUI explorer layout restructured: `[chevron (2 cols)] [icon] [space] [name]` — both dirs and files align icons at the same column. GTK name column: `ellipsize: End` on `CellRendererText` + `Fixed` column sizing prevents long filenames from pushing indicators off-screen. + +Case-insensitive explorer sort: `explorer_sort_case_insensitive` setting (default true); `:set noesci` to disable. Applied to TUI `collect_rows`, GTK `build_file_tree_shallow` and `tree_row_expanded`. `TuiSidebar.sort_case_insensitive` mirrors engine setting. + +Fix: `LineEnding::detect()` byte-boundary crash — slicing at byte 8192 could land inside a multi-byte character (e.g. `─` at bytes 8190..8193). Now backs up to nearest char boundary via `is_char_boundary()` loop. 10 new tests. + +--- + +**Session 245 — Editor action menu, richer syntax highlighting, explorer colors (5282 tests):** + +Editor action menu (`⋯`) button at right edge of each tab bar group. 8-item dropdown: Close All, Close Others, Close Saved, Close to Right/Left, Toggle Word Wrap, Change Language Mode, Reveal in File Explorer. `ContextMenuTarget::EditorActionMenu` + `open_editor_action_menu()` + `close_all_tabs()`. TUI: `TAB_ACTION_BTN_COLS` constant, click handling in multi/single-group paths. GTK: `ActionBtnMap` type, `ClickTarget::ActionMenuButton`, `show_action_menu_popover()` with `PopoverMenu`. + +Richer tree-sitter highlight queries: 12 new Theme fields (`control_flow`, `operator`, `punctuation`, `macro_call`, `attribute`, `lifetime`, `constant`, `escape`, `boolean`, `property`, `parameter`, `module`) with colors for all 6 built-in themes + VSCode JSON importer (`keyword.control` scope). `scope_color()` expanded from 8→23 capture names. All 20 language queries expanded: keywords split into storage (`@keyword`) vs control flow (`@keyword.control`), plus operators, punctuation, numbers, booleans, method calls, field access, parameters, escape sequences, macros, attributes, lifetimes. `semantic_token_style()` now checks `controlFlow` modifier on keyword tokens, plus handles `operator`, `boolean`, `lifetime`, `attribute`, `builtinType`. Fixed tree-sitter `reparse()`: always full parse (passing old tree without `tree.edit()` caused stale byte offsets → garbled partial-word coloring). Insert mode now does immediate `update_syntax()` instead of 150ms debounce. + +Explorer color overhaul: removed `explorer_dir_fg` distinction — folders/files same base color. Git status propagated recursively to parent dirs (priority M>D>R>A>?). Diagnostic counts propagated recursively to parent dirs. Name fg color priority: error > warning > git > default. GTK indicator column split into own `TreeViewColumn` (no longer clipped by filename column). + +Bug fixes: split-down icon changed from pushpin `\u{F0931}` to caret-down `\u{f0d7}`; midline ellipsis `⋯` (`\u{22EF}`); GTK tab bar clip height (`line_height`→`tab_row_height`); split/diff buttons shifted left by action button width; `gtk_editor_bottom()` shared helper eliminates coordinate mismatches between draw, click, and divider handlers; capture-phase GestureDrag and click-handler divider hit-tests both exclude tab bar regions; GTK menu dropdown padding (removed blank header row, added 4px symmetric padding); LSP status no longer downgrades Running→Initializing when semantic tokens temporarily empty; TUI settings button bug added to BUGS.md. Removed Pinned Tabs from roadmap. + +--- + +**Session 244 — TUI rendering artifact fix (5275 tests):** +Mitigated intermittent TUI stale character artifacts. Two fixes in `src/tui_main/mod.rs`: (1) `terminal.clear()` on `Event::Resize` — terminal emulators reflow screen content on resize, desynchronizing the physical display from ratatui's previous-frame buffer; clearing resets both internal buffers so the next draw emits every cell. (2) Popup dismiss detection — track `had_popup_overlay` flag; when picker or folder picker transitions from visible to hidden, call `terminal.clear()` to force full redraw instead of relying on ratatui's incremental diff (which can miss cells where the popup was drawn over the editor). Also removed the "no way to close debug output tab" bug from BUGS.md (already fixed in a prior session). + +**Session 243 — LSP status indicator (5275 tests):** +Persistent LSP server status in per-window status bar. `LspStatus` enum in `lsp_manager.rs`: `None` (no LSP for filetype), `Installing` (binary being installed), `Initializing(server_name)` (server started but not ready), `Running(server_name)` (fully indexed), `Crashed`. Server name extracted from command path (`/usr/bin/rust-analyzer` → `rust-analyzer`). `server_has_responded: HashMap` on `LspManager` tracks first meaningful response; `mark_server_responded(server_id)` called on non-empty hover, definition, and completion responses in `panels.rs`. `lsp_status_for_language()` on `LspManager` checks `initialized` + `server_has_responded` maps. `lsp_status_for_buffer(buffer_id)` on Engine combines `lsp_installing` check + manager query. Render-side readiness override in `build_window_status_line()`: `Running` downgraded to `Initializing` when `BufferState.semantic_tokens` is empty — semantic tokens arrive after full workspace indexing, aligning with hover/go-to-definition readiness (~20s on large Rust projects). Status bar display: `rust-analyzer` (ready, normal color), `rust-analyzer…` (indexing, dimmed), `LSP↓` (installing, dimmed), `LSP✗` (crashed, red), hidden (no LSP). `StatusAction::LspInfo` click runs `:LspInfo` command. 1 new test. + +**Session 242 — Clickable status bar segments + line endings (5273 tests):** +Made all per-window status bar segments interactive in both GTK and TUI backends. `StatusAction` enum (`GoToLine`, `ChangeLanguage`, `ChangeIndentation`, `ChangeLineEnding`, `ChangeEncoding`, `SwitchBranch`) added to `StatusSegment.action: Option` field. `Engine::handle_status_action()` in execute.rs routes each action to the appropriate picker or message. New picker sources: `PickerSource::Languages` (37 languages from `all_known_language_ids()` in lsp.rs, confirm sets filetype via `SyntaxLanguage::from_language_id()` + `Syntax::new_from_language_id()`), `PickerSource::Indentation` (6 presets: Spaces 2/4/8, Tabs 2/4/8, confirm applies `expand_tab`/`tabstop`/`shift_width`), `PickerSource::LineEndings` (LF/CRLF picker, confirm calls `set_line_ending()`). Line ending infrastructure: `LineEnding` enum (`LF`/`Crlf`) in buffer_manager.rs; `LineEnding::detect()` scans first 8KB on file open; `BufferState.line_ending` field; `set_line_ending()` converts all `\r\n` ↔ `\n` in rope and marks dirty; detection re-runs on `reload_from_disk()`. New status bar segments: indentation (`Spaces: 4` / `Tab Size: 4`), line ending (`LF` / `CRLF`), git branch now clickable (opens branch picker). TUI: `status_segment_hit_test()` walks segments by char width at click time; fixed global status bar guard at `row + 2 == term_height` consuming per-window status clicks (added `!engine.settings.window_status_line` guard). GTK: `gtk_status_segment_hit_test()` walks segments by pixel width; `ClickTarget::StatusBarAction` variant; fixed `pixel_to_click_target` second `editor_bottom` calculation not accounting for per-window status or bottom panels (was always using `line_height * 2.0`). `PickerAction::SetLanguage(String)`, `SetIndentation(bool, u8)`, `SetLineEnding(bool)` variants with confirm handlers in picker.rs. 4 new tests. + +**Session 241 — Per-window status lines (5265 tests):** +Replaced the single global status bar with per-window status lines (Vim/Neovim behavior). Each window gets a status bar at its bottom edge. Active window shows: bold mode name (text tinted green/purple/red for Insert/Visual/Replace), bold filename, dirty `[+]` flag, macro recording indicator, git branch with ahead/behind, filetype, `utf-8`, cursor `Ln N, Col N`. Inactive windows show: dimmed filename + dirty + cursor position. Colors fully derived from theme — active bar bg = `theme.background.lighten(0.10)` (or `.darken(0.10)` for light themes), fg = `theme.foreground`; inactive uses `theme.status_inactive_bg/fg`. No hardcoded hex colors in rendering code. Global status bar removed when per-window is active; command line remains. New `window_status_line` setting (default `true`); `:set windowstatusline`/`:set nowindowstatusline` (abbreviation `wsl`); `SettingDef` entry in Settings sidebar. New types: `StatusSegment { text, fg, bg, bold }`, `WindowStatusLine { left_segments, right_segments }` in render.rs; `RenderedWindow.status_line: Option`. `build_window_status_line()` queries per-window buffer state. `build_screen_layout()` reduces `visible_lines` by 1 and skips `build_status_line()` when per-window active. TUI: `render_window_status_line()` draws segments in bottom row of window rect; `render_window()` shadows `area` to shrink editor content; horizontal separator suppressed when upper window has status bar; global status bar layout constraint set to 0; per-window status bar click consumed in mouse handler. GTK: `draw_window_status_bar()` renders Cairo segments with per-segment Pango bold; drawn after scrollbars; global status bar height reduced. 6 new theme fields: `status_mode_normal/insert/visual/replace_bg` (mode text tints), `status_inactive_bg/fg` (inactive bars). All 6 built-in themes updated. VSCode JSON importer inherits from base theme. 6 new tests. + **Session 240 — Cursorline highlight, GTK tab bar polish, breadcrumb picker pre-selection (5199 tests):** **Cursorline highlight:** `cursorline` setting default changed from `false` to `true`; `cursorline_bg` theme color derived from background via new `Color::cursorline_tint()` method (dark themes lighten 6%, light themes darken 4%); rendered in both GTK (`draw.rs`) and TUI (`render_impl.rs`) as full-width line background behind the cursor line (active window only); priority: DAP stopped > diff > cursorline > normal; `RenderedWindow.cursorline` bool propagated from settings; VSCode theme importer maps `editor.lineHighlightBackground`; all 6 built-in themes derive color from background. Updated existing `test_set_cursorline` test (default now true, abbreviation `cul`/`nocul`). **Hardcoded colors audit:** Scanned all rendering files, found 59 hardcoded color instances across 5 files (`css.rs` 23, `draw.rs` 12, `panels.rs` 15, `render_impl.rs` 3, `mod.rs` 3). Filed as low-priority bug in BUGS.md with per-file breakdown. Saved feedback memory: always use Theme struct fields, never hardcode colors. diff --git a/SUMMARIES/core_modules.md b/SUMMARIES/core_modules.md index a3ee4a3a..2af7dc0f 100644 --- a/SUMMARIES/core_modules.md +++ b/SUMMARIES/core_modules.md @@ -107,16 +107,18 @@ Buffer storage and management. - `BufferManager::create(path)` / `get(id)` / `get_mut(id)` / `remove(id)` - `BufferState::from_text(text)` / `from_file(path)` — buffer creation -## syntax.rs — 1,525 lines -Tree-sitter syntax highlighting for 20 languages. +## syntax.rs — 1,703 lines +Tree-sitter syntax highlighting for 20 languages. Comprehensive highlight queries with 23 capture names: keyword, keyword.control, operator, string, comment, function, function.call, method.call, type, variable, number, boolean, constant, punctuation.bracket, punctuation.delimiter, macro, attribute, lifetime, escape, module, parameter, property, field. ### Types -- `SyntaxHighlighter` — tree-sitter parser + tree per buffer +- `Syntax` — tree-sitter parser + query + tree per buffer - `SyntaxLanguage` — enum of 20 supported languages ### Key Functions -- `SyntaxHighlighter::new(language)` — create parser for language -- `parse(text)` / `edit_and_reparse(text, edit)` — incremental parsing -- `highlight_line(line, text)` — get syntax spans for a line -- `language_for_extension(ext)` / `language_for_path(path)` — language detection +- `Syntax::new_for_language(lang)` / `new_from_path(path)` — create parser +- `parse(text)` — full parse + highlight extraction (always fresh, no incremental tree reuse) +- `reparse(text)` — re-parse tree only (always full, not incremental — no tree.edit() support) +- `extract_highlights(text)` / `extract_highlights_range(text, start, end)` — query captures +- `SyntaxLanguage::from_path(path)` / `from_extension(ext)` — language detection +- `query_source()` — per-language tree-sitter highlight query strings (inline) ## spell.rs — 379 lines Spell checking via spellbook (Hunspell format). diff --git a/SUMMARIES/engine_execute.md b/SUMMARIES/engine_execute.md index 085bade4..dd4d694c 100644 --- a/SUMMARIES/engine_execute.md +++ b/SUMMARIES/engine_execute.md @@ -1,9 +1,10 @@ -# src/core/engine/execute.rs — 2,998 lines +# src/core/engine/execute.rs — 3,008 lines Ex-command dispatcher. Parses and executes all `:` commands entered in command mode. ## Key Methods - `execute_command(cmd)` — main dispatcher; giant match over ~100+ command names - Handles: `:w`, `:q`, `:e`, `:sp`, `:vs`, `:bn`, `:bp`, `:bd`, `:tabnew`, `:tabclose`, `:set`, `:colorscheme`, `:norm`, `:grep`, `:vimgrep`, `:copen`, `:cn`, `:cp`, `:Gdiff`, `:Gblame`, `:Gstatus`, `:Gpush`, `:Gpull`, `:Gfetch`, `:Gbranches`, `:term`, `:LspInfo`, `:LspRestart`, `:LspInstall`, `:DapInstall`, `:Plugin`, `:Settings`, `:Keymaps`, `:AI`, `:ExtRemove`, `:ExtRefresh`, `:map`/`:nmap`/`:imap`/`:vmap`, `:retab`, `:saveas`, `:windo`/`:bufdo`/`:tabdo`, `:fold`, `:Rename`, `:Lformat`, `:CodeAction`, `:hover`, `:DiffPeek`, `:Explore`, etc. +- `handle_status_action(action) -> Option` — handles clickable status bar segment actions; returns `Some(EngineAction::ToggleSidebar)` for sidebar toggle (backend must dispatch), handles panel/menu toggle directly - Range parsing: `%`, `'<,'>`, `N,M`, `.`, `$`, relative `+N/-N` - Falls through to plugin command dispatch if no built-in match diff --git a/SUMMARIES/engine_mod.md b/SUMMARIES/engine_mod.md index f407ea55..3bb58962 100644 --- a/SUMMARIES/engine_mod.md +++ b/SUMMARIES/engine_mod.md @@ -1,4 +1,4 @@ -# src/core/engine/mod.rs — 3,415 lines +# src/core/engine/mod.rs — 3,624 lines Core engine definition. Contains the `Engine` struct (all editor state), enums, types, `new()` constructor, free functions, and `mod` declarations for all submodules. @@ -7,11 +7,14 @@ Core engine definition. Contains the `Engine` struct (all editor state), enums, - `EngineAction` — enum returned by key handlers (None, Quit, OpenFile, Redraw, etc.) - `Mode` — editor mode (Normal, Insert, Visual, VisualLine, VisualBlock, Command, Search, Replace) - `PickerSource` / `PickerItem` / `PickerAction` — unified picker types (includes `CommandCenter` source, `GotoLine(usize)` and `GotoSymbol(PathBuf, usize, usize)` actions; `PickerItem` has `depth`/`expandable`/`expanded` for tree view) +- `StatusAction` — enum for clickable status bar segments (GoToLine, ChangeLanguage, ChangeIndentation, ChangeLineEnding, ChangeEncoding, SwitchBranch, LspInfo, ToggleSidebar, TogglePanel, ToggleMenuBar, DismissNotifications) +- `NotificationKind` — enum for background operation types (LspInstall, LspIndexing, ExtensionInstall, GitOperation, ProjectSearch, ProjectReplace) +- `Notification` — background operation tracking (id, kind, message, done, created_at, done_at) - `Dialog` / `DialogButton` / `DialogInput` — modal dialog system - `PaletteCommand` — command palette entry (includes "Go: Command Center") - `DiffLine` / `AlignedDiffEntry` — diff display types - `TabDragState` — tab drag-and-drop state -- `ContextMenuState` / `ContextMenuItem` — right-click context menus +- `ContextMenuState` / `ContextMenuItem` / `ContextMenuTarget` — context menus (Tab, ExplorerFile, ExplorerDir, Editor, EditorActionMenu, ExtPanel) - `PanelHoverPopup` / `EditorHoverPopup` — hover popup state - `EditorGroup` — tab group with own tab list + `tab_scroll_offset` for overflow scrolling - `UserKeymap` — user-defined key remapping @@ -23,6 +26,12 @@ Core engine definition. Contains the `Engine` struct (all editor state), enums, ## Key Functions - `Engine::new()` — constructor, initializes all state - `Engine::open(path)` — create engine with a file open +- `Engine::notify(kind, msg)` — push in-progress notification, returns ID +- `Engine::notify_done(id, msg)` — mark notification as done by ID +- `Engine::notify_done_by_kind(kind, msg)` — mark all notifications of a kind as done +- `Engine::dismiss_notification(id)` — remove notification by ID +- `Engine::dismiss_done_notifications()` — remove all completed notifications +- `Engine::tick_notifications()` — auto-dismiss completed notifications after 5s timeout - `normalize_ex_command(input)` — abbreviation expansion for ex commands - `build_aligned_diff(left, right)` — side-by-side diff alignment - `lcs_diff(a, b)` — LCS-based line diff diff --git a/SUMMARIES/engine_small_submodules.md b/SUMMARIES/engine_small_submodules.md index 74696ba5..89212e85 100644 --- a/SUMMARIES/engine_small_submodules.md +++ b/SUMMARIES/engine_small_submodules.md @@ -1,6 +1,6 @@ # Engine Small Submodules -## accessors.rs — 444 lines +## accessors.rs — 494 lines Convenience facade methods for accessing the active group/buffer/window. - `buffer()` / `buffer_mut()` — active window's buffer - `view()` / `view_mut()` — active window's viewport @@ -12,6 +12,7 @@ Convenience facade methods for accessing the active group/buffer/window. - `adjust_group_rects_for_hidden_tabs(rects, height)` — expand content area when tab bar hidden - `sidebar_has_focus()` — true if any sidebar panel has keyboard focus - `clear_sidebar_focus()` — clear all sidebar panel focus flags +- `explorer_indicators()` — git statuses + diagnostic counts for explorer tree; propagates both recursively to parent dirs ## search.rs — 642 lines Cursor visibility, scroll synchronization, project search/replace, word search. diff --git a/SUMMARIES/engine_tests.md b/SUMMARIES/engine_tests.md index 593949ec..4c471722 100644 --- a/SUMMARIES/engine_tests.md +++ b/SUMMARIES/engine_tests.md @@ -1,4 +1,4 @@ -# src/core/engine/tests.rs — 15,889 lines +# src/core/engine/tests.rs — 17,961 lines All engine unit and integration tests. ~815 test functions covering every Vim feature, command, motion, text object, and edge case. diff --git a/SUMMARIES/engine_windows.md b/SUMMARIES/engine_windows.md index 5eecb368..8c051996 100644 --- a/SUMMARIES/engine_windows.md +++ b/SUMMARIES/engine_windows.md @@ -1,4 +1,4 @@ -# src/core/engine/windows.rs — 3,205 lines +# src/core/engine/windows.rs — 3,396 lines Window/tab/editor-group management, splits, focus, resize, tab drag-and-drop, tab navigation history, and session restore. @@ -13,6 +13,7 @@ Window/tab/editor-group management, splits, focus, resize, tab drag-and-drop, ta - `new_tab()` — create new tab - `close_tab(idx)` — close tab with confirmation if dirty; cleans up nav history entries - `close_tab_confirm(idx)` — close with save prompt +- `close_all_tabs()` — close all tabs in active group - `next_tab()` / `prev_tab()` — gt/gT tab cycling - `goto_tab(n)` — go to tab by number - `move_tab(delta)` — reorder tabs @@ -32,6 +33,12 @@ Window/tab/editor-group management, splits, focus, resize, tab drag-and-drop, ta - `resize_group_split(delta)` — resize group divider - `calculate_group_window_rects(bounds)` — layout calculation; adjusts rects for hidden tab bars via `adjust_group_rects_for_hidden_tabs` +## Context Menus +- `open_tab_context_menu(group_id, tab_idx, x, y)` — right-click tab menu +- `open_editor_action_menu(group_id, x, y)` — `…` button dropdown (Close All/Others/Saved/Right/Left, Toggle Wrap, Change Language, Reveal) +- `context_menu_confirm()` — dispatch selected context menu action +- `handle_context_menu_key(key)` — keyboard navigation for context menus + ## Session - `save_session()` — persist open tabs/groups/layout to disk - `restore_session()` — reload previous session state diff --git a/SUMMARIES/gtk_draw.md b/SUMMARIES/gtk_draw.md index 30e87ae7..185bc882 100644 --- a/SUMMARIES/gtk_draw.md +++ b/SUMMARIES/gtk_draw.md @@ -1,4 +1,4 @@ -# src/gtk/draw.rs — 5,760 lines +# src/gtk/draw.rs — 5,960 lines All Cairo/Pango drawing functions for the GTK backend. Each `draw_*` function renders one UI component onto a Cairo context using data from `ScreenLayout`. @@ -6,10 +6,11 @@ All Cairo/Pango drawing functions for the GTK backend. Each `draw_*` function re - `draw_editor` — main editor area (all windows, gutters, text, cursors) - `draw_window` — single editor window with syntax-highlighted lines - `draw_visual_selection` — visual mode selection overlay -- `draw_tab_bar` — tab strip per editor group with scroll offset +- `draw_tab_bar` — tab strip per editor group with scroll offset + `…` action menu button - `draw_breadcrumb_bar` — file path breadcrumbs below tab bar - `draw_h_scrollbars` — horizontal scrollbars - `draw_tab_drag_overlay` — drag indicator when moving tabs +- `draw_window_status_bar` — per-window status bar with styled segments - `draw_window_separators` — dividers between split windows - `draw_completion_popup` — LSP/word completion dropdown - `draw_hover_popup` — LSP hover information popup diff --git a/SUMMARIES/gtk_helpers.md b/SUMMARIES/gtk_helpers.md index d305ebd9..ead6cf57 100644 --- a/SUMMARIES/gtk_helpers.md +++ b/SUMMARIES/gtk_helpers.md @@ -1,10 +1,11 @@ # GTK Helper Files -## src/gtk/click.rs — 616 lines +## src/gtk/click.rs — 697 lines Mouse click/drag/double-click handling for GTK backend. Maps pixel coordinates to logical click targets. -- `ClickTarget` — enum: TabBar, Gutter, BufferPos, SplitButton, CloseTab, DiffToolbar*, NavBack, NavForward, None -- `pixel_to_click_target()` — converts (x,y) pixel position to a `ClickTarget`; uses cached Pango maps -- `handle_mouse_click()` — dispatches click to engine actions; NavBack/NavForward call `tab_nav_back/forward` +- `ClickTarget` — enum: TabBar, Gutter, BufferPos, SplitButton, CloseTab, DiffToolbar*, StatusBarAction, ActionMenuButton, NavBack, NavForward, None +- `pixel_to_click_target()` — converts (x,y) pixel position to a `ClickTarget`; per-window status bar segment hit-testing +- `gtk_status_segment_hit_test()` — walks status segments by pixel width to find clicked action +- `handle_mouse_click()` — dispatches click to engine actions; StatusBarAction calls `handle_status_action()` - Click/drag/double-click handler functions dispatched from `App::update()` ## src/gtk/css.rs — 535 lines diff --git a/SUMMARIES/gtk_mod.md b/SUMMARIES/gtk_mod.md index 4fa7ecd1..b6221354 100644 --- a/SUMMARIES/gtk_mod.md +++ b/SUMMARIES/gtk_mod.md @@ -1,4 +1,4 @@ -# src/gtk/mod.rs — 9,740 lines +# src/gtk/mod.rs — 10,048 lines GTK4/Relm4 application shell. Defines the `App` struct, `Msg` enum, and `SimpleComponent` impl (init/view/update). Contains the main event loop, window setup, input handling, and all GTK widget wiring. @@ -19,6 +19,7 @@ GTK4/Relm4 application shell. Defines the `App` struct, `Msg` enum, and `SimpleC - `handle_mouse_click_msg()` — left click dispatching via `pixel_to_click_target()` (includes status bar branch click handler) - `handle_mouse_drag_msg()` — mouse drag (tab, scrollbar, sidebar resize, text selection) - `handle_mouse_up_msg()` — mouse release, tab drop, sidebar divider drop +- `show_action_menu_popover()` — editor action menu (`…` button) popover with gio::Menu - `handle_tab_right_click()` — tab context menu (close, split, copy path) - `handle_editor_right_click()` — editor context menu (cut, copy, paste, LSP actions) - `handle_terminal_msg()` — terminal toggle, tabs, mouse, find, clipboard diff --git a/SUMMARIES/render.md b/SUMMARIES/render.md index 7d826155..b51d7bb9 100644 --- a/SUMMARIES/render.md +++ b/SUMMARIES/render.md @@ -1,4 +1,4 @@ -# src/render.rs — 6,463 lines +# src/render.rs — 7,384 lines Platform-agnostic rendering abstraction. Transforms engine state into `ScreenLayout` consumed by both GTK and TUI backends. Contains all themes, render data structs, and the main layout builder. @@ -6,11 +6,14 @@ Platform-agnostic rendering abstraction. Transforms engine state into `ScreenLay - `Color` — RGB color with hex parsing, lighten/darken, `cursorline_tint()`, Cairo/Pango conversion - `Style` — fg/bg/bold/italic/underline - `StyledSpan` — text span with style + column range -- `Theme` — complete color scheme (70+ color fields incl. `cursorline_bg`); 6 built-ins (OneDark, Gruvbox, TokyoNight, Solarized, VSCode Dark/Light) + VSCode JSON import +- `Theme` — complete color scheme (95+ color fields incl. `scrollbar_thumb`, `scrollbar_track`, `terminal_bg`, `activity_bar_fg`); 6 built-ins + VSCode JSON import; `scope_color()` maps 23 tree-sitter capture names; `semantic_token_style()` handles LSP semantic tokens with `controlFlow` modifier ## Key Types — Editor Content - `RenderedLine` — single visual line with spans, gutter, diagnostics, git markers, fold state, wrap info -- `RenderedWindow` — complete window render data (lines, cursor, selection, scrollbars, etc.) +- `StatusAction` — re-exported from core; action enum for clickable status segments (GoToLine, ChangeLanguage, etc.) +- `StatusSegment` — styled segment of a per-window status line (text, fg, bg, bold, action) +- `WindowStatusLine` — per-window status bar (left/right segment vectors) +- `RenderedWindow` — complete window render data (lines, cursor, selection, scrollbars, `status_line: Option`, etc.) - `CursorPos` / `CursorShape` — cursor position and shape - `SelectionRange` / `SelectionKind` — visual selection data - `DiagnosticMark` / `SpellMark` — underline markers diff --git a/SUMMARIES/tui_modules.md b/SUMMARIES/tui_modules.md index 65fc1fd7..a2c63fc8 100644 --- a/SUMMARIES/tui_modules.md +++ b/SUMMARIES/tui_modules.md @@ -10,18 +10,19 @@ TUI application shell using ratatui + crossterm. Contains setup, event loop, key - Clipboard via `copypasta_ext::x11_bin::ClipboardContext` - Keyboard enhancement flags for Kitty/WezTerm (disambiguate Ctrl+Shift combos) -## src/tui_main/render_impl.rs — 3,845 lines +## src/tui_main/render_impl.rs — 3,944 lines All TUI rendering. Converts `ScreenLayout` into ratatui `Frame` draws. - `draw_frame(frame, engine, theme)` — top-level render function - `build_screen_for_tui(engine, cols, rows)` — compute layout geometry - Tab bar rendering per editor group - Editor window rendering (syntax spans → ratatui Spans) - Popup rendering: completion, hover, picker, dialog, context menu, diff peek, signature help -- Status line + command line + wildmenu rendering +- Per-window status line rendering (`render_window_status_line`) +- Global status line + command line + wildmenu rendering - Menu bar + dropdown rendering (centered nav arrows + Command Center search box) - Debug toolbar rendering -## src/tui_main/panels.rs — 4,048 lines +## src/tui_main/panels.rs — 4,034 lines Sidebar panel rendering for all TUI panels. - Activity bar (icon column, panel switching) - Explorer file tree with git/diagnostic indicators + inline new-entry rows (`render_new_entry_row()`) @@ -34,13 +35,13 @@ Sidebar panel rendering for all TUI panels. - Extension dynamic panels (Lua-registered panels) - Panel hover popup rendering -## src/tui_main/mouse.rs — 2,459 lines +## src/tui_main/mouse.rs — 2,661 lines All TUI mouse interaction handling. - `handle_mouse(event, engine, layout)` — top-level mouse dispatcher - Activity bar clicks (panel switching) - Explorer tree clicks (file open, expand/collapse, context menu) - Editor clicks (cursor placement, selection, drag) -- Tab bar clicks (tab switch, close button, drag between groups) +- Tab bar clicks (tab switch, close button, action menu `…` button, drag between groups) - Sidebar resize drag (Alt+Left/Right or mouse drag on border) - Scrollbar drag (vertical + horizontal, editor + panel) - Status bar clicks (branch name click opens branch picker) diff --git a/src/core/buffer_manager.rs b/src/core/buffer_manager.rs index 5e0dffd0..8ebd134f 100644 --- a/src/core/buffer_manager.rs +++ b/src/core/buffer_manager.rs @@ -7,6 +7,37 @@ use super::buffer::{Buffer, BufferId}; use super::cursor::Cursor; use super::syntax::Syntax; +/// Line ending format for a buffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineEnding { + LF, + Crlf, +} + +impl LineEnding { + /// Detect line ending from file content bytes. Scans up to 8KB. + pub fn detect(text: &str) -> Self { + let mut end = text.len().min(8192); + // Back up to a valid char boundary (multi-byte chars may straddle 8KB) + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + let scan = &text[..end]; + if scan.contains("\r\n") { + LineEnding::Crlf + } else { + LineEnding::LF + } + } + + pub fn as_str(self) -> &'static str { + match self { + LineEnding::LF => "LF", + LineEnding::Crlf => "CRLF", + } + } +} + // ============================================================================= // Undo/Redo Data Structures // ============================================================================= @@ -126,6 +157,8 @@ pub struct BufferState { /// When `Some(n)`, overrides `settings.shift_width` for this buffer. /// Detected on file open by analyzing indent deltas between lines. pub detected_indent: Option, + /// Line ending format (LF or CRLF). Detected on file open, default LF. + pub line_ending: LineEnding, } impl std::fmt::Debug for BufferState { @@ -178,6 +211,7 @@ impl BufferState { file_mtime: None, file_change_warned: false, detected_indent: None, + line_ending: LineEnding::LF, }; state.update_syntax(); state @@ -188,6 +222,7 @@ impl BufferState { let lsp_language_id = crate::core::lsp::language_id_from_path(&path); let canonical_path = path.canonicalize().ok(); let file_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); + let line_ending = LineEnding::detect(&buffer.to_string()); let mut state = Self { buffer, @@ -224,6 +259,7 @@ impl BufferState { file_mtime, file_change_warned: false, detected_indent: None, + line_ending, }; state.detect_indent(); state.update_syntax(); @@ -234,7 +270,11 @@ impl BufferState { pub fn update_syntax(&mut self) { let text = self.buffer.to_string(); self.highlights = if let Some(ref mut syn) = self.syntax { - syn.parse(&text) + let mut hl = syn.parse(&text); + // Ensure sorted by start_byte — the render pipeline uses binary + // search (partition_point) to narrow highlights to the viewport. + hl.sort_by_key(|h| h.0); + hl } else { Vec::new() }; @@ -287,6 +327,7 @@ impl BufferState { /// Mark syntax as needing a re-parse. Does NO work — just records the /// timestamp so the idle handler can debounce and re-parse after the user /// pauses typing. Call this on every keystroke in insert mode. + #[allow(dead_code)] pub fn mark_syntax_stale(&mut self) { self.syntax_stale = true; self.syntax_stale_since = Some(std::time::Instant::now()); @@ -329,6 +370,25 @@ impl BufferState { self.max_col = text.lines().map(|l| l.chars().count()).max().unwrap_or(0); } + /// Switch line ending format. Converts all line endings in the buffer content. + pub fn set_line_ending(&mut self, new: LineEnding) { + if self.line_ending == new { + return; + } + let text = self.buffer.to_string(); + let converted = match new { + LineEnding::Crlf => text.replace('\n', "\r\n"), + LineEnding::LF => text.replace("\r\n", "\n"), + }; + let char_len = self.buffer.len_chars(); + self.buffer.delete_range(0, char_len); + if !converted.is_empty() { + self.buffer.insert(0, &converted); + } + self.line_ending = new; + self.dirty = true; + } + /// Save the buffer to its associated file path. pub fn save(&mut self) -> Result { if let Some(ref path) = self.file_path { @@ -348,6 +408,7 @@ impl BufferState { pub fn reload_from_disk(&mut self) -> Result<(), io::Error> { if let Some(ref path) = self.file_path { let text = std::fs::read_to_string(path)?; + self.line_ending = LineEnding::detect(&text); let char_len = self.buffer.len_chars(); self.buffer.delete_range(0, char_len); if !text.is_empty() { diff --git a/src/core/engine/accessors.rs b/src/core/engine/accessors.rs index 8253ecbc..cb14f556 100644 --- a/src/core/engine/accessors.rs +++ b/src/core/engine/accessors.rs @@ -255,6 +255,56 @@ impl Engine { } } + // Propagate git statuses up to parent directories so that a folder + // shows modified/added color when any descendant file has that status. + // Priority: M > D > R > A > ? + fn git_priority(c: char) -> u8 { + match c { + 'M' => 5, + 'D' => 4, + 'R' => 3, + 'A' => 2, + '?' => 1, + _ => 0, + } + } + let git_file_paths: Vec = git_statuses.keys().cloned().collect(); + for file_path in &git_file_paths { + let status = git_statuses[file_path]; + let mut ancestor = file_path.parent(); + while let Some(dir) = ancestor { + let entry = git_statuses.entry(dir.to_path_buf()).or_insert(status); + if git_priority(status) > git_priority(*entry) { + *entry = status; + } + ancestor = dir.parent(); + if dir == self.cwd { + break; + } + } + } + + // Propagate diagnostic counts up to parent directories so that a + // folder shows error/warning color when any descendant file has issues. + let file_paths: Vec = diag_counts.keys().cloned().collect(); + for file_path in &file_paths { + let (errors, warnings) = diag_counts[file_path]; + if errors == 0 && warnings == 0 { + continue; + } + let mut ancestor = file_path.parent(); + while let Some(dir) = ancestor { + let entry = diag_counts.entry(dir.to_path_buf()).or_insert((0, 0)); + entry.0 += errors; + entry.1 += warnings; + ancestor = dir.parent(); + // Stop at the cwd to avoid propagating to unrelated dirs. + if dir == self.cwd { + break; + } + } + } + (git_statuses, diag_counts) } diff --git a/src/core/engine/buffers.rs b/src/core/engine/buffers.rs index 962609ab..8b67adfa 100644 --- a/src/core/engine/buffers.rs +++ b/src/core/engine/buffers.rs @@ -11,6 +11,7 @@ impl Engine { /// Mark syntax as stale without doing any parsing work. Call on every /// keystroke in insert mode — the idle handler debounces the actual re-parse. + #[allow(dead_code)] pub fn mark_syntax_stale(&mut self) { self.active_buffer_state_mut().mark_syntax_stale(); } @@ -867,7 +868,10 @@ impl Engine { state.read_only = true; state.scratch_name = Some(format!("{file_name} (HEAD)")); // Set syntax highlighting to match the file type. - if let Some(syn) = crate::core::syntax::Syntax::new_from_path(path.to_str()) { + if let Some(syn) = crate::core::syntax::Syntax::new_from_path_with_overrides( + path.to_str(), + Some(&self.highlight_overrides), + ) { state.syntax = Some(syn); } state.update_syntax(); @@ -1073,7 +1077,10 @@ impl Engine { state.buffer.content = ropey::Rope::from_str(head_content); state.read_only = true; state.scratch_name = Some(format!("{file_name} (HEAD)")); - if let Some(syn) = crate::core::syntax::Syntax::new_from_path(path.to_str()) { + if let Some(syn) = crate::core::syntax::Syntax::new_from_path_with_overrides( + path.to_str(), + Some(&self.highlight_overrides), + ) { state.syntax = Some(syn); } state.update_syntax(); @@ -1133,7 +1140,10 @@ impl Engine { state.read_only = true; state.scratch_name = Some(format!("{file_name} ({short})")); state.diff_label = Some(format!("{file_name} ({short})")); - if let Some(syn) = crate::core::syntax::Syntax::new_from_path(Some(rel_path)) { + if let Some(syn) = crate::core::syntax::Syntax::new_from_path_with_overrides( + Some(rel_path), + Some(&self.highlight_overrides), + ) { state.syntax = Some(syn); state.update_syntax(); } @@ -1151,7 +1161,10 @@ impl Engine { state.buffer.content = ropey::Rope::from_str(&before); state.read_only = true; state.scratch_name = Some(format!("{file_name} ({short}~1)")); - if let Some(syn) = crate::core::syntax::Syntax::new_from_path(Some(rel_path)) { + if let Some(syn) = crate::core::syntax::Syntax::new_from_path_with_overrides( + Some(rel_path), + Some(&self.highlight_overrides), + ) { state.syntax = Some(syn); state.update_syntax(); } @@ -1920,11 +1933,20 @@ impl Engine { .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); - let cursor = name.len(); + // Pre-select the stem (filename without extension) so Backspace + // removes just the name, preserving the extension. + let stem_end = if path.is_dir() { + // Directories: select entire name + name.len() + } else { + // Find last '.' that isn't at position 0 (dotfiles like .gitignore) + name.rfind('.').filter(|&i| i > 0).unwrap_or(name.len()) + }; self.explorer_rename = Some(ExplorerRenameState { path, + cursor: stem_end, + selection_anchor: if stem_end > 0 { Some(0) } else { None }, input: name, - cursor, }); } @@ -1944,6 +1966,30 @@ impl Engine { None => return false, }; + // Helper: get sorted selection range (start, end) or None. + let sel_range = |s: &ExplorerRenameState| -> Option<(usize, usize)> { + s.selection_anchor.map(|a| { + let lo = a.min(s.cursor); + let hi = a.max(s.cursor); + (lo, hi) + }) + }; + + // Helper: delete selected text, place cursor at selection start. + // Returns true if there was a selection to delete. + fn delete_selection(s: &mut ExplorerRenameState) -> bool { + if let Some(anchor) = s.selection_anchor.take() { + let lo = anchor.min(s.cursor); + let hi = anchor.max(s.cursor); + if lo != hi { + s.input.drain(lo..hi); + s.cursor = lo; + return true; + } + } + false + } + match key_name { "Escape" => { self.explorer_rename = None; @@ -1970,7 +2016,7 @@ impl Engine { return true; } "BackSpace" => { - if state.cursor > 0 { + if !delete_selection(state) && state.cursor > 0 { let prev = state.input[..state.cursor] .char_indices() .next_back() @@ -1982,7 +2028,7 @@ impl Engine { return true; } "Delete" => { - if state.cursor < state.input.len() { + if !delete_selection(state) && state.cursor < state.input.len() { state.input.remove(state.cursor); } return true; @@ -1995,6 +2041,7 @@ impl Engine { .map(|(i, _)| i) .unwrap_or(0); } + state.selection_anchor = None; return true; } "Right" => { @@ -2006,27 +2053,83 @@ impl Engine { .map(|(i, _)| state.cursor + i) .unwrap_or(state.input.len()); } + state.selection_anchor = None; return true; } "Home" => { state.cursor = 0; + state.selection_anchor = None; return true; } "End" => { state.cursor = state.input.len(); + state.selection_anchor = None; return true; } _ => {} } - // Printable character insertion - if !ctrl { - if let Some(ch) = unicode { - if !ch.is_control() { - state.input.insert(state.cursor, ch); - state.cursor += ch.len_utf8(); + // Ctrl shortcuts + if ctrl { + match key_name { + "a" => { + // Select all + state.selection_anchor = Some(0); + state.cursor = state.input.len(); return true; } + "c" => { + // Copy selection to clipboard + if let Some((lo, hi)) = sel_range(state) { + if lo != hi { + let text = state.input[lo..hi].to_string(); + if let Some(ref cb) = self.clipboard_write { + let _ = cb(&text); + } + } + } + return true; + } + "x" => { + // Cut selection to clipboard + if let Some((lo, hi)) = sel_range(state) { + if lo != hi { + let text = state.input[lo..hi].to_string(); + if let Some(ref cb) = self.clipboard_write { + let _ = cb(&text); + } + delete_selection(state); + } + } + return true; + } + "v" => { + // Paste from clipboard + delete_selection(state); + let paste = if let Some(ref cb) = self.clipboard_read { + cb().unwrap_or_default() + } else { + String::new() + }; + // Only use first line + let line = paste.lines().next().unwrap_or(""); + state.input.insert_str(state.cursor, line); + state.cursor += line.len(); + return true; + } + _ => {} + } + // Consume other ctrl combos + return true; + } + + // Printable character insertion (replaces selection if any) + if let Some(ch) = unicode { + if !ch.is_control() { + delete_selection(state); + state.input.insert(state.cursor, ch); + state.cursor += ch.len_utf8(); + return true; } } @@ -2174,14 +2277,37 @@ impl Engine { _ => {} } - // Printable character insertion - if !ctrl { - if let Some(ch) = unicode { - if !ch.is_control() { - state.input.insert(state.cursor, ch); - state.cursor += ch.len_utf8(); + // Ctrl shortcuts for new-entry input + if ctrl { + match key_name { + "v" => { + // Paste from clipboard + let paste = if let Some(ref cb) = self.clipboard_read { + cb().unwrap_or_default() + } else { + String::new() + }; + let line = paste.lines().next().unwrap_or(""); + state.input.insert_str(state.cursor, line); + state.cursor += line.len(); + return true; + } + "a" => { + // Select all — no selection support in new-entry, just move to end + state.cursor = state.input.len(); return true; } + _ => {} + } + return true; + } + + // Printable character insertion + if let Some(ch) = unicode { + if !ch.is_control() { + state.input.insert(state.cursor, ch); + state.cursor += ch.len_utf8(); + return true; } } diff --git a/src/core/engine/execute.rs b/src/core/engine/execute.rs index 8b1d9003..9a193a92 100644 --- a/src/core/engine/execute.rs +++ b/src/core/engine/execute.rs @@ -71,7 +71,7 @@ impl Engine { return EngineAction::None; } - // Handle :DapBottomPanel terminal|output — switch the bottom panel tab. + // Handle :DapBottomPanel terminal|output|close — switch or close the bottom panel tab. if let Some(panel_name) = cmd.strip_prefix("DapBottomPanel").map(|s| s.trim()) { match panel_name { "terminal" => { @@ -82,8 +82,12 @@ impl Engine { self.bottom_panel_kind = BottomPanelKind::DebugOutput; self.message = "Bottom panel: Debug Output".to_string(); } + "close" => { + self.bottom_panel_open = false; + self.message = "Bottom panel closed".to_string(); + } _ => { - self.message = "Usage: :DapBottomPanel terminal|output".to_string(); + self.message = "Usage: :DapBottomPanel terminal|output|close".to_string(); } } return EngineAction::None; @@ -3032,4 +3036,52 @@ impl Engine { text.to_string() } } + + /// Handle a click on an interactive status bar segment. + /// Handle a status bar segment click. Returns an `EngineAction` if the + /// caller (backend) must perform it (e.g. sidebar toggle lives on the UI). + pub fn handle_status_action(&mut self, action: &StatusAction) -> Option { + match action { + StatusAction::GoToLine => { + self.open_picker(PickerSource::CommandCenter); + self.picker_query = ":".to_string(); + self.picker_filter(); + self.picker_load_preview(); + } + StatusAction::ChangeLanguage => { + self.open_picker(PickerSource::Languages); + } + StatusAction::ChangeIndentation => { + self.open_picker(PickerSource::Indentation); + } + StatusAction::ChangeLineEnding => { + self.open_picker(PickerSource::LineEndings); + } + StatusAction::ChangeEncoding => { + self.message = "Only UTF-8 encoding is supported".to_string(); + } + StatusAction::SwitchBranch => { + self.open_picker(PickerSource::GitBranches); + } + StatusAction::LspInfo => { + let _ = self.execute_command("LspInfo"); + } + StatusAction::ToggleSidebar => { + return Some(EngineAction::ToggleSidebar); + } + StatusAction::TogglePanel => { + if self.terminal_panes.is_empty() { + return Some(EngineAction::OpenTerminal); + } + self.toggle_terminal(); + } + StatusAction::ToggleMenuBar => { + self.toggle_menu_bar(); + } + StatusAction::DismissNotifications => { + self.dismiss_done_notifications(); + } + } + None + } } diff --git a/src/core/engine/ext_panel.rs b/src/core/engine/ext_panel.rs index 75c9583c..8a468cff 100644 --- a/src/core/engine/ext_panel.rs +++ b/src/core/engine/ext_panel.rs @@ -2038,6 +2038,10 @@ impl Engine { if self.settings.set_value_str(def.key, &val).is_ok() { let _ = self.settings.save(); } + // Lazy-init spell checker when toggled on via text entry + if def.key == "spell" && self.settings.spell { + self.ensure_spell_checker(); + } self.settings_editing = None; self.settings_edit_buf.clear(); } @@ -2140,6 +2144,10 @@ impl Engine { if self.settings.set_value_str(def.key, new_val).is_ok() { let _ = self.settings.save(); } + // Lazy-init spell checker when toggled on + if def.key == "spell" && self.settings.spell { + self.ensure_spell_checker(); + } } } SettingType::Enum(options) => { diff --git a/src/core/engine/keys.rs b/src/core/engine/keys.rs index fccc3d28..f0153617 100644 --- a/src/core/engine/keys.rs +++ b/src/core/engine/keys.rs @@ -349,14 +349,11 @@ impl Engine { self.set_dirty(true); let t1 = std::time::Instant::now(); - // In insert mode, use the fast path: just re-parse the tree - // (incremental, fast) without extracting highlights (O(n), slow). - // Highlights are marked stale and re-extracted on the next render. - if self.mode == Mode::Insert { - self.mark_syntax_stale(); - } else { - self.update_syntax(); - } + // Always do a full re-parse + highlight extraction so byte + // offsets stay correct. Tree-sitter incremental parsing is fast + // enough for interactive use; deferring caused garbled colors + // because stale byte offsets produced partial-word highlighting. + self.update_syntax(); let t2 = std::time::Instant::now(); // Auto-promote preview buffer on text modification diff --git a/src/core/engine/lsp_ops.rs b/src/core/engine/lsp_ops.rs index 21d5bcb8..2cd0a62b 100644 --- a/src/core/engine/lsp_ops.rs +++ b/src/core/engine/lsp_ops.rs @@ -193,6 +193,8 @@ impl Engine { let count = entries.len(); registry::save_cache(&entries); self.ext_registry = Some(entries); + // Re-filter stored diagnostics with updated ignore_error_sources. + self.refilter_diagnostics(); self.message = format!("Extension registry updated ({count} extensions)"); } None => { @@ -279,6 +281,10 @@ impl Engine { ext_name: ext_name.clone(), install_key: lsp_key, }); + self.notify( + NotificationKind::LspInstall, + &format!("Installing {}…", manifest.lsp.binary), + ); status_parts.push(format!("LSP: installing {}…", manifest.lsp.binary)); } } @@ -708,4 +714,27 @@ impl Engine { } false } + + /// Get the LSP status for a specific buffer's language. + /// Returns `LspStatus::None` if no LSP is configured or the manager isn't started. + pub fn lsp_status_for_buffer( + &self, + buffer_id: crate::core::buffer::BufferId, + ) -> crate::core::lsp_manager::LspStatus { + use crate::core::lsp_manager::LspStatus; + let lang = match self.buffer_manager.get(buffer_id) { + Some(s) => match s.lsp_language_id.as_deref() { + Some(l) => l, + None => return LspStatus::None, + }, + None => return LspStatus::None, + }; + if self.lsp_installing.contains(lang) { + return LspStatus::Installing; + } + match &self.lsp_manager { + Some(mgr) => mgr.lsp_status_for_language(lang), + None => LspStatus::None, + } + } } diff --git a/src/core/engine/mod.rs b/src/core/engine/mod.rs index 86a9ff7d..d2944ada 100644 --- a/src/core/engine/mod.rs +++ b/src/core/engine/mod.rs @@ -741,7 +741,56 @@ pub static PALETTE_COMMANDS: &[PaletteCommand] = &[ // ─── Unified Picker Types ──────────────────────────────────────────────────── /// Identifies the data source backing a picker modal. +/// Action triggered when a status bar segment is clicked. #[derive(Debug, Clone, PartialEq)] +pub enum StatusAction { + GoToLine, + ChangeLanguage, + ChangeIndentation, + ChangeLineEnding, + ChangeEncoding, + SwitchBranch, + LspInfo, + ToggleSidebar, + TogglePanel, + ToggleMenuBar, + /// Dismiss all completed notifications (click on bell icon). + DismissNotifications, +} + +// ─── Notification System ──────────────────────────────────────────────────── + +/// Kind of background operation being tracked by a notification. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[allow(dead_code)] +pub enum NotificationKind { + LspInstall, + LspIndexing, + ExtensionInstall, + GitOperation, + ProjectSearch, + ProjectReplace, +} + +/// A single notification tracking a background operation. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Notification { + /// Unique ID for this notification. + pub id: u64, + /// Kind of operation (for dedup/grouping). + pub kind: NotificationKind, + /// Short message displayed in the status bar (e.g. "Installing rust-analyzer…"). + pub message: String, + /// `true` once the operation is done (switches from spinner to bell). + pub done: bool, + /// When the notification was created. + pub created_at: std::time::Instant, + /// When the notification was marked done (for auto-dismiss after a delay). + pub done_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[allow(dead_code)] pub enum PickerSource { Files, @@ -755,6 +804,12 @@ pub enum PickerSource { GitBranches, /// Command Center: dynamic prefix routing (>, @, #, :, ?). CommandCenter, + /// Language/filetype picker (click on language segment in status bar). + Languages, + /// Indentation picker (click on indent segment in status bar). + Indentation, + /// Line ending picker (LF / CRLF). + LineEndings, Custom(String), } @@ -812,6 +867,12 @@ pub enum PickerAction { GotoLine(usize), /// Jump to a symbol location (file, line, col). GotoSymbol(PathBuf, usize, usize), + /// Set language/filetype for the active buffer. + SetLanguage(String), + /// Set indentation: (expand_tab, tabstop/shift_width). + SetIndentation(bool, u8), + /// Set line ending format: true = CRLF, false = LF. + SetLineEnding(bool), Custom(String), } @@ -957,6 +1018,7 @@ pub enum ContextMenuTarget { ExplorerFile { path: PathBuf }, ExplorerDir { path: PathBuf }, Editor, + EditorActionMenu { group_id: GroupId }, ExtPanel { panel_name: String, item_id: String }, } @@ -1140,6 +1202,9 @@ pub struct ExplorerRenameState { pub input: String, /// Byte-offset cursor position within `input`. pub cursor: usize, + /// Byte-offset selection anchor (if different from cursor, text between + /// `selection_anchor` and `cursor` is selected). `None` = no selection. + pub selection_anchor: Option, } /// State for inline new-file/folder creation in the explorer sidebar. @@ -2078,6 +2143,8 @@ pub struct Engine { /// Runtime overrides for comment styles, keyed by LSP language ID. /// Populated from extension manifests and `vimcode.set_comment_style()` Lua API. pub comment_overrides: HashMap, + /// Highlight query overrides from extension manifests, keyed by LSP language ID. + pub highlight_overrides: HashMap, // --- Fuzzy file finder --- /// Project root directory for the fuzzy finder. @@ -2131,6 +2198,12 @@ pub struct Engine { pub picker_title: String, /// Preview pane content for the selected item, or None for no-preview sources. pub picker_preview: Option, + /// Per-source search history (session-scoped, not persisted). + pub picker_history: std::collections::HashMap>, + /// Current position in history when navigating (None = not browsing history). + pub picker_history_index: Option, + /// Saves the user's in-progress query when they start browsing history. + pub picker_history_typing_buffer: String, // --- Breadcrumb focus mode --- /// Whether breadcrumb keyboard navigation is active (entered via `b`). @@ -2189,6 +2262,8 @@ pub struct Engine { // --- Menu bar / debug toolbar --- /// Whether the VSCode-style menu bar strip is visible. pub menu_bar_visible: bool, + /// Whether the menu bar can be fully hidden (true in TUI, false in GTK where it's the title bar). + pub menu_bar_toggleable: bool, /// Index of the currently open top-level menu dropdown (None = bar visible but no dropdown). pub menu_open_idx: Option, /// Whether the debug toolbar strip is shown (persistent for now; later: only during DAP session). @@ -2554,6 +2629,12 @@ pub struct Engine { /// Extension panel help bindings: panel_name -> [(key, description)] pub ext_panel_help_bindings: HashMap>, + // --- Notifications (background operation progress) --- + /// Active notifications (spinner/bell indicators in status bar). + pub notifications: Vec, + /// Counter for generating unique notification IDs. + next_notification_id: u64, + // --- Editor hover popup --- /// Active editor hover popup with rendered markdown content. pub editor_hover: Option, @@ -2769,6 +2850,7 @@ impl Engine { sc_help_open: false, plugin_manager: None, comment_overrides: HashMap::new(), + highlight_overrides: HashMap::new(), cwd, tab_switcher_open: false, tab_switcher_selected: 0, @@ -2790,6 +2872,9 @@ impl Engine { picker_scroll_top: 0, picker_title: String::new(), picker_preview: None, + picker_history: std::collections::HashMap::new(), + picker_history_index: None, + picker_history_typing_buffer: String::new(), breadcrumb_focus: false, breadcrumb_selected: 0, breadcrumb_segments: Vec::new(), @@ -2820,6 +2905,7 @@ impl Engine { terminal_split: false, terminal_split_left_cols: 0, menu_bar_visible: false, + menu_bar_toggleable: false, menu_open_idx: None, debug_toolbar_visible: false, dap_session_active: false, @@ -2944,6 +3030,8 @@ impl Engine { ext_panel_focus_pending: None, ext_panel_help_open: false, ext_panel_help_bindings: HashMap::new(), + notifications: Vec::new(), + next_notification_id: 1, editor_hover: None, editor_hover_dwell: None, editor_hover_dismiss_at: None, @@ -3017,6 +3105,84 @@ impl Engine { engine.plugin_init(); engine } + + // ── Notification helpers ───────────────────────────────────────────────── + + /// Push a new in-progress notification. Returns the notification ID. + pub fn notify(&mut self, kind: NotificationKind, message: &str) -> u64 { + let id = self.next_notification_id; + self.next_notification_id += 1; + self.notifications.push(Notification { + id, + kind, + message: message.to_string(), + done: false, + created_at: std::time::Instant::now(), + done_at: None, + }); + id + } + + /// Mark a notification as done (switches from spinner to bell). + /// If `new_message` is `Some`, updates the display text. + #[allow(dead_code)] + pub fn notify_done(&mut self, id: u64, new_message: Option<&str>) { + if let Some(n) = self.notifications.iter_mut().find(|n| n.id == id) { + n.done = true; + n.done_at = Some(std::time::Instant::now()); + if let Some(msg) = new_message { + n.message = msg.to_string(); + } + } + } + + /// Mark a notification as done by kind (useful when the caller didn't store the ID). + pub fn notify_done_by_kind(&mut self, kind: &NotificationKind, new_message: Option<&str>) { + for n in &mut self.notifications { + if &n.kind == kind && !n.done { + n.done = true; + n.done_at = Some(std::time::Instant::now()); + if let Some(msg) = new_message { + n.message = msg.to_string(); + } + } + } + } + + /// Remove a specific notification by ID. + #[allow(dead_code)] + pub fn dismiss_notification(&mut self, id: u64) { + self.notifications.retain(|n| n.id != id); + } + + /// Remove all completed notifications (bell icon click). + pub fn dismiss_done_notifications(&mut self) { + self.notifications.retain(|n| !n.done); + } + + /// Auto-dismiss completed notifications after 5 seconds. + /// Call from the backend poll loop. + pub fn tick_notifications(&mut self) { + let now = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(5); + self.notifications.retain(|n| { + if let Some(done_at) = n.done_at { + now.duration_since(done_at) < timeout + } else { + true // in-progress notifications never auto-dismiss + } + }); + } + + /// Returns true if there are any active (in-progress) notifications. + pub fn has_active_notifications(&self) -> bool { + self.notifications.iter().any(|n| !n.done) + } + + /// Returns true if there are any completed notifications waiting to be dismissed. + pub fn has_done_notifications(&self) -> bool { + self.notifications.iter().any(|n| n.done) + } } impl Default for Engine { diff --git a/src/core/engine/motions.rs b/src/core/engine/motions.rs index b0388199..4beef90d 100644 --- a/src/core/engine/motions.rs +++ b/src/core/engine/motions.rs @@ -785,6 +785,44 @@ impl Engine { } } + /// Populate highlight query overrides from installed extension manifests, + /// then re-apply to any already-open buffers whose language matches. + pub(crate) fn populate_highlight_overrides(&mut self) { + for manifest in self.ext_available_manifests() { + if !self.extension_state.is_installed(&manifest.name) { + continue; + } + if let Some(ref hl) = manifest.highlights { + if hl.is_empty() { + continue; + } + for lang_id in &manifest.language_ids { + self.highlight_overrides + .entry(lang_id.clone()) + .or_insert_with(|| hl.clone()); + } + } + } + // Re-apply to open buffers so files opened before extensions loaded + // pick up the override queries. + if !self.highlight_overrides.is_empty() { + let ids: Vec<_> = self.buffer_manager.list().to_vec(); + for bid in ids { + if let Some(state) = self.buffer_manager.get_mut(bid) { + if let Some(ref path) = state.file_path.clone() { + if let Some(syn) = crate::core::syntax::Syntax::new_from_path_with_overrides( + path.to_str(), + Some(&self.highlight_overrides), + ) { + state.syntax = Some(syn); + state.update_syntax(); + } + } + } + } + } + } + pub(crate) fn format_lines(&mut self, start_line: usize, end_line: usize, changed: &mut bool) { let total = self.buffer().len_lines(); let start = start_line.min(total.saturating_sub(1)); diff --git a/src/core/engine/panels.rs b/src/core/engine/panels.rs index 6264929d..394ab295 100644 --- a/src/core/engine/panels.rs +++ b/src/core/engine/panels.rs @@ -568,6 +568,29 @@ impl Engine { self.lsp_dirty_buffers.remove(&buffer_id); } + /// Re-filter all stored diagnostics using the current extension manifests' + /// `ignore_error_sources`. Called when the registry updates (the initial + /// cache may lack the field, so diagnostics stored before the fetch need + /// retroactive filtering). + pub(crate) fn refilter_diagnostics(&mut self) { + let ignored: HashSet = self + .ext_available_manifests() + .iter() + .flat_map(|m| m.lsp.ignore_error_sources.clone()) + .collect(); + if ignored.is_empty() { + return; + } + for diagnostics in self.lsp_diagnostics.values_mut() { + diagnostics.retain(|d| { + if d.severity != DiagnosticSeverity::Error { + return true; + } + !d.source.as_deref().is_some_and(|s| ignored.contains(s)) + }); + } + } + /// Notify LSP that a file was closed. pub(crate) fn lsp_did_close(&mut self, buffer_id: BufferId) { let path = self @@ -655,10 +678,26 @@ impl Engine { }) .collect(); + // Pre-compute ignored diagnostic error sources (once per poll batch). + // Extensions can declare sources whose error-severity diagnostics should + // be suppressed (e.g. "rust-analyzer" — its native type-check produces + // false positives; real errors come from "rustc" via cargo check). + let ignored_error_sources: HashSet = self + .ext_available_manifests() + .iter() + .flat_map(|m| m.lsp.ignore_error_sources.clone()) + .collect(); + let mut redraw = false; for event in events { match event { - LspEvent::Initialized(..) => { + LspEvent::Initialized(server_id, ..) => { + // Mark server as responsive — initialization handshake is + // sufficient proof for servers that don't support semantic + // tokens (e.g. marksman). + if let Some(mgr) = self.lsp_manager.as_mut() { + mgr.mark_server_responded(server_id); + } // Server is ready — re-open any already-open buffers let buffers: Vec<(PathBuf, String)> = self .buffer_manager @@ -691,7 +730,25 @@ impl Engine { if !redraw && visible_paths.contains(&path) { redraw = true; } - self.lsp_diagnostics.insert(path, diagnostics); + // Filter out error-severity diagnostics from ignored sources + // (e.g. rust-analyzer native errors — real errors come from rustc). + let filtered = if ignored_error_sources.is_empty() { + diagnostics + } else { + diagnostics + .into_iter() + .filter(|d| { + if d.severity != DiagnosticSeverity::Error { + return true; // keep non-errors from all sources + } + // Drop errors from ignored sources + !d.source + .as_deref() + .is_some_and(|s| ignored_error_sources.contains(s)) + }) + .collect() + }; + self.lsp_diagnostics.insert(path, filtered); } LspEvent::CompletionResponse { request_id, items, .. @@ -723,7 +780,14 @@ impl Engine { } // else: stale response (request already superseded) — ignore } - LspEvent::DefinitionResponse { locations, .. } => { + LspEvent::DefinitionResponse { + server_id, + locations, + .. + } => { + if let Some(mgr) = self.lsp_manager.as_mut() { + mgr.mark_server_responded(server_id); + } self.lsp_pending_definition = None; self.message.clear(); if let Some(loc) = locations.first() { @@ -750,7 +814,14 @@ impl Engine { self.message = "No definition found".to_string(); } } - LspEvent::HoverResponse { contents, .. } => { + LspEvent::HoverResponse { + server_id, + contents, + .. + } => { + if let Some(mgr) = self.lsp_manager.as_mut() { + mgr.mark_server_responded(server_id); + } self.lsp_pending_hover = None; // Treat empty/whitespace-only hover as "no hover". let text = contents.filter(|t| !t.trim().is_empty()); @@ -829,6 +900,15 @@ impl Engine { output, } => { self.lsp_installing.remove(&lang_id); + // Mark any matching LSP install notification as done. + self.notify_done_by_kind( + &NotificationKind::LspInstall, + if success { + Some("Install complete") + } else { + Some("Install failed") + }, + ); // preLaunchTask completion: resume debug session after build task. if let Some(task_label) = lang_id.strip_prefix("dap_task:") { // Append task output to Debug Output panel. @@ -900,6 +980,7 @@ impl Engine { command: binary.clone(), args: manifest_args.clone(), languages: vec![lsp_lang.clone()], + ..Default::default() }; if let Some(mgr) = &mut self.lsp_manager { mgr.add_registry_entry(config); diff --git a/src/core/engine/picker.rs b/src/core/engine/picker.rs index 97786b5a..258a46bc 100644 --- a/src/core/engine/picker.rs +++ b/src/core/engine/picker.rs @@ -86,6 +86,8 @@ impl Engine { self.picker_items.clear(); self.picker_preview = None; self.breadcrumb_scoped_parent = None; + self.picker_history_index = None; + self.picker_history_typing_buffer.clear(); match source { PickerSource::Files => { @@ -117,6 +119,18 @@ impl Engine { self.picker_title = "Switch Branch".to_string(); self.picker_populate_branches(); } + PickerSource::Languages => { + self.picker_title = "Select Language Mode".to_string(); + self.picker_populate_languages(); + } + PickerSource::Indentation => { + self.picker_title = "Select Indentation".to_string(); + self.picker_populate_indentation(); + } + PickerSource::LineEndings => { + self.picker_title = "Select Line Ending Sequence".to_string(); + self.picker_populate_line_endings(); + } _ => { self.picker_title = format!("{:?}", source); } @@ -564,6 +578,110 @@ impl Engine { .collect(); } + fn picker_populate_languages(&mut self) { + let current = self + .buffer_manager + .get(self.active_buffer_id()) + .and_then(|s| s.lsp_language_id.as_deref()) + .unwrap_or(""); + self.picker_all_items = crate::core::lsp::all_known_language_ids() + .into_iter() + .map(|lang| { + let detail = if lang == current { + Some("● current".to_string()) + } else { + None + }; + PickerItem { + display: lang.to_string(), + filter_text: lang.to_string(), + detail, + action: PickerAction::SetLanguage(lang.to_string()), + icon: None, + score: 0, + match_positions: Vec::new(), + depth: 0, + expandable: false, + expanded: false, + } + }) + .collect(); + } + + fn picker_populate_indentation(&mut self) { + let et = self.settings.expand_tab; + let ts = self.settings.tabstop; + let items = [ + ("Spaces: 2", true, 2u8), + ("Spaces: 4", true, 4), + ("Spaces: 8", true, 8), + ("Tabs (width 2)", false, 2), + ("Tabs (width 4)", false, 4), + ("Tabs (width 8)", false, 8), + ]; + self.picker_all_items = items + .iter() + .map(|(label, expand, width)| { + let is_current = *expand == et && *width == ts; + PickerItem { + display: label.to_string(), + filter_text: label.to_string(), + detail: if is_current { + Some("● current".to_string()) + } else { + None + }, + action: PickerAction::SetIndentation(*expand, *width), + icon: None, + score: 0, + match_positions: Vec::new(), + depth: 0, + expandable: false, + expanded: false, + } + }) + .collect(); + } + + fn picker_populate_line_endings(&mut self) { + use crate::core::buffer_manager::LineEnding; + let current = self + .buffer_manager + .get(self.active_buffer_id()) + .map(|s| s.line_ending) + .unwrap_or(LineEnding::LF); + let items = [ + ("LF", false), // is_crlf = false + ("CRLF", true), // is_crlf = true + ]; + self.picker_all_items = items + .iter() + .map(|(label, is_crlf)| { + let le = if *is_crlf { + LineEnding::Crlf + } else { + LineEnding::LF + }; + PickerItem { + display: label.to_string(), + filter_text: label.to_string(), + detail: if le == current { + Some("● current".to_string()) + } else { + None + }, + action: PickerAction::SetLineEnding(*is_crlf), + icon: None, + score: 0, + match_positions: Vec::new(), + depth: 0, + expandable: false, + expanded: false, + } + }) + .collect(); + } + /// Filter picker_all_items by the current query and populate picker_items. /// For live sources (Grep), runs a search instead of fuzzy-filtering. /// For CommandCenter, delegates to prefix-aware routing. @@ -1535,6 +1653,7 @@ impl Engine { /// Execute the currently selected picker item. pub fn picker_confirm(&mut self) -> EngineAction { + self.picker_push_history(); let Some(item) = self.picker_items.get(self.picker_selected).cloned() else { self.close_picker(); return EngineAction::None; @@ -1651,6 +1770,47 @@ impl Engine { PickerAction::CheckoutBranch(branch) => { self.execute_command(&format!("Gswitch {}", branch)) } + PickerAction::SetLanguage(lang) => { + // Set the language ID on the active buffer and re-run syntax + let bid = self.active_buffer_id(); + if let Some(state) = self.buffer_manager.get_mut(bid) { + state.lsp_language_id = Some(lang.clone()); + // Update syntax parser for the new language + state.syntax = crate::core::syntax::Syntax::new_from_language_id_with_overrides( + &lang, + Some(&self.highlight_overrides), + ); + state.update_syntax(); + } + self.message = format!("Language mode: {}", lang); + EngineAction::None + } + PickerAction::SetIndentation(expand, width) => { + self.settings.expand_tab = expand; + self.settings.tabstop = width; + self.settings.shift_width = width; + let _ = self.settings.save(); + self.message = if expand { + format!("Spaces: {}", width) + } else { + format!("Tab Size: {}", width) + }; + EngineAction::None + } + PickerAction::SetLineEnding(is_crlf) => { + use crate::core::buffer_manager::LineEnding; + let new = if is_crlf { + LineEnding::Crlf + } else { + LineEnding::LF + }; + let bid = self.active_buffer_id(); + if let Some(state) = self.buffer_manager.get_mut(bid) { + state.set_line_ending(new); + } + self.message = format!("Line endings: {}", new.as_str()); + EngineAction::None + } PickerAction::JumpToMark(_mark) => { // Phase 3: mark jumping via picker EngineAction::None @@ -1755,6 +1915,31 @@ impl Engine { } } + /// Save the current picker query to per-source history (dedup consecutive). + fn picker_push_history(&mut self) { + let q = self.picker_query.trim().to_string(); + if q.is_empty() { + return; + } + let hist = self + .picker_history + .entry(self.picker_source.clone()) + .or_default(); + if hist.last().is_none_or(|last| *last != q) { + hist.push(q); + // Cap at 100 entries. + if hist.len() > 100 { + hist.remove(0); + } + } + } + + /// Exit history browsing mode, resetting the index. + fn picker_exit_history(&mut self) { + self.picker_history_index = None; + self.picker_history_typing_buffer.clear(); + } + /// Route a key press when the unified picker is open. pub fn handle_picker_key( &mut self, @@ -1810,16 +1995,63 @@ impl Engine { EngineAction::None } "Down" | "Tab" => { - let max = self.picker_items.len().saturating_sub(1); - self.picker_selected = (self.picker_selected + 1).min(max); - self.picker_update_scroll(); - self.picker_load_preview(); + if self.picker_history_index.is_some() { + // Navigate forward in history or exit history mode. + let hist = self + .picker_history + .get(&self.picker_source) + .cloned() + .unwrap_or_default(); + let idx = self.picker_history_index.unwrap(); + if idx + 1 < hist.len() { + self.picker_history_index = Some(idx + 1); + self.picker_query = hist[idx + 1].clone(); + } else { + // Past newest entry — restore the original typed query. + self.picker_query = std::mem::take(&mut self.picker_history_typing_buffer); + self.picker_history_index = None; + } + self.picker_selected = 0; + self.picker_scroll_top = 0; + self.picker_filter(); + self.picker_load_preview(); + } else { + let max = self.picker_items.len().saturating_sub(1); + self.picker_selected = (self.picker_selected + 1).min(max); + self.picker_update_scroll(); + self.picker_load_preview(); + } EngineAction::None } "Up" => { - self.picker_selected = self.picker_selected.saturating_sub(1); - self.picker_update_scroll(); - self.picker_load_preview(); + if self.picker_selected == 0 { + // At top of results — enter or continue history browsing. + let hist_len = self + .picker_history + .get(&self.picker_source) + .map_or(0, |h| h.len()); + if hist_len > 0 { + let hist = &self.picker_history[&self.picker_source]; + let new_idx = match self.picker_history_index { + None => { + // Enter history mode — save current query. + self.picker_history_typing_buffer = self.picker_query.clone(); + hist_len - 1 + } + Some(idx) => idx.saturating_sub(1), + }; + self.picker_history_index = Some(new_idx); + self.picker_query = hist[new_idx].clone(); + self.picker_selected = 0; + self.picker_scroll_top = 0; + self.picker_filter(); + self.picker_load_preview(); + } + } else { + self.picker_selected = self.picker_selected.saturating_sub(1); + self.picker_update_scroll(); + self.picker_load_preview(); + } EngineAction::None } "n" if ctrl => { @@ -1845,6 +2077,7 @@ impl Engine { self.picker_query.push(c); } } + self.picker_exit_history(); self.picker_selected = 0; self.picker_scroll_top = 0; self.picker_filter(); @@ -1853,6 +2086,7 @@ impl Engine { EngineAction::None } "BackSpace" => { + self.picker_exit_history(); self.picker_query.pop(); self.picker_selected = 0; self.picker_scroll_top = 0; @@ -1864,6 +2098,7 @@ impl Engine { if !ctrl { if let Some(c) = unicode { if !c.is_control() { + self.picker_exit_history(); self.picker_query.push(c); self.picker_selected = 0; self.picker_scroll_top = 0; diff --git a/src/core/engine/plugins.rs b/src/core/engine/plugins.rs index 3a673d27..da57bb8e 100644 --- a/src/core/engine/plugins.rs +++ b/src/core/engine/plugins.rs @@ -78,8 +78,9 @@ impl Engine { for name in &installed_names { self.load_ext_settings(name); } - // Populate comment style overrides from installed extension manifests + // Populate comment style and highlight query overrides from installed extensions self.populate_comment_overrides(); + self.populate_highlight_overrides(); // Fire VimEnter event after plugin initialization is complete self.plugin_event("VimEnter", ""); } @@ -424,7 +425,10 @@ impl Engine { other => other, }; let fake_path = format!("scratch.{ext}"); - if let Some(syn) = Syntax::new_from_path(Some(&fake_path)) { + if let Some(syn) = Syntax::new_from_path_with_overrides( + Some(&fake_path), + Some(&self.highlight_overrides), + ) { state.syntax = Some(syn); state.update_syntax(); } diff --git a/src/core/engine/search.rs b/src/core/engine/search.rs index e61b09ce..492b54c3 100644 --- a/src/core/engine/search.rs +++ b/src/core/engine/search.rs @@ -307,6 +307,10 @@ impl Engine { return; } self.project_search_running = true; + self.notify( + NotificationKind::ProjectSearch, + &format!("Searching for \"{query}\"…"), + ); self.message = format!("Searching for \"{}\"…", query); let opts = self.project_search_options.clone(); let (tx, rx) = std::sync::mpsc::channel(); @@ -331,6 +335,7 @@ impl Engine { let query = self.project_search_query.clone(); self.project_search_receiver = None; self.project_search_running = false; + self.notify_done_by_kind(&NotificationKind::ProjectSearch, Some("Search complete")); match result { Ok(results) => self.apply_search_results(results, &query), Err(e) => { @@ -447,6 +452,10 @@ impl Engine { return; } self.project_replace_running = true; + self.notify( + NotificationKind::ProjectReplace, + &format!("Replacing \"{query}\" → \"{replacement}\"…"), + ); self.message = format!("Replacing \"{}\" → \"{}\"…", query, replacement); let opts = self.project_search_options.clone(); let skip = self.dirty_buffer_paths(); @@ -472,6 +481,7 @@ impl Engine { }; self.project_replace_receiver = None; self.project_replace_running = false; + self.notify_done_by_kind(&NotificationKind::ProjectReplace, Some("Replace complete")); match result { Ok(rr) => self.apply_replace_result(rr), Err(e) => { diff --git a/src/core/engine/terminal_ops.rs b/src/core/engine/terminal_ops.rs index 1dcf3fb9..e4994ac1 100644 --- a/src/core/engine/terminal_ops.rs +++ b/src/core/engine/terminal_ops.rs @@ -186,11 +186,21 @@ impl Engine { /// - If open and focused → close (hide) /// - If open but unfocused → give focus /// - If not open → signal UI to open (UI calls terminal_new_tab with correct dimensions) + /// + /// Also closes the debug output bottom panel if it is the only thing keeping + /// the bottom panel visible (no terminal running). pub fn toggle_terminal(&mut self) { if self.terminal_open && self.terminal_has_focus { self.close_terminal(); + // Also close debug output panel if no terminal remains + if self.bottom_panel_open && !self.terminal_open { + self.bottom_panel_open = false; + } } else if self.terminal_open { self.terminal_has_focus = true; + } else if self.bottom_panel_open { + // No terminal but debug output panel is open — close it + self.bottom_panel_open = false; } else { // Signal UI to call terminal_new_tab with correct dimensions self.terminal_open = true; @@ -268,6 +278,7 @@ impl Engine { command: bin.to_string(), args: manifest.lsp.args.clone(), languages: vec![lsp_lang.clone()], + ..Default::default() }; if let Some(mgr) = &mut self.lsp_manager { mgr.add_registry_entry(config); diff --git a/src/core/engine/tests.rs b/src/core/engine/tests.rs index 1605325e..693ebcb9 100644 --- a/src/core/engine/tests.rs +++ b/src/core/engine/tests.rs @@ -9124,6 +9124,137 @@ fn test_picker_palette_command_opens_picker() { assert_eq!(engine.picker_source, PickerSource::Commands); } +// ─── Picker search history tests ───────────────────────────────────────── + +#[test] +fn test_picker_history_saved_on_confirm() { + let mut e = Engine::new(); + // Open picker, type a query, confirm. + e.open_picker(PickerSource::Grep); + e.handle_picker_key("", Some('f'), false); + e.handle_picker_key("", Some('o'), false); + e.handle_picker_key("", Some('o'), false); + e.handle_picker_key("Return", None, false); + assert!(!e.picker_open); + let hist = e.picker_history.get(&PickerSource::Grep).unwrap(); + assert_eq!(hist, &["foo"]); +} + +#[test] +fn test_picker_history_no_duplicate_consecutive() { + let mut e = Engine::new(); + // Confirm same query twice — should only appear once. + for _ in 0..2 { + e.open_picker(PickerSource::Grep); + for c in "foo".chars() { + e.handle_picker_key("", Some(c), false); + } + e.handle_picker_key("Return", None, false); + } + let hist = e.picker_history.get(&PickerSource::Grep).unwrap(); + assert_eq!(hist, &["foo"]); +} + +#[test] +fn test_picker_history_per_source() { + let mut e = Engine::new(); + // Add history for Grep. + e.open_picker(PickerSource::Grep); + for c in "grep_q".chars() { + e.handle_picker_key("", Some(c), false); + } + e.handle_picker_key("Return", None, false); + // Add history for Files. + e.open_picker(PickerSource::Files); + for c in "file_q".chars() { + e.handle_picker_key("", Some(c), false); + } + e.handle_picker_key("Return", None, false); + // Each source has its own history. + assert_eq!( + e.picker_history.get(&PickerSource::Grep).unwrap(), + &["grep_q"] + ); + assert_eq!( + e.picker_history.get(&PickerSource::Files).unwrap(), + &["file_q"] + ); +} + +#[test] +fn test_picker_history_up_recalls_at_top() { + let mut e = Engine::new(); + // Build some history. + for q in &["alpha", "beta"] { + e.open_picker(PickerSource::Grep); + for c in q.chars() { + e.handle_picker_key("", Some(c), false); + } + e.handle_picker_key("Return", None, false); + } + // Open fresh picker, press Up at top to recall history. + e.open_picker(PickerSource::Grep); + assert_eq!(e.picker_query, ""); + e.handle_picker_key("Up", None, false); + assert_eq!(e.picker_query, "beta"); // most recent + e.handle_picker_key("Up", None, false); + assert_eq!(e.picker_query, "alpha"); // older + // Up at oldest stays at oldest. + e.handle_picker_key("Up", None, false); + assert_eq!(e.picker_query, "alpha"); +} + +#[test] +fn test_picker_history_down_restores_typing() { + let mut e = Engine::new(); + // Build history. + e.open_picker(PickerSource::Grep); + for c in "old".chars() { + e.handle_picker_key("", Some(c), false); + } + e.handle_picker_key("Return", None, false); + // Open picker, type something, then browse history and come back. + e.open_picker(PickerSource::Grep); + for c in "new".chars() { + e.handle_picker_key("", Some(c), false); + } + // Move down to selected=0 first (results have items since "new" matches nothing in grep). + // Actually picker_selected starts at 0, so Up enters history. + e.handle_picker_key("Up", None, false); + assert_eq!(e.picker_query, "old"); + // Down past newest restores the typed query. + e.handle_picker_key("Down", None, false); + assert_eq!(e.picker_query, "new"); + assert!(e.picker_history_index.is_none()); +} + +#[test] +fn test_picker_history_typing_exits_history_mode() { + let mut e = Engine::new(); + e.open_picker(PickerSource::Grep); + for c in "old".chars() { + e.handle_picker_key("", Some(c), false); + } + e.handle_picker_key("Return", None, false); + // Browse history, then type — should exit history mode. + e.open_picker(PickerSource::Grep); + e.handle_picker_key("Up", None, false); + assert_eq!(e.picker_query, "old"); + assert!(e.picker_history_index.is_some()); + e.handle_picker_key("", Some('x'), false); + assert!(e.picker_history_index.is_none()); + assert_eq!(e.picker_query, "oldx"); +} + +#[test] +fn test_picker_history_empty_query_not_saved() { + let mut e = Engine::new(); + e.open_picker(PickerSource::Grep); + // Confirm with empty query — nothing saved. + e.handle_picker_key("Return", None, false); + assert!(e.picker_history.get(&PickerSource::Grep).is_none()); +} + // ─── Command Center tests ──────────────────────────────────────────────── #[test] @@ -14265,6 +14396,17 @@ fn test_spell_toggle_via_palette_action() { assert!(e.spell_checker.is_some()); } +#[test] +fn test_spell_set_spell_command_initializes_checker() { + let mut e = engine_with_text("the quik fox"); + assert!(!e.settings.spell); + assert!(e.spell_checker.is_none()); + // :set spell should lazy-init the checker + e.execute_command("set spell"); + assert!(e.settings.spell); + assert!(e.spell_checker.is_some()); +} + // ── LaTeX text objects and motions ──────────────────────────────────────── fn latex_engine(text: &str) -> Engine { @@ -17869,3 +18011,394 @@ fn test_symbol_tree_synthetic_container() { .expect("Should have method_a"); assert_eq!(method_a.depth, 1); } + +// ── Editor action menu tests ────────────────────────────────────────────────── + +#[test] +fn test_editor_action_menu_opens() { + let mut e = Engine::new(); + e.buffer_mut().insert(0, "hello\n"); + let gid = e.active_group; + e.open_editor_action_menu(gid, 0, 0); + assert!(e.context_menu.is_some()); + let cm = e.context_menu.as_ref().unwrap(); + assert!(matches!( + cm.target, + ContextMenuTarget::EditorActionMenu { .. } + )); + // Should have 8 items. + assert_eq!(cm.items.len(), 8); + assert_eq!(cm.items[0].action, "close_all"); + assert_eq!(cm.items[1].action, "close_others"); + assert_eq!(cm.items[5].action, "toggle_wrap"); + assert_eq!(cm.items[6].action, "change_language"); + assert_eq!(cm.items[7].action, "reveal"); +} + +#[test] +fn test_editor_action_menu_close_others_disabled_with_one_tab() { + let mut e = Engine::new(); + e.buffer_mut().insert(0, "hello\n"); + let gid = e.active_group; + e.open_editor_action_menu(gid, 0, 0); + let cm = e.context_menu.as_ref().unwrap(); + // "Close Others" disabled with only 1 tab. + let close_others = cm + .items + .iter() + .find(|i| i.action == "close_others") + .unwrap(); + assert!(!close_others.enabled); + // "Close to the Left" disabled when active_tab == 0. + let close_left = cm.items.iter().find(|i| i.action == "close_left").unwrap(); + assert!(!close_left.enabled); +} + +#[test] +fn test_editor_action_menu_toggle_wrap() { + let mut e = Engine::new(); + e.buffer_mut().insert(0, "hello\n"); + let gid = e.active_group; + assert!(!e.settings.wrap); + e.open_editor_action_menu(gid, 0, 0); + // Select "toggle_wrap" and confirm. + if let Some(ref mut cm) = e.context_menu { + if let Some(idx) = cm.items.iter().position(|i| i.action == "toggle_wrap") { + cm.selected = idx; + } + } + e.context_menu_confirm(); + assert!(e.settings.wrap); + assert!(e.message.contains("on")); +} + +#[test] +fn test_editor_action_menu_close_all() { + let mut e = Engine::new(); + e.buffer_mut().insert(0, "hello\n"); + // Open a second tab. + e.new_tab(None); + assert!(e.active_group().tabs.len() >= 2); + let gid = e.active_group; + e.open_editor_action_menu(gid, 0, 0); + if let Some(ref mut cm) = e.context_menu { + if let Some(idx) = cm.items.iter().position(|i| i.action == "close_all") { + cm.selected = idx; + } + } + e.context_menu_confirm(); + // After closing all, should have 1 scratch tab (close_tab creates one). + assert_eq!(e.active_group().tabs.len(), 1); +} + +#[test] +fn test_close_all_tabs_method() { + let mut e = Engine::new(); + e.new_tab(None); + e.new_tab(None); + assert_eq!(e.active_group().tabs.len(), 3); + e.close_all_tabs(); + // close_tab always leaves at least 1 scratch buffer. + assert_eq!(e.active_group().tabs.len(), 1); +} + +// ── Explorer inline rename improvements ────────────────────────────────────── + +#[test] +fn test_explorer_rename_preselects_stem() { + let mut e = Engine::new(); + e.start_explorer_rename(PathBuf::from("/tmp/hello.rs")); + let rename = e.explorer_rename.as_ref().unwrap(); + assert_eq!(rename.input, "hello.rs"); + // Stem "hello" is selected (anchor=0, cursor=5) + assert_eq!(rename.selection_anchor, Some(0)); + assert_eq!(rename.cursor, 5); +} + +#[test] +fn test_explorer_rename_preselects_dotfile_fully() { + let mut e = Engine::new(); + e.start_explorer_rename(PathBuf::from("/tmp/.gitignore")); + let rename = e.explorer_rename.as_ref().unwrap(); + assert_eq!(rename.input, ".gitignore"); + // Dotfiles: '.' at pos 0, so rfind('.') returns 0 which is filtered out + // → selects entire name + assert_eq!(rename.selection_anchor, Some(0)); + assert_eq!(rename.cursor, ".gitignore".len()); +} + +#[test] +fn test_explorer_rename_preselects_no_extension() { + let mut e = Engine::new(); + e.start_explorer_rename(PathBuf::from("/tmp/Makefile")); + let rename = e.explorer_rename.as_ref().unwrap(); + assert_eq!(rename.input, "Makefile"); + // No extension → selects entire name + assert_eq!(rename.selection_anchor, Some(0)); + assert_eq!(rename.cursor, "Makefile".len()); +} + +#[test] +fn test_explorer_rename_backspace_deletes_selection() { + let mut e = Engine::new(); + e.start_explorer_rename(PathBuf::from("/tmp/hello.rs")); + // Pre-selected: "hello" (anchor=0, cursor=5) + e.handle_explorer_rename_key("BackSpace", None, false); + let rename = e.explorer_rename.as_ref().unwrap(); + // Selection deleted, only ".rs" remains + assert_eq!(rename.input, ".rs"); + assert_eq!(rename.cursor, 0); + assert_eq!(rename.selection_anchor, None); +} + +#[test] +fn test_explorer_rename_typing_replaces_selection() { + let mut e = Engine::new(); + e.start_explorer_rename(PathBuf::from("/tmp/hello.rs")); + // Type 'w' — should replace the selected "hello" with "w" + e.handle_explorer_rename_key("w", Some('w'), false); + let rename = e.explorer_rename.as_ref().unwrap(); + assert_eq!(rename.input, "w.rs"); + assert_eq!(rename.cursor, 1); + assert_eq!(rename.selection_anchor, None); +} + +#[test] +fn test_explorer_rename_escape_cancels_immediately() { + let mut e = Engine::new(); + e.start_explorer_rename(PathBuf::from("/tmp/hello.rs")); + // Escape cancels rename immediately, even with active selection + assert!(e + .explorer_rename + .as_ref() + .unwrap() + .selection_anchor + .is_some()); + e.handle_explorer_rename_key("Escape", None, false); + assert!(e.explorer_rename.is_none(), "Escape should cancel rename"); +} + +#[test] +fn test_explorer_rename_ctrl_a_selects_all() { + let mut e = Engine::new(); + e.start_explorer_rename(PathBuf::from("/tmp/hello.rs")); + // Clear initial selection first + e.handle_explorer_rename_key("Right", None, false); + // Ctrl-A selects all + e.handle_explorer_rename_key("a", Some('a'), true); + let rename = e.explorer_rename.as_ref().unwrap(); + assert_eq!(rename.selection_anchor, Some(0)); + assert_eq!(rename.cursor, "hello.rs".len()); +} + +#[test] +fn test_explorer_rename_ctrl_v_paste() { + use std::sync::{Arc, Mutex}; + let mut e = Engine::new(); + let clipboard_content = Arc::new(Mutex::new("pasted_name".to_string())); + let cc = clipboard_content.clone(); + e.clipboard_read = Some(Box::new(move || Ok(cc.lock().unwrap().clone()))); + + e.start_explorer_rename(PathBuf::from("/tmp/hello.rs")); + // Selection is "hello" (anchor=0, cursor=5). Ctrl-V should replace it. + e.handle_explorer_rename_key("v", Some('v'), true); + let rename = e.explorer_rename.as_ref().unwrap(); + assert_eq!(rename.input, "pasted_name.rs"); +} + +#[test] +fn test_explorer_rename_ctrl_c_copies_selection() { + use std::sync::{Arc, Mutex}; + let mut e = Engine::new(); + let clipboard_content = Arc::new(Mutex::new(String::new())); + let cc = clipboard_content.clone(); + e.clipboard_write = Some(Box::new(move |text: &str| { + *cc.lock().unwrap() = text.to_string(); + Ok(()) + })); + + e.start_explorer_rename(PathBuf::from("/tmp/hello.rs")); + // Selection is "hello". Ctrl-C should copy it. + e.handle_explorer_rename_key("c", Some('c'), true); + assert_eq!(*clipboard_content.lock().unwrap(), "hello"); + // Input unchanged + let rename = e.explorer_rename.as_ref().unwrap(); + assert_eq!(rename.input, "hello.rs"); +} + +#[test] +fn test_explorer_rename_ctrl_x_cuts_selection() { + use std::sync::{Arc, Mutex}; + let mut e = Engine::new(); + let clipboard_content = Arc::new(Mutex::new(String::new())); + let cc = clipboard_content.clone(); + e.clipboard_write = Some(Box::new(move |text: &str| { + *cc.lock().unwrap() = text.to_string(); + Ok(()) + })); + + e.start_explorer_rename(PathBuf::from("/tmp/hello.rs")); + // Selection is "hello". Ctrl-X should cut it. + e.handle_explorer_rename_key("x", Some('x'), true); + assert_eq!(*clipboard_content.lock().unwrap(), "hello"); + let rename = e.explorer_rename.as_ref().unwrap(); + assert_eq!(rename.input, ".rs"); + assert_eq!(rename.cursor, 0); +} + +// ─── Layout toggle button tests ───────────────────────────────────────────── + +#[test] +fn test_status_action_toggle_panel() { + let mut e = Engine::new(); + assert!(!e.terminal_open); + // When no terminal panes exist, returns OpenTerminal for the backend to create a PTY + let result = e.handle_status_action(&StatusAction::TogglePanel); + assert_eq!(result, Some(EngineAction::OpenTerminal)); +} + +#[test] +fn test_status_action_toggle_menu_bar() { + let mut e = Engine::new(); + assert!(!e.menu_bar_visible); + let result = e.handle_status_action(&StatusAction::ToggleMenuBar); + assert!(result.is_none()); + assert!(e.menu_bar_visible); + // Toggle again + let result = e.handle_status_action(&StatusAction::ToggleMenuBar); + assert!(result.is_none()); + assert!(!e.menu_bar_visible); +} + +#[test] +fn test_status_action_toggle_sidebar_returns_engine_action() { + let mut e = Engine::new(); + let result = e.handle_status_action(&StatusAction::ToggleSidebar); + assert_eq!(result, Some(EngineAction::ToggleSidebar)); +} + +#[test] +fn test_status_action_existing_actions_return_none() { + let mut e = Engine::new(); + // Existing actions should return None (handled internally by engine) + assert!(e.handle_status_action(&StatusAction::GoToLine).is_none()); + assert!(e + .handle_status_action(&StatusAction::ChangeLanguage) + .is_none()); + assert!(e + .handle_status_action(&StatusAction::ChangeEncoding) + .is_none()); +} + +// ── Notification system tests ─────────────────────────────────────────── + +#[test] +fn test_notify_creates_notification() { + let mut e = Engine::new(); + assert!(e.notifications.is_empty()); + let id = e.notify(NotificationKind::ProjectSearch, "Searching…"); + assert_eq!(e.notifications.len(), 1); + assert_eq!(e.notifications[0].id, id); + assert_eq!(e.notifications[0].message, "Searching…"); + assert!(!e.notifications[0].done); + assert!(e.has_active_notifications()); + assert!(!e.has_done_notifications()); +} + +#[test] +fn test_notify_done_marks_complete() { + let mut e = Engine::new(); + let id = e.notify(NotificationKind::ProjectSearch, "Searching…"); + e.notify_done(id, Some("Done!")); + assert_eq!(e.notifications.len(), 1); + assert!(e.notifications[0].done); + assert_eq!(e.notifications[0].message, "Done!"); + assert!(!e.has_active_notifications()); + assert!(e.has_done_notifications()); +} + +#[test] +fn test_notify_done_by_kind() { + let mut e = Engine::new(); + e.notify(NotificationKind::ProjectSearch, "Search 1…"); + e.notify(NotificationKind::ProjectSearch, "Search 2…"); + e.notify(NotificationKind::LspInstall, "Installing…"); + e.notify_done_by_kind(&NotificationKind::ProjectSearch, Some("Search complete")); + // Both ProjectSearch notifications should be done, LspInstall still active + let search_done = e + .notifications + .iter() + .filter(|n| n.kind == NotificationKind::ProjectSearch && n.done) + .count(); + let lsp_active = e + .notifications + .iter() + .filter(|n| n.kind == NotificationKind::LspInstall && !n.done) + .count(); + assert_eq!(search_done, 2); + assert_eq!(lsp_active, 1); +} + +#[test] +fn test_dismiss_notification_by_id() { + let mut e = Engine::new(); + let id1 = e.notify(NotificationKind::ProjectSearch, "A"); + let _id2 = e.notify(NotificationKind::LspInstall, "B"); + assert_eq!(e.notifications.len(), 2); + e.dismiss_notification(id1); + assert_eq!(e.notifications.len(), 1); + assert_eq!(e.notifications[0].message, "B"); +} + +#[test] +fn test_dismiss_done_notifications() { + let mut e = Engine::new(); + let id1 = e.notify(NotificationKind::ProjectSearch, "A"); + let _id2 = e.notify(NotificationKind::LspInstall, "B"); + e.notify_done(id1, None); + e.dismiss_done_notifications(); + assert_eq!(e.notifications.len(), 1); + assert_eq!(e.notifications[0].message, "B"); + assert!(!e.notifications[0].done); +} + +#[test] +fn test_tick_notifications_auto_dismiss() { + let mut e = Engine::new(); + let id = e.notify(NotificationKind::ProjectSearch, "Searching…"); + e.notify_done(id, Some("Done")); + // Manually backdate the done_at to simulate time passing + e.notifications[0].done_at = + Some(std::time::Instant::now() - std::time::Duration::from_secs(10)); + e.tick_notifications(); + assert!(e.notifications.is_empty(), "Should auto-dismiss after 5s"); +} + +#[test] +fn test_tick_notifications_keeps_active() { + let mut e = Engine::new(); + e.notify(NotificationKind::ProjectSearch, "Searching…"); + e.tick_notifications(); + assert_eq!( + e.notifications.len(), + 1, + "In-progress notifications should not be auto-dismissed" + ); +} + +#[test] +fn test_status_action_dismiss_notifications() { + let mut e = Engine::new(); + let id = e.notify(NotificationKind::ProjectSearch, "Done"); + e.notify_done(id, None); + e.handle_status_action(&StatusAction::DismissNotifications); + assert!(e.notifications.is_empty()); +} + +#[test] +fn test_notification_ids_increment() { + let mut e = Engine::new(); + let id1 = e.notify(NotificationKind::ProjectSearch, "A"); + let id2 = e.notify(NotificationKind::LspInstall, "B"); + assert!(id2 > id1); +} diff --git a/src/core/engine/windows.rs b/src/core/engine/windows.rs index a599a57c..d20590ab 100644 --- a/src/core/engine/windows.rs +++ b/src/core/engine/windows.rs @@ -679,6 +679,99 @@ impl Engine { }); } + /// Open the editor action menu ("..." button) for a tab bar group. + pub fn open_editor_action_menu(&mut self, group_id: GroupId, x: u16, y: u16) { + let group = match self.editor_groups.get(&group_id) { + Some(g) => g, + None => return, + }; + let tabs_len = group.tabs.len(); + let active_tab = group.active_tab; + let has_file = self.tab_file_path(group_id, active_tab).is_some(); + let wrap_label = if self.settings.wrap { + "Word Wrap: Off" + } else { + "Word Wrap: On" + }; + + let items = vec![ + ContextMenuItem { + label: "Close All".into(), + action: "close_all".into(), + shortcut: String::new(), + separator_after: false, + enabled: true, + }, + ContextMenuItem { + label: "Close Others".into(), + action: "close_others".into(), + shortcut: String::new(), + separator_after: false, + enabled: tabs_len > 1, + }, + ContextMenuItem { + label: "Close Saved".into(), + action: "close_saved".into(), + shortcut: String::new(), + separator_after: false, + enabled: true, + }, + ContextMenuItem { + label: "Close to the Right".into(), + action: "close_right".into(), + shortcut: String::new(), + separator_after: false, + enabled: active_tab < tabs_len.saturating_sub(1), + }, + ContextMenuItem { + label: "Close to the Left".into(), + action: "close_left".into(), + shortcut: String::new(), + separator_after: true, + enabled: active_tab > 0, + }, + ContextMenuItem { + label: wrap_label.into(), + action: "toggle_wrap".into(), + shortcut: String::new(), + separator_after: false, + enabled: true, + }, + ContextMenuItem { + label: "Change Language Mode".into(), + action: "change_language".into(), + shortcut: String::new(), + separator_after: false, + enabled: true, + }, + ContextMenuItem { + label: "Reveal in File Explorer".into(), + action: "reveal".into(), + shortcut: String::new(), + separator_after: false, + enabled: has_file, + }, + ]; + + let selected = items.iter().position(|i| i.enabled).unwrap_or(0); + self.context_menu = Some(ContextMenuState { + target: ContextMenuTarget::EditorActionMenu { group_id }, + items, + selected, + screen_x: x, + screen_y: y, + }); + } + + /// Close all tabs in the active group. + pub fn close_all_tabs(&mut self) { + let tabs_len = self.active_group().tabs.len(); + for _ in 0..tabs_len { + self.active_group_mut().active_tab = 0; + self.close_tab(); + } + } + /// Open a context menu for an explorer file/directory. pub fn open_explorer_context_menu(&mut self, path: PathBuf, is_dir: bool, x: u16, y: u16) { let mut items = vec![]; @@ -941,6 +1034,15 @@ impl Engine { self.context_menu = None; } + /// Return the (path, is_dir) of the current context menu target, if any. + pub fn context_menu_target_path(&self) -> Option<(PathBuf, bool)> { + self.context_menu.as_ref().and_then(|cm| match &cm.target { + ContextMenuTarget::ExplorerFile { path } => Some((path.clone(), false)), + ContextMenuTarget::ExplorerDir { path } => Some((path.clone(), true)), + _ => None, + }) + } + /// Confirm the currently selected context menu item. Returns the action string. pub fn context_menu_confirm(&mut self) -> Option { let menu = self.context_menu.take()?; @@ -1174,6 +1276,43 @@ impl Engine { } _ => {} }, + ContextMenuTarget::EditorActionMenu { group_id } => { + let group_id = *group_id; + self.active_group = group_id; + match action.as_str() { + "close_all" => { + self.close_all_tabs(); + } + "close_others" => { + self.close_other_tabs(); + } + "close_saved" => { + self.close_saved_tabs(); + } + "close_right" => { + self.close_tabs_to_right(); + } + "close_left" => { + self.close_tabs_to_left(); + } + "toggle_wrap" => { + self.settings.wrap = !self.settings.wrap; + self.message = format!( + "Word wrap {}", + if self.settings.wrap { "on" } else { "off" } + ); + } + "change_language" => { + self.open_picker(PickerSource::Languages); + } + "reveal" => { + if let Some(path) = self.file_path().map(|p| p.to_path_buf()) { + self.reveal_in_file_manager(&path); + } + } + _ => {} + } + } ContextMenuTarget::ExtPanel { panel_name, item_id, diff --git a/src/core/extensions.rs b/src/core/extensions.rs index 9e8e1424..3654d372 100644 --- a/src/core/extensions.rs +++ b/src/core/extensions.rs @@ -66,6 +66,10 @@ pub struct ExtensionManifest { /// Optional comment style override for languages handled by this extension. #[serde(default)] pub comment: Option, + /// Tree-sitter highlight query (S-expression) for this language. + /// Overrides the built-in query in `syntax.rs` when present. + #[serde(default)] + pub highlights: Option, /// User-configurable settings declared by this extension. #[serde(default)] pub settings: Vec, @@ -113,12 +117,17 @@ pub struct LspConfig { /// Checked before starting the server; a helpful message is shown if missing. #[serde(default)] pub dependencies: Vec, - /// Diagnostic sources whose errors should be excluded from explorer counts. - /// E.g. `["rust-analyzer"]` — its internal analysis produces false-positive - /// errors; real errors come from `"rustc"` (cargo check). Warnings from - /// these sources are still counted. + /// Diagnostic sources whose errors should be excluded from editor display + /// and explorer counts. E.g. `["rust-analyzer"]` — its internal analysis + /// produces false-positive errors; real errors come from `"rustc"` (cargo + /// check). Warnings from these sources are still shown. #[serde(default)] pub ignore_error_sources: Vec, + /// JSON object merged into the LSP `initialize` request's + /// `initializationOptions`. Allows per-server configuration (e.g. + /// `{"diagnostics": {"enable": false}}` for rust-analyzer). + #[serde(default)] + pub initialization_options: Option, } impl LspConfig { diff --git a/src/core/lsp.rs b/src/core/lsp.rs index 1b0614ea..2a556c18 100644 --- a/src/core/lsp.rs +++ b/src/core/lsp.rs @@ -458,12 +458,17 @@ pub struct LspPosition { pub character: u32, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct LspServerConfig { pub command: String, #[serde(default)] pub args: Vec, pub languages: Vec, + /// JSON object merged into the `initializationOptions` field of the LSP + /// `initialize` request. Allows per-server configuration (e.g. + /// rust-analyzer diagnostic settings). + #[serde(default)] + pub initialization_options: Option, } // --------------------------------------------------------------------------- @@ -566,6 +571,52 @@ pub fn language_id_from_path(path: &Path) -> Option { Some(lang.to_string()) } +/// Return a sorted, deduplicated list of all known language identifiers. +pub fn all_known_language_ids() -> Vec<&'static str> { + let mut langs = vec![ + "bicep", + "bibtex", + "c", + "cpp", + "csharp", + "css", + "dockerfile", + "elixir", + "go", + "graphql", + "haskell", + "html", + "java", + "javascript", + "javascriptreact", + "json", + "kotlin", + "latex", + "lua", + "markdown", + "nix", + "ocaml", + "php", + "python", + "ruby", + "rust", + "scala", + "shellscript", + "solidity", + "sql", + "swift", + "terraform", + "toml", + "typescript", + "typescriptreact", + "yaml", + "zig", + ]; + langs.sort_unstable(); + langs.dedup(); + langs +} + /// Convert a UTF-16 offset to a char (grapheme-unaware) column index within a line. /// LSP positions use UTF-16 code units; Ropey/our engine use char indices. pub fn utf16_offset_to_char(line_text: &str, utf16_offset: u32) -> usize { @@ -887,7 +938,7 @@ impl LspServer { // Send initialize request let root_uri = path_to_uri(root_path); - let init_params = serde_json::json!({ + let mut init_params = serde_json::json!({ "processId": std::process::id(), "rootUri": root_uri, "capabilities": { @@ -956,6 +1007,13 @@ impl LspServer { } } }); + // Merge per-extension initializationOptions into the request. + if let Some(ref opts) = config.initialization_options { + init_params + .as_object_mut() + .unwrap() + .insert("initializationOptions".to_string(), opts.clone()); + } server.send_request("initialize", init_params); Ok(server) diff --git a/src/core/lsp_manager.rs b/src/core/lsp_manager.rs index 52e73826..0c4e29aa 100644 --- a/src/core/lsp_manager.rs +++ b/src/core/lsp_manager.rs @@ -45,27 +45,36 @@ pub fn default_server_registry() -> Vec { command: "rust-analyzer".to_string(), args: vec![], languages: vec!["rust".to_string()], + ..Default::default() }, // Python — ordered fallbacks (first binary found on PATH/Mason wins) LspServerConfig { command: "pyright-langserver".to_string(), args: vec!["--stdio".to_string()], languages: vec!["python".to_string()], + + ..Default::default() }, LspServerConfig { command: "basedpyright-langserver".to_string(), args: vec!["--stdio".to_string()], languages: vec!["python".to_string()], + + ..Default::default() }, LspServerConfig { command: "pylsp".to_string(), args: vec![], languages: vec!["python".to_string()], + + ..Default::default() }, LspServerConfig { command: "jedi-language-server".to_string(), args: vec![], languages: vec!["python".to_string()], + + ..Default::default() }, LspServerConfig { command: "typescript-language-server".to_string(), @@ -76,81 +85,113 @@ pub fn default_server_registry() -> Vec { "javascriptreact".to_string(), "typescriptreact".to_string(), ], + + ..Default::default() }, LspServerConfig { command: "gopls".to_string(), args: vec![], languages: vec!["go".to_string()], + + ..Default::default() }, LspServerConfig { command: "clangd".to_string(), args: vec![], languages: vec!["c".to_string(), "cpp".to_string()], + + ..Default::default() }, LspServerConfig { command: "csharp-ls".to_string(), args: vec![], languages: vec!["csharp".to_string()], + + ..Default::default() }, LspServerConfig { command: "lua-language-server".to_string(), args: vec![], languages: vec!["lua".to_string()], + + ..Default::default() }, LspServerConfig { command: "bash-language-server".to_string(), args: vec!["start".to_string()], languages: vec!["shellscript".to_string()], + + ..Default::default() }, LspServerConfig { command: "yaml-language-server".to_string(), args: vec!["--stdio".to_string()], languages: vec!["yaml".to_string()], + + ..Default::default() }, LspServerConfig { command: "kotlin-language-server".to_string(), args: vec![], languages: vec!["kotlin".to_string()], + + ..Default::default() }, LspServerConfig { command: "zls".to_string(), args: vec![], languages: vec!["zig".to_string()], + + ..Default::default() }, LspServerConfig { command: "elixir-ls".to_string(), args: vec![], languages: vec!["elixir".to_string()], + + ..Default::default() }, LspServerConfig { command: "ruby-lsp".to_string(), args: vec![], languages: vec!["ruby".to_string()], + + ..Default::default() }, LspServerConfig { command: "terraform-ls".to_string(), args: vec!["serve".to_string()], languages: vec!["terraform".to_string()], + + ..Default::default() }, LspServerConfig { command: "marksman".to_string(), args: vec!["server".to_string()], languages: vec!["markdown".to_string()], + + ..Default::default() }, LspServerConfig { command: "taplo".to_string(), args: vec!["lsp".to_string(), "stdio".to_string()], languages: vec!["toml".to_string()], + + ..Default::default() }, LspServerConfig { command: "sourcekit-lsp".to_string(), args: vec![], languages: vec!["swift".to_string()], + + ..Default::default() }, LspServerConfig { command: "metals".to_string(), args: vec![], languages: vec!["scala".to_string()], + + ..Default::default() }, ] } @@ -173,17 +214,20 @@ fn server_configs_from_manifest( } else { manifest.language_ids.clone() }; + let init_opts = manifest.lsp.initialization_options.clone(); let mut configs = Vec::new(); configs.push(LspServerConfig { command: manifest.lsp.binary.clone(), args: args.clone(), languages: languages.clone(), + initialization_options: init_opts.clone(), }); for fb in &manifest.lsp.fallback_binaries { configs.push(LspServerConfig { command: fb.clone(), args: args.clone(), languages: languages.clone(), + initialization_options: init_opts.clone(), }); } configs @@ -300,6 +344,9 @@ pub struct LspManager { /// covered by an extension, so we don't fall back to the built-in registry for languages /// that have a (not-yet-installed) extension. all_ext_manifests: Vec, + /// Servers that have returned at least one non-empty response (symbols, hover, etc.). + /// This indicates the server has finished indexing and is truly "ready". + server_has_responded: HashMap, /// Servers that crashed or exited (for display in :LspInfo). crashed_servers: Vec, /// Last error from `ensure_server_for_language` (dependency check failure, etc.). @@ -307,7 +354,62 @@ pub struct LspManager { pub last_start_error: Option, } +/// LSP server status for a given language (used by status bar indicator). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LspStatus { + /// No LSP server configured or applicable for this language. + None, + /// Server binary is being installed. + Installing, + /// Server is spawned but hasn't completed initialization handshake. + Initializing(String), + /// Server is running and ready. Contains the server command name. + Running(String), + /// Server crashed or exited unexpectedly. + Crashed, +} + impl LspManager { + /// Mark a server as responsive (ready for requests). + pub fn mark_server_responded(&mut self, server_id: LspServerId) { + self.server_has_responded.insert(server_id, true); + } + + /// Get the LSP status for a given language identifier. + pub fn lsp_status_for_language(&self, lang: &str) -> LspStatus { + // Check if a server exists for this language + if let Some(&server_id) = self.language_to_server.get(lang) { + let cmd = self + .servers + .get(server_id) + .map(|s| { + let c = s.command(); + c.rsplit('/').next().unwrap_or(c).to_string() + }) + .unwrap_or_default(); + let handshake_done = self.initialized.get(&server_id).copied().unwrap_or(false); + let has_responded = self + .server_has_responded + .get(&server_id) + .copied() + .unwrap_or(false); + if handshake_done && has_responded { + LspStatus::Running(cmd) + } else { + // Still initializing (handshake pending) or indexing (no responses yet) + LspStatus::Initializing(cmd) + } + } else { + // Check if it crashed + let crashed = self.crashed_servers.iter().any(|s| s.contains(lang)); + if crashed { + LspStatus::Crashed + } else { + LspStatus::None + } + } + } + pub fn new(root_path: PathBuf, user_servers: &[LspServerConfig]) -> Self { let (event_tx, event_rx) = mpsc::channel(); @@ -336,6 +438,7 @@ impl LspManager { semantic_legends: HashMap::new(), ext_manifests: Vec::new(), all_ext_manifests: Vec::new(), + server_has_responded: HashMap::new(), crashed_servers: Vec::new(), last_start_error: None, } diff --git a/src/core/settings.rs b/src/core/settings.rs index 84ea248d..74151638 100644 --- a/src/core/settings.rs +++ b/src/core/settings.rs @@ -171,6 +171,10 @@ pub struct Settings { #[serde(default = "default_cursorline")] pub cursorline: bool, + /// Per-window status lines instead of a single global status bar (default true). + #[serde(default = "default_window_status_line")] + pub window_status_line: bool, + /// Automatically reload files when changed externally (default true). /// Vim: `autoread`. #[serde(default = "default_autoread")] @@ -237,6 +241,9 @@ pub struct Settings { /// Show hidden files (dotfiles) in the file explorer (default: false). #[serde(default)] pub show_hidden_files: bool, + /// Sort explorer entries case-insensitively (default: true). + #[serde(default = "default_true")] + pub explorer_sort_case_insensitive: bool, // ── Swap files ──────────────────────────────────────────────────────────── /// Enable swap file crash recovery (default: true). @@ -323,6 +330,10 @@ fn default_incremental_search() -> bool { true // Default: enabled } +fn default_true() -> bool { + true +} + fn default_auto_indent() -> bool { true // Default: enabled } @@ -355,6 +366,10 @@ fn default_hlsearch() -> bool { true } +fn default_window_status_line() -> bool { + true +} + fn default_cursorline() -> bool { true } @@ -678,6 +693,7 @@ impl Default for Settings { smartcase: false, scrolloff: 0, cursorline: default_cursorline(), + window_status_line: default_window_status_line(), autoread: default_autoread(), splitbelow: false, splitright: false, @@ -692,6 +708,7 @@ impl Default for Settings { ai_base_url: String::new(), ai_completions: false, show_hidden_files: false, + explorer_sort_case_insensitive: true, swap_file: default_swap_file(), updatetime: default_updatetime(), breadcrumbs: default_breadcrumbs(), @@ -941,12 +958,14 @@ impl Settings { "ignorecase" | "ic" => self.ignorecase = enable, "smartcase" | "scs" => self.smartcase = enable, "cursorline" | "cul" => self.cursorline = enable, + "windowstatusline" | "wsl" => self.window_status_line = enable, "autoread" | "ar" => self.autoread = enable, "splitbelow" | "sb" => self.splitbelow = enable, "splitright" | "spr" => self.splitright = enable, "ai_completions" => self.ai_completions = enable, "formatonsave" | "fos" => self.format_on_save = enable, "showhiddenfiles" | "shf" => self.show_hidden_files = enable, + "explorersortcaseinsensitive" | "esci" => self.explorer_sort_case_insensitive = enable, "swapfile" => self.swap_file = enable, "breadcrumbs" => self.breadcrumbs = enable, "hidesingletab" | "hst" => self.hide_single_tab = enable, @@ -1109,6 +1128,11 @@ impl Settings { } else { "nocursorline".to_string() }), + "windowstatusline" | "wsl" => Ok(if self.window_status_line { + "windowstatusline".to_string() + } else { + "nowindowstatusline".to_string() + }), "splitbelow" | "sb" => Ok(if self.splitbelow { "splitbelow".to_string() } else { @@ -1131,6 +1155,11 @@ impl Settings { } else { "noshowhiddenfiles".to_string() }), + "explorersortcaseinsensitive" | "esci" => Ok(if self.explorer_sort_case_insensitive { + "explorersortcaseinsensitive".to_string() + } else { + "noexplorersortcaseinsensitive".to_string() + }), "swapfile" => Ok(if self.swap_file { "swapfile".to_string() } else { @@ -1226,6 +1255,7 @@ impl Settings { LineNumberMode::Hybrid => "hybrid".to_string(), }, "cursorline" => self.cursorline.to_string(), + "window_status_line" => self.window_status_line.to_string(), "tabstop" => self.tabstop.to_string(), "shift_width" => self.shift_width.to_string(), "expand_tab" => self.expand_tab.to_string(), @@ -1258,6 +1288,9 @@ impl Settings { "ai_base_url" => self.ai_base_url.clone(), "ai_completions" => self.ai_completions.to_string(), "showhiddenfiles" | "shf" | "show_hidden_files" => self.show_hidden_files.to_string(), + "explorersortcaseinsensitive" | "esci" | "explorer_sort_case_insensitive" => { + self.explorer_sort_case_insensitive.to_string() + } "swapfile" | "swap_file" => self.swap_file.to_string(), "updatetime" | "ut" => self.updatetime.to_string(), "breadcrumbs" => self.breadcrumbs.to_string(), @@ -1295,6 +1328,7 @@ impl Settings { }; } "cursorline" => self.cursorline = value == "true", + "window_status_line" => self.window_status_line = value == "true", "tabstop" => { self.tabstop = value .parse() @@ -1355,6 +1389,9 @@ impl Settings { "showhiddenfiles" | "shf" | "show_hidden_files" => { self.show_hidden_files = value == "true" } + "explorersortcaseinsensitive" | "esci" | "explorer_sort_case_insensitive" => { + self.explorer_sort_case_insensitive = value == "true" + } "swapfile" | "swap_file" => self.swap_file = value == "true", "updatetime" | "ut" => { self.updatetime = value @@ -1511,6 +1548,13 @@ pub static SETTING_DEFS: &[SettingDef] = &[ category: "Appearance", setting_type: SettingType::Bool, }, + SettingDef { + key: "window_status_line", + label: "Per-Window Status Line", + description: "Show a status line at the bottom of each window instead of a single global bar", + category: "Appearance", + setting_type: SettingType::Bool, + }, SettingDef { key: "breadcrumbs", label: "Breadcrumbs", diff --git a/src/core/syntax.rs b/src/core/syntax.rs index 4d792374..ef9034e3 100644 --- a/src/core/syntax.rs +++ b/src/core/syntax.rs @@ -26,6 +26,59 @@ pub enum SyntaxLanguage { } impl SyntaxLanguage { + /// Map an LSP language identifier (e.g. "rust", "python") to a SyntaxLanguage. + pub fn from_language_id(id: &str) -> Option { + match id { + "rust" => Some(Self::Rust), + "python" => Some(Self::Python), + "javascript" | "javascriptreact" => Some(Self::JavaScript), + "typescript" => Some(Self::TypeScript), + "typescriptreact" => Some(Self::TypeScriptReact), + "go" => Some(Self::Go), + "c" => Some(Self::C), + "cpp" => Some(Self::Cpp), + "csharp" => Some(Self::CSharp), + "java" => Some(Self::Java), + "ruby" => Some(Self::Ruby), + "lua" => Some(Self::Lua), + "shellscript" => Some(Self::Bash), + "json" => Some(Self::Json), + "toml" => Some(Self::Toml), + "yaml" => Some(Self::Yaml), + "html" => Some(Self::Html), + "css" => Some(Self::Css), + "markdown" => Some(Self::Markdown), + "latex" | "bibtex" => Some(Self::Latex), + _ => None, + } + } + + /// Return the LSP language ID for this language. + pub fn language_id(&self) -> &'static str { + match self { + Self::Rust => "rust", + Self::Python => "python", + Self::JavaScript => "javascript", + Self::TypeScript => "typescript", + Self::TypeScriptReact => "typescriptreact", + Self::Go => "go", + Self::C => "c", + Self::Cpp => "cpp", + Self::CSharp => "csharp", + Self::Java => "java", + Self::Ruby => "ruby", + Self::Lua => "lua", + Self::Bash => "shellscript", + Self::Json => "json", + Self::Toml => "toml", + Self::Yaml => "yaml", + Self::Html => "html", + Self::Css => "css", + Self::Markdown => "markdown", + Self::Latex => "latex", + } + } + /// Detect language from file extension pub fn from_path(path: &str) -> Option { let path_lower = path.to_lowercase(); @@ -164,304 +217,309 @@ impl SyntaxLanguage { match self { Self::Rust => " (function_item name: (identifier) @function) + (call_expression function: (identifier) @function.call) + (call_expression function: (field_expression field: (field_identifier) @method.call)) + (call_expression function: (scoped_identifier name: (identifier) @function.call)) + (macro_invocation macro: (identifier) @macro) + (macro_invocation macro: (scoped_identifier name: (identifier) @macro)) + (macro_definition name: (identifier) @macro) + (type_identifier) @type + (primitive_type) @type + (scoped_type_identifier name: (type_identifier) @type) (string_literal) @string + (raw_string_literal) @string + (char_literal) @string + (integer_literal) @number + (float_literal) @number + (boolean_literal) @boolean (line_comment) @comment + (block_comment) @comment + (attribute_item) @attribute + (inner_attribute_item) @attribute + (lifetime (identifier) @lifetime) (mod_item name: (identifier) @module) + (scoped_identifier path: (identifier) @module) + (field_expression field: (field_identifier) @property) + (field_declaration name: (field_identifier) @property) + (shorthand_field_initializer (identifier) @property) + (parameter pattern: (identifier) @parameter) + (self) @variable + (mutable_specifier) @keyword + (escape_sequence) @escape + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\";\" \",\" \"::\" \".\"] @punctuation.delimiter + [\"=\" \"+=\" \"-=\" \"*=\" \"/=\" \"==\" \"!=\" \"<\" \">\" \"<=\" \">=\" \"&&\" \"||\" \"!\" \"&\" \"|\" \"^\" \"+\" \"-\" \"*\" \"/\" \"%\" \"..\" \"?\" ] @operator + [\"->\" \"=>\"] @operator [ - \"fn\" - \"struct\" - \"enum\" - \"impl\" - \"pub\" - \"use\" - \"mod\" - \"let\" - \"if\" - \"else\" - \"match\" + \"fn\" \"struct\" \"enum\" \"impl\" \"pub\" \"use\" \"mod\" \"let\" + \"const\" \"static\" \"trait\" \"where\" \"type\" \"as\" \"dyn\" + \"async\" \"await\" \"move\" \"ref\" \"unsafe\" \"extern\" ] @keyword - (type_identifier) @type - (primitive_type) @type + [ + \"if\" \"else\" \"match\" \"for\" \"while\" \"loop\" \"return\" + \"in\" \"break\" \"continue\" \"yield\" + ] @keyword.control ", Self::Python => " (function_definition name: (identifier) @function) (class_definition name: (identifier) @type) + (call function: (identifier) @function.call) + (call function: (attribute attribute: (identifier) @method.call)) (string) @string + (integer) @number + (float) @number + (true) @boolean + (false) @boolean + (none) @constant (comment) @comment - [ - \"def\" - \"class\" - \"if\" - \"elif\" - \"else\" - \"for\" - \"while\" - \"return\" - \"import\" - \"from\" - \"as\" - \"try\" - \"except\" - \"finally\" - \"with\" - \"lambda\" - \"pass\" - \"break\" - \"continue\" - \"raise\" - \"yield\" - \"async\" - \"await\" + (decorator) @attribute + (escape_sequence) @escape + (attribute attribute: (identifier) @property) + (parameters (identifier) @parameter) + (default_parameter name: (identifier) @parameter) + (typed_parameter (identifier) @parameter) + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \":\" \";\"] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"//\" \"%\" \"**\" \"==\" \"!=\" \"<\" \">\" \"<=\" \">=\" \"+=\" \"-=\" \"*=\" \"/=\"] @operator + [\"and\" \"or\" \"not\" \"in\" \"is\"] @operator + [\"def\" \"class\" \"import\" \"from\" \"as\" \"with\" \"lambda\" + \"async\" \"await\" \"global\" \"nonlocal\" \"del\" \"assert\" ] @keyword - (call function: (identifier) @function) + [\"if\" \"elif\" \"else\" \"for\" \"while\" \"return\" \"try\" \"except\" + \"finally\" \"pass\" \"break\" \"continue\" \"raise\" \"yield\" + ] @keyword.control ", Self::JavaScript => " (function_declaration name: (identifier) @function) (method_definition name: (property_identifier) @function) + (call_expression function: (identifier) @function.call) + (call_expression function: (member_expression property: (property_identifier) @method.call)) (class_declaration name: (identifier) @type) (string) @string (template_string) @string + (number) @number + (true) @boolean + (false) @boolean + (null) @constant (comment) @comment - [ - \"function\" - \"class\" - \"const\" - \"let\" - \"var\" - \"if\" - \"else\" - \"for\" - \"while\" - \"do\" - \"return\" - \"import\" - \"export\" - \"from\" - \"default\" - \"try\" - \"catch\" - \"finally\" - \"throw\" - \"new\" - \"async\" - \"await\" - \"break\" - \"continue\" - \"switch\" - \"case\" + (regex) @string + (member_expression property: (property_identifier) @property) + (pair key: (property_identifier) @property) + (shorthand_property_identifier) @property + (formal_parameters (identifier) @parameter) + (escape_sequence) @escape + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \";\" \":\"] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"%\" \"==\" \"===\" \"!=\" \"!==\" \"<\" \">\" \"<=\" \">=\" \"&&\" \"||\" \"!\" \"+=\" \"-=\" \"*=\" \"/=\"] @operator + [\"=>\" \"...\" \"??\" \"instanceof\" \"typeof\"] @operator + (this) @variable + [\"function\" \"class\" \"const\" \"let\" \"var\" \"new\" \"async\" \"await\" + \"import\" \"export\" \"from\" \"default\" \"void\" \"delete\" \"of\" \"in\" ] @keyword + [\"if\" \"else\" \"for\" \"while\" \"do\" \"return\" \"try\" \"catch\" \"finally\" + \"throw\" \"break\" \"continue\" \"switch\" \"case\" \"yield\" + ] @keyword.control ", Self::Go => " (function_declaration name: (identifier) @function) (method_declaration name: (field_identifier) @function) + (call_expression function: (identifier) @function.call) + (call_expression function: (selector_expression field: (field_identifier) @method.call)) (type_declaration (type_spec name: (type_identifier) @type)) + (type_identifier) @type (interpreted_string_literal) @string (raw_string_literal) @string + (rune_literal) @string + (int_literal) @number + (float_literal) @number + (imaginary_literal) @number + (true) @boolean + (false) @boolean + (nil) @constant (comment) @comment - [ - \"func\" - \"package\" - \"import\" - \"type\" - \"struct\" - \"interface\" - \"if\" - \"else\" - \"for\" - \"range\" - \"return\" - \"go\" - \"defer\" - \"var\" - \"const\" - \"switch\" - \"case\" - \"default\" - \"break\" - \"continue\" - \"fallthrough\" - \"select\" - \"chan\" - \"map\" + (selector_expression field: (field_identifier) @property) + (field_declaration name: (field_identifier) @property) + (package_identifier) @module + (parameter_declaration name: (identifier) @parameter) + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \";\" \":\"] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"%\" \"==\" \"!=\" \"<\" \">\" \"<=\" \">=\" \"&&\" \"||\" \"!\" \"&\" \"|\" \"^\" \":=\" \"+=\" \"-=\" \"<-\"] @operator + [\"func\" \"package\" \"import\" \"type\" \"struct\" \"interface\" + \"go\" \"defer\" \"var\" \"const\" \"chan\" \"map\" ] @keyword - (type_identifier) @type + [\"if\" \"else\" \"for\" \"range\" \"return\" \"switch\" \"case\" \"default\" + \"break\" \"continue\" \"fallthrough\" \"select\" + ] @keyword.control ", Self::Cpp => " (function_definition declarator: (function_declarator declarator: (identifier) @function)) (declaration declarator: (function_declarator declarator: (identifier) @function)) + (call_expression function: (identifier) @function.call) + (call_expression function: (field_expression field: (field_identifier) @method.call)) (class_specifier name: (type_identifier) @type) (struct_specifier name: (type_identifier) @type) + (type_identifier) @type + (primitive_type) @type + (namespace_identifier) @module (string_literal) @string + (char_literal) @string + (raw_string_literal) @string + (number_literal) @number + (true) @boolean + (false) @boolean + (null) @constant (comment) @comment - [ - \"class\" - \"struct\" - \"enum\" - \"namespace\" - \"public\" - \"private\" - \"protected\" - \"virtual\" - \"static\" - \"const\" - \"if\" - \"else\" - \"for\" - \"while\" - \"do\" - \"return\" - \"break\" - \"continue\" - \"switch\" - \"case\" - \"default\" - \"template\" - \"typename\" - \"using\" - \"new\" - \"delete\" - \"try\" - \"catch\" - \"throw\" + (field_expression field: (field_identifier) @property) + (field_declaration declarator: (field_identifier) @property) + (parameter_declaration declarator: (identifier) @parameter) + (preproc_include) @macro + (preproc_def name: (identifier) @macro) + (preproc_function_def name: (identifier) @macro) + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \";\" \":\" \"::\" \"->\" ] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"%\" \"==\" \"!=\" \"<\" \">\" \"<=\" \">=\" \"&&\" \"||\" \"!\" \"&\" \"|\" \"^\" \"+=\" \"-=\"] @operator + [\"++\" \"--\"] @operator + [\"class\" \"struct\" \"enum\" \"namespace\" \"public\" \"private\" \"protected\" + \"virtual\" \"static\" \"const\" \"template\" \"typename\" \"using\" \"new\" \"delete\" + \"constexpr\" \"noexcept\" \"override\" \"final\" \"explicit\" + \"inline\" \"volatile\" \"extern\" ] @keyword - (type_identifier) @type - (primitive_type) @type + [\"if\" \"else\" \"for\" \"while\" \"do\" \"return\" \"break\" \"continue\" + \"switch\" \"case\" \"default\" \"try\" \"catch\" \"throw\" + ] @keyword.control ", Self::C => " (function_definition declarator: (function_declarator declarator: (identifier) @function)) (declaration declarator: (function_declarator declarator: (identifier) @function)) + (call_expression function: (identifier) @function.call) (struct_specifier name: (type_identifier) @type) (enum_specifier name: (type_identifier) @type) + (type_identifier) @type + (primitive_type) @type (string_literal) @string + (char_literal) @string + (number_literal) @number + (true) @boolean + (false) @boolean + (null) @constant (comment) @comment - [ - \"if\" - \"else\" - \"for\" - \"while\" - \"do\" - \"return\" - \"break\" - \"continue\" - \"switch\" - \"case\" - \"default\" - \"struct\" - \"enum\" - \"typedef\" - \"static\" - \"const\" - \"sizeof\" + (field_expression field: (field_identifier) @property) + (field_declaration declarator: (field_identifier) @property) + (parameter_declaration declarator: (identifier) @parameter) + (preproc_include) @macro + (preproc_def name: (identifier) @macro) + (preproc_function_def name: (identifier) @macro) + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \";\" \":\"] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"%\" \"==\" \"!=\" \"<\" \">\" \"<=\" \">=\" \"&&\" \"||\" \"!\" \"&\" \"|\" \"^\" \"+=\" \"-=\"] @operator + [\"->\" \"++\" \"--\"] @operator + [\"struct\" \"enum\" \"typedef\" \"static\" \"const\" \"sizeof\" + \"extern\" \"inline\" \"volatile\" \"unsigned\" \"signed\" \"union\" ] @keyword - (type_identifier) @type - (primitive_type) @type + [\"if\" \"else\" \"for\" \"while\" \"do\" \"return\" \"break\" \"continue\" + \"switch\" \"case\" \"default\" \"goto\" + ] @keyword.control ", Self::TypeScript | Self::TypeScriptReact => " (function_declaration name: (identifier) @function) (method_definition name: (property_identifier) @function) + (call_expression function: (identifier) @function.call) + (call_expression function: (member_expression property: (property_identifier) @method.call)) (class_declaration name: (type_identifier) @type) (interface_declaration name: (type_identifier) @type) (type_alias_declaration name: (type_identifier) @type) + (type_identifier) @type (string) @string (template_string) @string + (number) @number (comment) @comment - [ - \"function\" - \"class\" - \"interface\" - \"type\" - \"const\" - \"let\" - \"var\" - \"if\" - \"else\" - \"for\" - \"while\" - \"do\" - \"return\" - \"import\" - \"export\" - \"from\" - \"default\" - \"try\" - \"catch\" - \"finally\" - \"throw\" - \"new\" - \"async\" - \"await\" - \"break\" - \"continue\" - \"switch\" - \"case\" - \"as\" - \"extends\" - \"implements\" + (member_expression property: (property_identifier) @property) + (pair key: (property_identifier) @property) + (shorthand_property_identifier) @property + (required_parameter pattern: (identifier) @parameter) + (optional_parameter pattern: (identifier) @parameter) + (escape_sequence) @escape + (this) @variable + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \";\" \":\"] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"%\" \"==\" \"===\" \"!=\" \"!==\" \"<\" \">\" \"<=\" \">=\" \"&&\" \"||\" \"!\" \"+=\" \"-=\" \"*=\" \"/=\"] @operator + [\"=>\" \"...\" \"??\" \"instanceof\" \"typeof\"] @operator + [\"function\" \"class\" \"interface\" \"type\" \"const\" \"let\" \"var\" + \"new\" \"async\" \"await\" \"import\" \"export\" \"from\" \"default\" + \"as\" \"extends\" \"implements\" \"void\" \"delete\" \"of\" \"in\" + \"declare\" \"enum\" \"namespace\" \"readonly\" \"abstract\" \"override\" ] @keyword - (type_identifier) @type + [\"if\" \"else\" \"for\" \"while\" \"do\" \"return\" \"try\" \"catch\" \"finally\" + \"throw\" \"break\" \"continue\" \"switch\" \"case\" \"yield\" + ] @keyword.control ", Self::Css => " (tag_name) @function (class_selector) @type (id_selector) @type - (property_name) @keyword + (property_name) @property (string_value) @string + (color_value) @number + (integer_value) @number + (float_value) @number + (plain_value) @variable (comment) @comment + [\"{\" \"}\" \"(\" \")\" \"[\" \"]\"] @punctuation.bracket + [\";\" \":\" \",\"] @punctuation.delimiter + (important) @keyword ", Self::Json => " - (pair key: (string) @type) + (pair key: (string) @property) (string) @string (number) @number - (true) @keyword - (false) @keyword - (null) @keyword + (true) @boolean + (false) @boolean + (null) @constant + [\"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \":\"] @punctuation.delimiter ", Self::Bash => " (function_definition name: (word) @function) + (command_name (word) @function.call) (string) @string + (raw_string) @string + (number) @number (comment) @comment - (variable_name) @type - [ - \"if\" - \"then\" - \"else\" - \"elif\" - \"fi\" - \"for\" - \"while\" - \"do\" - \"done\" - \"case\" - \"esac\" - \"function\" - \"in\" - ] @keyword + (variable_name) @variable + (simple_expansion) @variable + (expansion) @variable + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\" \"((\" \"))\" \"[[\" \"]]\"] @punctuation.bracket + [\";\" \";;\" \"|\" \"||\" \"&&\" \"&\"] @punctuation.delimiter + [\"=\" \"==\" \"!=\"] @operator + [\">\" \">>\" \"<\" \"<<\"] @operator + [\"function\" \"local\" \"export\" \"unset\" \"declare\"] @keyword + [\"if\" \"then\" \"else\" \"elif\" \"fi\" \"for\" \"while\" \"do\" \"done\" + \"case\" \"esac\" \"in\" \"select\" \"until\" + ] @keyword.control ", Self::Ruby => " (method name: (identifier) @function) + (call method: (identifier) @method.call) (class name: (constant) @type) (constant) @type (string) @string + (integer) @number + (float) @number + (nil) @constant + (true) @boolean + (false) @boolean + (self) @variable (comment) @comment - [ - \"def\" - \"end\" - \"class\" - \"module\" - \"if\" - \"else\" - \"elsif\" - \"unless\" - \"while\" - \"until\" - \"for\" - \"do\" - \"return\" - ] @keyword - (nil) @keyword - (true) @keyword - (false) @keyword - (self) @keyword + (simple_symbol) @string + (escape_sequence) @escape + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \";\" \":\"] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"%\" \"==\" \"!=\" \"<\" \">\" \"<=\" \">=\" \"&&\" \"||\" \"!\"] @operator + [\"and\" \"or\" \"not\"] @operator + [\"def\" \"end\" \"class\" \"module\"] @keyword + [\"if\" \"else\" \"elsif\" \"unless\" \"while\" \"until\" \"for\" \"do\" + \"return\" \"begin\" \"rescue\" \"ensure\" \"yield\" \"break\" \"next\" + ] @keyword.control ", Self::CSharp => " (method_declaration name: (identifier) @function) @@ -502,124 +560,95 @@ impl SyntaxLanguage { (comment) @comment (member_access_expression name: (identifier) @variable) (attribute name: (identifier) @function) - [ - \"class\" - \"interface\" - \"struct\" - \"namespace\" - \"using\" - \"public\" - \"private\" - \"protected\" - \"internal\" - \"static\" - \"if\" - \"else\" - \"for\" - \"foreach\" - \"while\" - \"do\" - \"return\" - \"new\" - \"override\" - \"virtual\" - \"abstract\" - \"sealed\" - \"async\" - \"await\" - \"readonly\" - \"const\" - \"base\" - \"this\" - \"throw\" - \"try\" - \"catch\" - \"finally\" - \"switch\" - \"case\" - \"default\" - \"break\" - \"continue\" - \"enum\" - \"delegate\" - \"event\" - \"get\" - \"set\" - \"in\" - \"out\" - \"ref\" - \"params\" - \"is\" - \"as\" - \"typeof\" - \"partial\" + [\"class\" \"interface\" \"struct\" \"namespace\" \"using\" + \"public\" \"private\" \"protected\" \"internal\" \"static\" + \"new\" \"override\" \"virtual\" \"abstract\" \"sealed\" + \"async\" \"await\" \"readonly\" \"const\" \"base\" \"this\" + \"enum\" \"delegate\" \"event\" \"get\" \"set\" + \"in\" \"out\" \"ref\" \"params\" \"is\" \"as\" \"typeof\" \"partial\" ] @keyword - (boolean_literal) @keyword - (null_literal) @keyword + [\"if\" \"else\" \"for\" \"foreach\" \"while\" \"do\" \"return\" + \"throw\" \"try\" \"catch\" \"finally\" \"switch\" \"case\" \"default\" + \"break\" \"continue\" + ] @keyword.control + (boolean_literal) @boolean + (null_literal) @constant (predefined_type) @type + (escape_sequence) @escape + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \";\" \":\"] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"%\" \"==\" \"!=\" \"<\" \">\" \"<=\" \">=\" \"&&\" \"||\" \"!\" \"&\" \"|\" \"^\" \"+=\" \"-=\" \"*=\" \"/=\"] @operator + [\"++\" \"--\"] @operator ", Self::Java => " (method_declaration name: (identifier) @function) + (method_invocation name: (identifier) @method.call) (class_declaration name: (identifier) @type) (interface_declaration name: (identifier) @type) + (type_identifier) @type (string_literal) @string + (character_literal) @string + (decimal_integer_literal) @number + (hex_integer_literal) @number + (decimal_floating_point_literal) @number + (true) @boolean + (false) @boolean + (null_literal) @constant (line_comment) @comment (block_comment) @comment - [ - \"class\" - \"interface\" - \"extends\" - \"implements\" - \"public\" - \"private\" - \"protected\" - \"static\" - \"if\" - \"else\" - \"for\" - \"while\" - \"return\" - \"new\" - \"import\" - \"package\" + (field_access field: (identifier) @property) + (formal_parameter name: (identifier) @parameter) + (marker_annotation name: (identifier) @attribute) + (annotation name: (identifier) @attribute) + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \";\" \":\"] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"%\" \"==\" \"!=\" \"<\" \">\" \"<=\" \">=\" \"&&\" \"||\" \"!\" \"&\" \"|\" \"^\" \"+=\" \"-=\" \"*=\" \"/=\"] @operator + [\"++\" \"--\" \"instanceof\"] @operator + [\"class\" \"interface\" \"extends\" \"implements\" \"public\" \"private\" + \"protected\" \"static\" \"new\" \"import\" \"package\" + \"abstract\" \"final\" \"synchronized\" \"enum\" ] @keyword - (true) @keyword - (false) @keyword - (null_literal) @keyword - (type_identifier) @type + [\"if\" \"else\" \"for\" \"while\" \"return\" \"throw\" \"throws\" + \"try\" \"catch\" \"finally\" \"break\" \"continue\" \"do\" + \"switch\" \"case\" \"default\" + ] @keyword.control ", Self::Toml => " - (bare_key) @type - (quoted_key) @type + (bare_key) @property + (quoted_key) @property (string) @string (integer) @number (float) @number - (boolean) @keyword + (boolean) @boolean (comment) @comment + [\"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\"=\" \",\" \".\"] @punctuation.delimiter ", Self::Yaml => " - (block_mapping_pair key: (flow_node) @type) - (flow_mapping (_ key: (flow_node) @type)) + (block_mapping_pair key: (flow_node) @property) + (flow_mapping (_ key: (flow_node) @property)) (double_quote_scalar) @string (single_quote_scalar) @string (block_scalar) @string (integer_scalar) @number (float_scalar) @number - (boolean_scalar) @keyword - (null_scalar) @keyword + (boolean_scalar) @boolean + (null_scalar) @constant (comment) @comment (anchor_name) @function (alias_name) @function - (tag) @function + (tag) @attribute ", Self::Html => " (tag_name) @keyword - (attribute_name) @type + (attribute_name) @attribute (attribute_value) @string (quoted_attribute_value) @string (comment) @comment (doctype) @keyword (raw_text) @string + [\"<\" \">\" \"\" ] @punctuation.bracket + [\"=\"] @operator ", Self::Latex => " (line_comment) @comment @@ -644,20 +673,25 @@ impl SyntaxLanguage { ", Self::Lua => " (function_declaration name: (identifier) @function) - (function_call name: (identifier) @function) + (function_call name: (identifier) @function.call) + (function_call name: (dot_index_expression field: (identifier) @method.call)) (string) @string (comment) @comment (number) @number - [ - \"function\" \"end\" \"local\" \"return\" \"if\" \"then\" \"else\" \"elseif\" - \"for\" \"while\" \"do\" \"repeat\" \"until\" \"in\" \"not\" - \"and\" \"or\" - ] @keyword + (true) @boolean + (false) @boolean + (nil) @constant + (dot_index_expression field: (identifier) @property) (break_statement) @keyword (goto_statement) @keyword - (true) @keyword - (false) @keyword - (nil) @keyword + [\"(\" \")\" \"[\" \"]\" \"{\" \"}\"] @punctuation.bracket + [\",\" \".\" \";\" \":\"] @punctuation.delimiter + [\"=\" \"+\" \"-\" \"*\" \"/\" \"%\" \"==\" \"~=\" \"<\" \">\" \"<=\" \">=\" \"..\" \"#\"] @operator + [\"and\" \"or\" \"not\"] @operator + [\"function\" \"end\" \"local\"] @keyword + [\"return\" \"if\" \"then\" \"else\" \"elseif\" + \"for\" \"while\" \"do\" \"repeat\" \"until\" \"in\" + ] @keyword.control ", Self::Markdown => " (atx_heading) @function @@ -695,14 +729,30 @@ pub struct Syntax { impl Syntax { /// Create a new Syntax highlighter for a specific language pub fn new_for_language(language: SyntaxLanguage) -> Self { + Self::new_for_language_with_query(language, None) + } + + /// Create a Syntax highlighter, optionally using a custom highlight query. + /// Falls back to the built-in query if `override_query` is None or fails to compile. + pub fn new_for_language_with_query( + language: SyntaxLanguage, + override_query: Option<&str>, + ) -> Self { let mut parser = Parser::new(); let ts_language = language.language(); parser .set_language(&ts_language) .expect("Error loading grammar"); - let query_source = language.query_source(); - let query = Query::new(&ts_language, query_source).expect("Error compiling query"); + let query = if let Some(oq) = override_query { + Query::new(&ts_language, oq).unwrap_or_else(|_| { + // Override failed to compile; fall back to built-in + Query::new(&ts_language, language.query_source()) + .expect("Error compiling built-in query") + }) + } else { + Query::new(&ts_language, language.query_source()).expect("Error compiling query") + }; Self { parser, @@ -712,10 +762,40 @@ impl Syntax { } } - /// Create a new Syntax highlighter, detecting language from file path + /// Create a new Syntax highlighter, detecting language from file path. + /// Looks up override queries from the provided map keyed by language ID. pub fn new_from_path(path: Option<&str>) -> Option { - path.and_then(SyntaxLanguage::from_path) - .map(Self::new_for_language) + Self::new_from_path_with_overrides(path, None) + } + + /// Create a new Syntax highlighter with optional highlight query overrides. + pub fn new_from_path_with_overrides( + path: Option<&str>, + overrides: Option<&std::collections::HashMap>, + ) -> Option { + let lang = path.and_then(SyntaxLanguage::from_path)?; + let override_query = overrides + .and_then(|m| m.get(lang.language_id())) + .map(|s| s.as_str()); + Some(Self::new_for_language_with_query(lang, override_query)) + } + + /// Create a Syntax from an LSP language identifier (e.g. "rust", "python"). + #[allow(dead_code)] + pub fn new_from_language_id(id: &str) -> Option { + Self::new_from_language_id_with_overrides(id, None) + } + + /// Create a Syntax from an LSP language ID with optional highlight query overrides. + pub fn new_from_language_id_with_overrides( + id: &str, + overrides: Option<&std::collections::HashMap>, + ) -> Option { + let lang = SyntaxLanguage::from_language_id(id)?; + let override_query = overrides + .and_then(|m| m.get(lang.language_id())) + .map(|s| s.as_str()); + Some(Self::new_for_language_with_query(lang, override_query)) } } @@ -734,20 +814,14 @@ impl Syntax { /// This is fast (tree-sitter reuses unchanged subtrees) and should be /// called on every keystroke. Highlight extraction can be deferred. pub fn reparse(&mut self, text: &str) { - // Skip incremental parsing for Markdown: tree-sitter-md's external - // scanner can corrupt the parser's logger struct when reusing an old - // tree, causing a SIGSEGV in ts_parser__log. - let old_tree = if matches!( - self.language, - SyntaxLanguage::Markdown | SyntaxLanguage::Yaml - ) { - None - } else { - self.last_tree.as_ref() - }; + // Always do a full parse (no old_tree). Passing the old tree for + // incremental parsing requires calling tree.edit() with precise byte + // offset deltas BEFORE reparsing. Without tree.edit(), tree-sitter + // assumes the text is unchanged and reuses stale nodes, producing + // highlights with wrong byte offsets (garbled partial-word coloring). let tree = self .parser - .parse(text, old_tree) + .parse(text, None) .expect("tree-sitter parse failed"); self.last_tree = Some(tree); } @@ -1431,9 +1505,21 @@ mod tests { #[test] fn test_syntax_rust_basic() { let mut syntax = Syntax::new_for_language(SyntaxLanguage::Rust); - let code = "fn main() { let x = 42; }"; + let code = "fn main() { let x = 42; }\nstruct Foo { bar: u32 }\nimpl Foo { fn baz(&self) -> bool { true } }"; let highlights = syntax.parse(code); assert!(!highlights.is_empty()); + let kinds: std::collections::HashSet<&str> = + highlights.iter().map(|(_, _, k)| k.as_str()).collect(); + assert!(kinds.contains("keyword"), "missing keyword"); + assert!(kinds.contains("function"), "missing function"); + assert!(kinds.contains("number"), "missing number"); + assert!(kinds.contains("type"), "missing type"); + assert!( + kinds.contains("punctuation.bracket"), + "missing punctuation.bracket" + ); + assert!(kinds.contains("operator"), "missing operator"); + assert!(kinds.contains("boolean"), "missing boolean"); } #[test] @@ -1468,6 +1554,52 @@ mod tests { assert!(!highlights.is_empty()); } + #[test] + fn test_reparse_preserves_captures() { + let mut syntax = Syntax::new_for_language(SyntaxLanguage::Rust); + let code1 = "fn main() { if true { let x = 42; } }"; + let h1 = syntax.parse(code1); + let kinds1: std::collections::HashSet<&str> = + h1.iter().map(|(_, _, k)| k.as_str()).collect(); + assert!( + kinds1.contains("keyword.control"), + "initial missing keyword.control" + ); + assert!(kinds1.contains("keyword"), "initial missing keyword"); + assert!(kinds1.contains("boolean"), "initial missing boolean"); + + // Simulate edit and re-parse + let code2 = "fn main() { if true { let x = 43; } }"; + let h2 = syntax.parse(code2); + let kinds2: std::collections::HashSet<&str> = + h2.iter().map(|(_, _, k)| k.as_str()).collect(); + assert!( + kinds2.contains("keyword.control"), + "reparse missing keyword.control" + ); + assert!(kinds2.contains("keyword"), "reparse missing keyword"); + assert!(kinds2.contains("boolean"), "reparse missing boolean"); + } + + #[test] + fn test_highlights_sorted_by_start_byte() { + let mut syntax = Syntax::new_for_language(SyntaxLanguage::Rust); + let code = "fn main() { if true { let x = 42; } }\nstruct Foo { bar: u32 }"; + let highlights = syntax.parse(code); + for pair in highlights.windows(2) { + assert!( + pair[0].0 <= pair[1].0, + "highlights not sorted by start_byte: ({}, {}, {}) before ({}, {}, {})", + pair[0].0, + pair[0].1, + pair[0].2, + pair[1].0, + pair[1].1, + pair[1].2, + ); + } + } + #[test] fn test_syntax_c_basic() { let mut syntax = Syntax::new_for_language(SyntaxLanguage::C); @@ -1549,20 +1681,20 @@ mod tests { let kinds: Vec<&str> = highlights.iter().map(|(_, _, k)| k.as_str()).collect(); assert!(kinds.contains(&"comment"), "should highlight comments"); assert!(kinds.contains(&"string"), "should highlight quoted strings"); - assert!(kinds.contains(&"type"), "should highlight keys as type"); - assert!(kinds.contains(&"number"), "should highlight numbers"); assert!( - kinds.contains(&"keyword"), - "should highlight booleans as keyword" + kinds.contains(&"property"), + "should highlight keys as property" ); + assert!(kinds.contains(&"number"), "should highlight numbers"); + assert!(kinds.contains(&"boolean"), "should highlight booleans"); // Keys must not be overridden by string — check that key byte range has type, not string let key_highlights: Vec<_> = highlights .iter() .filter(|(s, e, _)| *s == 10 && *e == 18) .collect(); assert!( - key_highlights.iter().any(|(_, _, k)| k == "type"), - "key should be type" + key_highlights.iter().any(|(_, _, k)| k == "property"), + "key should be property" ); assert!( !key_highlights.iter().any(|(_, _, k)| k == "string"), @@ -1635,4 +1767,88 @@ mod tests { assert_eq!(scopes.len(), 1); assert_eq!(scopes[0].name, "hello"); } + + #[test] + fn test_language_id_round_trip() { + // Every variant should round-trip through language_id → from_language_id + let langs = [ + SyntaxLanguage::Rust, + SyntaxLanguage::Python, + SyntaxLanguage::JavaScript, + SyntaxLanguage::TypeScript, + SyntaxLanguage::Go, + SyntaxLanguage::C, + SyntaxLanguage::Cpp, + SyntaxLanguage::CSharp, + SyntaxLanguage::Java, + SyntaxLanguage::Ruby, + SyntaxLanguage::Lua, + SyntaxLanguage::Bash, + SyntaxLanguage::Json, + SyntaxLanguage::Toml, + SyntaxLanguage::Yaml, + SyntaxLanguage::Html, + SyntaxLanguage::Css, + SyntaxLanguage::Markdown, + SyntaxLanguage::Latex, + ]; + for lang in &langs { + let id = lang.language_id(); + let back = SyntaxLanguage::from_language_id(id); + assert_eq!( + back, + Some(*lang), + "round-trip failed for {:?} (id={id})", + lang + ); + } + } + + #[test] + fn test_override_query_used() { + // A valid override query should be used instead of the built-in + let override_q = "(line_comment) @comment"; + let mut syntax = + Syntax::new_for_language_with_query(SyntaxLanguage::Rust, Some(override_q)); + let highlights = syntax.parse("// hello\nfn main() {}"); + let kinds: std::collections::HashSet<&str> = + highlights.iter().map(|(_, _, k)| k.as_str()).collect(); + // Should have comment from override but NOT keyword (override doesn't capture keywords) + assert!( + kinds.contains("comment"), + "override should capture comments" + ); + assert!( + !kinds.contains("keyword"), + "override should NOT capture keywords" + ); + } + + #[test] + fn test_malformed_override_falls_back() { + // A malformed override should fall back to the built-in query + let bad_query = "THIS IS NOT A VALID QUERY !!!"; + let mut syntax = Syntax::new_for_language_with_query(SyntaxLanguage::Rust, Some(bad_query)); + let highlights = syntax.parse("fn main() { let x = 42; }"); + let kinds: std::collections::HashSet<&str> = + highlights.iter().map(|(_, _, k)| k.as_str()).collect(); + // Should fall back to built-in and have keywords + assert!( + kinds.contains("keyword"), + "fallback should capture keywords" + ); + } + + #[test] + fn test_override_map_lookup() { + let mut overrides = std::collections::HashMap::new(); + overrides.insert("rust".to_string(), "(line_comment) @comment".to_string()); + let mut syntax = + Syntax::new_from_path_with_overrides(Some("test.rs"), Some(&overrides)).unwrap(); + let highlights = syntax.parse("// hello\nfn main() {}"); + let kinds: std::collections::HashSet<&str> = + highlights.iter().map(|(_, _, k)| k.as_str()).collect(); + assert!(kinds.contains("comment")); + assert!(!kinds.contains("keyword")); + } } diff --git a/src/gtk/click.rs b/src/gtk/click.rs index cfb2779f..8f89428a 100644 --- a/src/gtk/click.rs +++ b/src/gtk/click.rs @@ -18,6 +18,10 @@ pub(super) enum ClickTarget { DiffToolbarNext, /// Click was on a diff toolbar toggle-fold button. DiffToolbarToggleFold, + /// Click was on a per-window status bar segment with an action. + StatusBarAction(crate::core::engine::StatusAction), + /// Click was on the editor action menu button ("…"). + ActionMenuButton(core::window::GroupId), /// Click was outside any actionable area. None, } @@ -36,6 +40,8 @@ pub(super) fn pixel_to_click_target( tab_slot_positions: &TabSlotMap, diff_btn_map: &DiffBtnMap, split_btn_map: &SplitBtnMap, + action_btn_map: &ActionBtnMap, + status_segment_map: &StatusSegmentMap, ) -> ClickTarget { let tab_row_height = (line_height * 1.6).ceil(); let tab_bar_height = if engine.settings.breadcrumbs { @@ -52,7 +58,9 @@ pub(super) fn pixel_to_click_target( } else { line_height }; - let status_bar_height = line_height * 2.0 + wildmenu_px; + let per_window_status = engine.settings.window_status_line; + let global_status_rows = if per_window_status { 1.0 } else { 2.0 }; + let status_bar_height = line_height * global_status_rows + wildmenu_px; let qf_px = if engine.quickfix_open { let n = engine.quickfix_items.len().clamp(1, 10) as f64; (n + 1.0) * line_height @@ -76,7 +84,7 @@ pub(super) fn pixel_to_click_target( .calculate_group_rects(content_bounds, tab_bar_height); engine.adjust_group_rects_for_hidden_tabs(&mut group_rects, tab_bar_height); - // Check if click is in any group's tab bar (the first line_height of the tab_bar_height region). + // Check if click is in any group's tab bar row. for (gid, grect) in &group_rects { if engine.is_tab_bar_hidden(*gid) { continue; @@ -85,7 +93,7 @@ pub(super) fn pixel_to_click_target( let tab_x_start = grect.x; let bar_width = grect.width; if y >= tab_y - && y < tab_y + tab_row_height + && y < tab_y + tab_bar_height && x >= tab_x_start && x < tab_x_start + bar_width { @@ -108,16 +116,24 @@ pub(super) fn pixel_to_click_target( } // Hit-test split buttons using cached Pango-measured widths. - // Only check if this group actually has split buttons drawn. + // Split buttons sit to the left of the action menu button. if let Some(&(both_btns_px, btn_right_px)) = split_btn_map.get(&group_id.0) { + let action_offset = action_btn_map + .get(&group_id.0) + .map(|&(start, end)| end - start) + .unwrap_or(0.0); let btn_down_px = both_btns_px - btn_right_px; - if local_x >= bar_width - btn_down_px { + if local_x >= bar_width - btn_down_px - action_offset + && local_x < bar_width - action_offset + { return ClickTarget::SplitButton( group_id, crate::core::window::SplitDirection::Horizontal, ); } - if local_x >= bar_width - both_btns_px { + if local_x >= bar_width - both_btns_px - action_offset + && local_x < bar_width - btn_down_px - action_offset + { return ClickTarget::SplitButton( group_id, crate::core::window::SplitDirection::Vertical, @@ -125,6 +141,13 @@ pub(super) fn pixel_to_click_target( } } + // Hit-test action menu button ("…") at the far right. + if let Some(&(start_x, end_x)) = action_btn_map.get(&group_id.0) { + if local_x >= start_x && local_x < end_x { + return ClickTarget::ActionMenuButton(group_id); + } + } + // Hit-test tabs using cached Pango-measured positions from draw_tab_bar. let hit = tab_slot_positions @@ -163,8 +186,29 @@ pub(super) fn pixel_to_click_target( } else { line_height }; - let status_bar_height = line_height * 2.0 + wildmenu_px; - let editor_bottom = height - status_bar_height; + let global_status_rows = if engine.settings.window_status_line { + 1.0 + } else { + 2.0 + }; + let status_bar_height = line_height * global_status_rows + wildmenu_px; + let qf_px2 = if engine.quickfix_open { + let n = engine.quickfix_items.len().clamp(1, 10) as f64; + (n + 1.0) * line_height + } else { + 0.0 + }; + let term_px2 = if engine.terminal_open || engine.bottom_panel_open { + (engine.session.terminal_panel_rows as usize + 2) as f64 * line_height + } else { + 0.0 + }; + let dbg_px2 = if engine.debug_toolbar_visible { + line_height + } else { + 0.0 + }; + let editor_bottom = height - status_bar_height - dbg_px2 - qf_px2 - term_px2; if y >= editor_bottom { return ClickTarget::None; @@ -233,14 +277,25 @@ pub(super) fn pixel_to_click_target( ); let gutter_width = gutter_char_width as f64 * char_width; - let per_window_status = if engine.windows.len() > 1 { + let per_window_status_px = if engine.settings.window_status_line { line_height } else { 0.0 }; - let text_area_height = rect.height - per_window_status; + let text_area_height = rect.height - per_window_status_px; if y >= rect.y + text_area_height { + // Click is in the per-window status bar area — use Pango-measured cached zones + if engine.settings.window_status_line { + let local_x = x - rect.x; + if let Some(zones) = status_segment_map.get(&window_id.0) { + for (start, end, action) in zones { + if local_x >= *start && local_x < *end { + return ClickTarget::StatusBarAction(action.clone()); + } + } + } + } return ClickTarget::None; } @@ -341,9 +396,9 @@ pub(super) fn pixel_to_click_target( } /// Handle mouse click by converting coordinates to buffer position. -/// Returns: `None` = non-buffer click (tab bar, split button, etc.); -/// `Some(true)` = close-tab on dirty buffer (show confirm dialog); -/// `Some(false)` = normal buffer click. +/// Returns: `(click, engine_action)` where click is `None` = non-buffer click, +/// `Some(true)` = close-tab on dirty buffer, `Some(false)` = normal buffer click; +/// `engine_action` is an optional action the caller must dispatch (e.g. sidebar toggle). #[allow(clippy::too_many_arguments)] pub(super) fn handle_mouse_click( engine: &mut Engine, @@ -357,7 +412,9 @@ pub(super) fn handle_mouse_click( tab_slot_positions: &TabSlotMap, diff_btn_map: &DiffBtnMap, split_btn_map: &SplitBtnMap, -) -> Option { + action_btn_map: &ActionBtnMap, + status_segment_map: &StatusSegmentMap, +) -> (Option, Option) { match pixel_to_click_target( engine, x, @@ -369,6 +426,8 @@ pub(super) fn handle_mouse_click( tab_slot_positions, diff_btn_map, split_btn_map, + action_btn_map, + status_segment_map, ) { ClickTarget::BufferPos(wid, line, col) => { // Alt+Click in VSCode mode → add cursor at position @@ -377,28 +436,28 @@ pub(super) fn handle_mouse_click( } else { engine.mouse_click(wid, line, col); } - Some(false) + (Some(false), None) } ClickTarget::SplitButton(group_id, dir) => { engine.active_group = group_id; engine.open_editor_group(dir); - None + (None, None) } ClickTarget::DiffToolbarPrev => { if engine.windows.contains_key(&engine.active_window_id()) { engine.jump_prev_hunk(); } - None + (None, None) } ClickTarget::DiffToolbarNext => { if engine.windows.contains_key(&engine.active_window_id()) { engine.jump_next_hunk(); } - None + (None, None) } ClickTarget::DiffToolbarToggleFold => { engine.diff_toggle_hide_unchanged(); - None + (None, None) } ClickTarget::CloseTab(group_id, tab_idx) => { if let Some(g) = engine.editor_groups.get_mut(&group_id) { @@ -407,12 +466,20 @@ pub(super) fn handle_mouse_click( engine.active_group = group_id; engine.line_annotations.clear(); if engine.dirty() { - return Some(true); + return (Some(true), None); } engine.close_tab(); - None + (None, None) + } + ClickTarget::StatusBarAction(action) => { + let ea = engine.handle_status_action(&action); + (None, ea) + } + ClickTarget::ActionMenuButton(group_id) => { + engine.open_editor_action_menu(group_id, 0, 0); + (None, None) } - _ => None, + _ => (None, None), } } @@ -540,6 +607,8 @@ pub(super) fn handle_mouse_double_click( tab_slot_positions: &TabSlotMap, diff_btn_map: &DiffBtnMap, split_btn_map: &SplitBtnMap, + action_btn_map: &ActionBtnMap, + status_segment_map: &StatusSegmentMap, ) { if let ClickTarget::BufferPos(wid, line, col) = pixel_to_click_target( engine, @@ -552,6 +621,8 @@ pub(super) fn handle_mouse_double_click( tab_slot_positions, diff_btn_map, split_btn_map, + action_btn_map, + status_segment_map, ) { engine.mouse_double_click(wid, line, col); } @@ -570,6 +641,8 @@ pub(super) fn handle_mouse_drag( tab_slot_positions: &TabSlotMap, diff_btn_map: &DiffBtnMap, split_btn_map: &SplitBtnMap, + action_btn_map: &ActionBtnMap, + status_segment_map: &StatusSegmentMap, ) { if let ClickTarget::BufferPos(wid, line, col) = pixel_to_click_target( engine, @@ -582,6 +655,8 @@ pub(super) fn handle_mouse_drag( tab_slot_positions, diff_btn_map, split_btn_map, + action_btn_map, + status_segment_map, ) { engine.mouse_drag(wid, line, col); } diff --git a/src/gtk/css.rs b/src/gtk/css.rs index f74837c2..05f3f404 100644 --- a/src/gtk/css.rs +++ b/src/gtk/css.rs @@ -9,14 +9,13 @@ pub(super) fn make_theme_css(theme: &Theme) -> String { } else { theme.status_fg.to_hex() }; - let active_bg = theme.status_bg.to_hex(); let editor_bg = theme.background.to_hex(); let text_fg = theme.foreground.to_hex(); let accent = theme.function.to_hex(); let sel_bg = theme.fuzzy_selected_bg.to_hex(); // Selected item text: white on dark selection bg for both light/dark themes. let sel_fg = if theme.is_light() { - "#ffffff".to_string() + theme.foreground.darken(0.9).to_hex() } else { bar_fg.clone() }; @@ -24,6 +23,8 @@ pub(super) fn make_theme_css(theme: &Theme) -> String { let dim_fg = theme.line_number_fg.to_hex(); let entry_bg = theme.active_background.to_hex(); let border_col = theme.separator.to_hex(); + let sb_thumb = theme.scrollbar_thumb.to_hex(); + let comment_fg = theme.comment.to_hex(); format!( r#" /* Activity Bar */ @@ -82,12 +83,6 @@ pub(super) fn make_theme_css(theme: &Theme) -> String { color: {bar_fg}; }} - /* Explorer Toolbar */ - .explorer-toolbar {{ - background-color: {active_bg}; - border-bottom: 1px solid {border_col}; - }} - /* Tree View */ treeview {{ background-color: {bar_bg}; @@ -267,6 +262,46 @@ pub(super) fn make_theme_css(theme: &Theme) -> String { color: {text_fg}; border: 1px solid {border_col}; }} + + /* Scrollbar — theme-aware overrides */ + scrollbar slider {{ + background: alpha({sb_thumb}, 0.5); + }} + scrollbar slider:hover {{ + background: alpha({sb_thumb}, 0.7); + }} + scrollbar slider:active {{ + background: alpha({sb_thumb}, 0.9); + }} + + /* Horizontal editor scrollbar — theme-aware */ + .h-editor-scrollbar slider {{ + background: alpha({sb_thumb}, 0.45); + }} + .h-editor-scrollbar slider:hover {{ + background: alpha({sb_thumb}, 0.7); + }} + + /* Find/Replace dialog — theme-aware */ + .find-dialog {{ + background-color: {editor_bg}; + border: 1px solid {border_col}; + }} + .find-dialog entry {{ + background-color: {entry_bg}; + color: {text_fg}; + border: 1px solid {border_col}; + }} + .find-dialog button {{ + border: 1px solid {border_col}; + color: {text_fg}; + }} + .find-dialog button:hover {{ + background-color: {hover_bg}; + }} + .find-match-count {{ + color: {comment_fg}; + }} "# ) } @@ -328,24 +363,6 @@ pub(super) const STATIC_CSS: &str = " /* Activity bar, sidebar, treeview: see make_theme_css() — applied dynamically */ - .explorer-toolbar button { - background: transparent; - border: 1px solid transparent; - border-radius: 2px; - color: #cccccc; - font-size: 16px; - padding: 4px; - } - - .explorer-toolbar button:hover { - background-color: #2a2d2e; - border-color: #0e639c; - } - - .explorer-toolbar button:active { - background-color: #094771; - } - /* treeview: see make_theme_css() — applied dynamically */ /* Thin overlay scrollbars */ diff --git a/src/gtk/draw.rs b/src/gtk/draw.rs index e944a2f5..e0264e5b 100644 --- a/src/gtk/draw.rs +++ b/src/gtk/draw.rs @@ -18,17 +18,21 @@ pub(super) fn draw_editor( tab_slot_positions_out: &Rc>, diff_btn_map_out: &Rc>, split_btn_map_out: &Rc>, + action_btn_map_out: &Rc>, dialog_btn_rects_out: &Rc>, editor_hover_rect_out: &Rc>>, editor_hover_link_rects_out: &Rc>>, mouse_pos: (f64, f64), tab_visible_counts_out: &Rc>>, + status_segment_map_out: &Rc>, ) { let theme = Theme::from_name(&engine.settings.colorscheme); // Clear cached button positions from previous frame. diff_btn_map_out.borrow_mut().clear(); split_btn_map_out.borrow_mut().clear(); + action_btn_map_out.borrow_mut().clear(); + status_segment_map_out.borrow_mut().clear(); // 1. Background let (bg_r, bg_g, bg_b) = theme.background.to_cairo(); @@ -74,7 +78,9 @@ pub(super) fn draw_editor( } else { line_height }; - let status_bar_height = line_height * 2.0 + wildmenu_px; + let per_window_status = engine.settings.window_status_line; + let global_status_rows = if per_window_status { 1.0 } else { 2.0 }; // cmd only vs status+cmd + let status_bar_height = line_height * global_status_rows + wildmenu_px; // Reserve space for the quickfix panel when open const QUICKFIX_ROWS: usize = 6; // 1 header + 5 result rows @@ -173,7 +179,7 @@ pub(super) fn draw_editor( // an inactive group's toolbar doesn't cause a visual shift. let show_split = is_active || engine.is_in_diff_view(); cr.save().ok(); - cr.rectangle(tab_x, tab_y, tab_w, line_height); + cr.rectangle(tab_x, tab_y, tab_w, tab_row_height); cr.clip(); cr.translate(tab_x, tab_y); let hover_idx = tab_close_hover.and_then(|(gid, tidx)| { @@ -188,7 +194,7 @@ pub(super) fn draw_editor( } else { None }; - let (positions, dbp, sbp, vis_count) = draw_tab_bar( + let (positions, dbp, sbp, vis_count, abp) = draw_tab_bar( cr, &layout, &theme, @@ -211,6 +217,9 @@ pub(super) fn draw_editor( if let Some(sp) = sbp { split_btn_map_out.borrow_mut().insert(gtb.group_id.0, sp); } + if let Some(ap) = abp { + action_btn_map_out.borrow_mut().insert(gtb.group_id.0, ap); + } tab_visible_counts_out .borrow_mut() .push((gtb.group_id, vis_count)); @@ -219,7 +228,7 @@ pub(super) fn draw_editor( } else if !engine.is_tab_bar_hidden(engine.active_group) { // Single group: draw tab bar at full width with split buttons. let hover_idx = tab_close_hover.map(|(_gid, tidx)| tidx); - let (positions, dbp, sbp, vis_count) = draw_tab_bar( + let (positions, dbp, sbp, vis_count, abp) = draw_tab_bar( cr, &layout, &theme, @@ -247,6 +256,11 @@ pub(super) fn draw_editor( .borrow_mut() .insert(engine.active_group.0, sp); } + if let Some(ap) = abp { + action_btn_map_out + .borrow_mut() + .insert(engine.active_group.0, ap); + } tab_visible_counts_out .borrow_mut() .push((engine.active_group, vis_count)); @@ -283,6 +297,7 @@ pub(super) fn draw_editor( draw_tab_drag_overlay( cr, engine, + &theme, width as f64, height as f64, line_height, @@ -422,6 +437,8 @@ pub(super) fn draw_editor( term_y, width as f64, line_height, + engine.terminal_open, + !screen.bottom_tabs.output_lines.is_empty(), ); match screen.bottom_tabs.active { render::BottomPanelKind::Terminal => { @@ -475,6 +492,7 @@ pub(super) fn draw_editor( draw_h_scrollbars( cr, engine, + &theme, &window_rects, char_width, line_height, @@ -482,21 +500,52 @@ pub(super) fn draw_editor( h_sb_dragging_window, ); - // 6. Status Line + // 5j. Draw per-window status bars (after scrollbars so they paint on top) + if per_window_status { + for rendered_window in &screen.windows { + if let Some(ref status) = rendered_window.status_line { + let wr = &rendered_window.rect; + let bar_y = wr.y + wr.height - line_height; + let mut zones = Vec::new(); + draw_window_status_bar( + cr, + &layout, + &theme, + status, + wr.x, + bar_y, + wr.width, + line_height, + &mut zones, + ); + status_segment_map_out + .borrow_mut() + .insert(rendered_window.window_id.0, zones); + } + } + } + + // 6. Status Line (global — only when per-window status is off) let status_y = height as f64 - status_bar_height; - draw_status_line( - cr, - &layout, - &theme, - &screen.status_left, - &screen.status_right, - width as f64, - status_y, - line_height, - ); + if !per_window_status { + draw_status_line( + cr, + &layout, + &theme, + &screen.status_left, + &screen.status_right, + width as f64, + status_y, + line_height, + ); + } // 6b. Wildmenu bar (between status and command line) - let mut next_y = status_y + line_height; + let mut next_y = if per_window_status { + status_y // no global status row — cmd line starts where status_y is + } else { + status_y + line_height // skip past the global status row + }; if let Some(ref wm) = screen.wildmenu { draw_wildmenu(cr, &layout, &theme, wm, width as f64, next_y, line_height); next_y += line_height; @@ -518,9 +567,11 @@ pub(super) fn draw_editor( /// window (VSCode style). Only shown when content is wider than the viewport. /// `hovered` — mouse is over any scrollbar track (brightens the thumb). /// `dragging_window` — window being dragged (shows the active/dragging colour). +#[allow(clippy::too_many_arguments)] pub(super) fn draw_h_scrollbars( cr: &Context, engine: &Engine, + theme: &Theme, window_rects: &[(core::WindowId, core::WindowRect)], char_width: f64, line_height: f64, @@ -538,7 +589,8 @@ pub(super) fn draw_h_scrollbars( // Track background (slightly darker when hovered/active) let track_alpha = if hovered || is_active { 0.35 } else { 0.20 }; - cr.set_source_rgba(0.0, 0.0, 0.0, track_alpha); + let (tr, tg, tb) = theme.scrollbar_track.to_cairo(); + cr.set_source_rgba(tr, tg, tb, track_alpha); cr.rectangle(track_x, track_y, track_w, sb_height); cr.fill().ok(); @@ -550,7 +602,8 @@ pub(super) fn draw_h_scrollbars( } else { 0.50 }; - cr.set_source_rgba(0.65, 0.65, 0.65, thumb_alpha); + let (thr, thg, thb) = theme.scrollbar_thumb.to_cairo(); + cr.set_source_rgba(thr, thg, thb, thumb_alpha); cr.rectangle(thumb_x, track_y, thumb_w, sb_height); cr.fill().ok(); } @@ -562,6 +615,7 @@ pub(super) fn draw_h_scrollbars( pub(super) fn draw_tab_drag_overlay( cr: &Context, engine: &Engine, + theme: &Theme, width: f64, height: f64, line_height: f64, @@ -581,7 +635,9 @@ pub(super) fn draw_tab_drag_overlay( } else { line_height }; - let status_bar_height = line_height * 2.0 + wildmenu_px; + let per_window_status = engine.settings.window_status_line; + let global_status_rows = if per_window_status { 1.0 } else { 2.0 }; // cmd only vs status+cmd + let status_bar_height = line_height * global_status_rows + wildmenu_px; let qf_px = if engine.quickfix_open { let n = engine.quickfix_items.len().clamp(1, 10) as f64; (n + 1.0) * line_height @@ -651,10 +707,11 @@ pub(super) fn draw_tab_drag_overlay( // Draw the highlight rectangle. if let Some((hx, hy, hw, hh)) = highlight { - cr.set_source_rgba(0.3, 0.5, 0.9, 0.15); + let (cr_r, cr_g, cr_b) = theme.cursor.to_cairo(); + cr.set_source_rgba(cr_r, cr_g, cr_b, 0.15); cr.rectangle(hx, hy, hw, hh); cr.fill().ok(); - cr.set_source_rgba(0.3, 0.5, 0.9, 0.5); + cr.set_source_rgba(cr_r, cr_g, cr_b, 0.5); cr.set_line_width(2.0); cr.rectangle(hx, hy, hw, hh); cr.stroke().ok(); @@ -669,7 +726,8 @@ pub(super) fn draw_tab_drag_overlay( let gx = mx + 12.0; let gy = my - th as f64 / 2.0; let pad = 4.0; - cr.set_source_rgba(0.15, 0.15, 0.15, 0.85); + let (gbr, gbg, gbb) = theme.background.to_cairo(); + cr.set_source_rgba(gbr, gbg, gbb, 0.85); cr.rectangle( gx - pad, gy - pad, @@ -677,7 +735,8 @@ pub(super) fn draw_tab_drag_overlay( th as f64 + pad * 2.0, ); cr.fill().ok(); - cr.set_source_rgba(0.9, 0.9, 0.9, 0.9); + let (gfr, gfg, gfb) = theme.foreground.to_cairo(); + cr.set_source_rgba(gfr, gfg, gfb, 0.9); cr.move_to(gx, gy); pangocairo::show_layout(cr, layout); } @@ -722,8 +781,8 @@ pub(super) fn draw_tab_bar( italic_font.set_style(pango::Style::Italic); // Measure both split buttons so tabs don't overlap them. - let btn_right_s = format!(" {}", icons::SPLIT_RIGHT.nerd); - let btn_down_s = format!(" {}", icons::SPLIT_DOWN.nerd); + let btn_right_s = format!(" {} ", icons::SPLIT_RIGHT.nerd); + let btn_down_s = format!(" {} ", icons::SPLIT_DOWN.nerd); let btn_right_text = btn_right_s.as_str(); let btn_down_text = btn_down_s.as_str(); let (both_btns_px, btn_right_px) = if show_split_btn { @@ -765,7 +824,14 @@ pub(super) fn draw_tab_bar( }; let diff_total_px = diff_btns_px + diff_label_px; - let tab_area_width = width - both_btns_px - diff_total_px; + // Measure the action menu button ("…"). + let action_btn_s = " \u{22EF} "; // " ⋯ " (midline ellipsis) + layout.set_font_description(Some(&normal_font)); + layout.set_text(action_btn_s); + let (action_w_i, _) = layout.pixel_size(); + let action_btn_px = action_w_i as f64; + + let tab_area_width = width - both_btns_px - diff_total_px - action_btn_px; // Measure the close button (×) once for use in every tab. layout.set_font_description(Some(&normal_font)); @@ -926,7 +992,7 @@ pub(super) fn draw_tab_bar( let diff_btn_pos: Option<(f64, f64, f64, f64, f64, f64)> = if let Some(dt) = diff_toolbar { layout.set_font_description(Some(&normal_font)); let (fr, fg_g, fb) = theme.tab_inactive_fg.to_cairo(); - let mut dx = width - both_btns_px - diff_total_px; + let mut dx = width - both_btns_px - diff_total_px - action_btn_px; // Change label (e.g. " 2 of 5") if let Some(lbl) = &dt.change_label { let (fr2, fg2, fb2) = theme.foreground.to_cairo(); @@ -976,13 +1042,16 @@ pub(super) fn draw_tab_bar( layout.set_font_description(Some(&normal_font)); let (fr, fg_g, fb) = theme.tab_inactive_fg.to_cairo(); cr.set_source_rgb(fr, fg_g, fb); - // Split-right button + // Split-right button (shifted left to make room for action button) layout.set_text(btn_right_text); - cr.move_to(width - both_btns_px, text_y_offset); + cr.move_to(width - both_btns_px - action_btn_px, text_y_offset); pangocairo::show_layout(cr, layout); // Split-down button layout.set_text(btn_down_text); - cr.move_to(width - both_btns_px + btn_right_px, text_y_offset); + cr.move_to( + width - both_btns_px - action_btn_px + btn_right_px, + text_y_offset, + ); pangocairo::show_layout(cr, layout); } @@ -1001,9 +1070,27 @@ pub(super) fn draw_tab_bar( let char_w = (char_px as f64).max(1.0); let available_cols = (effective_tab_area / char_w).floor().max(0.0) as usize; + // Draw the editor action menu button ("…") at the far right. + let action_btn_info = { + layout.set_font_description(Some(&normal_font)); + let (fr, fg_g, fb) = theme.tab_inactive_fg.to_cairo(); + cr.set_source_rgb(fr, fg_g, fb); + let ax = width - action_btn_px; + layout.set_text(action_btn_s); + cr.move_to(ax, text_y_offset); + pangocairo::show_layout(cr, layout); + Some((ax, width)) + }; + // Restore original editor font for subsequent rendering layout.set_font_description(Some(&saved_font)); - (slot_positions, diff_btn_pos, split_btn_info, available_cols) + ( + slot_positions, + diff_btn_pos, + split_btn_info, + available_cols, + action_btn_info, + ) } #[allow(clippy::too_many_arguments)] @@ -1350,6 +1437,12 @@ pub(super) fn draw_window( } } + // Restore layout to match rendered text (needed for correct + // index_to_pos when font_scale != 1.0, e.g. markdown headings). + layout.set_text(&rl.raw_text); + let line_attrs = build_pango_attrs(&rl.spans); + layout.set_attributes(Some(&line_attrs)); + // Diagnostic underlines (wavy squiggles) for dm in &rl.diagnostics { let diag_color = match dm.severity { @@ -1375,9 +1468,6 @@ pub(super) fn draw_window( .map(|(i, _)| i) .unwrap_or(rl.raw_text.len()); - layout.set_text(&rl.raw_text); - layout.set_attributes(None); - let start_pos = layout.index_to_pos(start_byte as i32); let end_pos = layout.index_to_pos(end_byte as i32); let x0 = text_x_offset + start_pos.x() as f64 / pango::SCALE as f64; @@ -1430,9 +1520,6 @@ pub(super) fn draw_window( .map(|(i, _)| i) .unwrap_or(rl.raw_text.len()); - layout.set_text(&rl.raw_text); - layout.set_attributes(None); - let start_pos = layout.index_to_pos(start_byte as i32); let end_pos = layout.index_to_pos(end_byte as i32); let x0 = text_x_offset + start_pos.x() as f64 / pango::SCALE as f64; @@ -1455,7 +1542,8 @@ pub(super) fn draw_window( if let Some((cursor_pos, cursor_shape)) = &rw.cursor { if let Some(rl) = rw.lines.get(cursor_pos.view_line) { layout.set_text(&rl.raw_text); - layout.set_attributes(None); + let cursor_attrs = build_pango_attrs(&rl.spans); + layout.set_attributes(Some(&cursor_attrs)); // When Ctrl+D selections are active, draw bar at right edge (col+1) let render_col = if !rw.extra_selections.is_empty() && *cursor_shape == CursorShape::Bar @@ -1508,7 +1596,8 @@ pub(super) fn draw_window( if let Some(rl) = rw.lines.get(cursor_pos.view_line) { if let Some(ghost) = &rl.ghost_suffix { layout.set_text(&rl.raw_text); - layout.set_attributes(None); + let ghost_line_attrs = build_pango_attrs(&rl.spans); + layout.set_attributes(Some(&ghost_line_attrs)); let byte_offset: usize = rl .raw_text .char_indices() @@ -1540,7 +1629,8 @@ pub(super) fn draw_window( for extra_pos in &rw.extra_cursors { if let Some(rl) = rw.lines.get(extra_pos.view_line) { layout.set_text(&rl.raw_text); - layout.set_attributes(None); + let extra_attrs = build_pango_attrs(&rl.spans); + layout.set_attributes(Some(&extra_attrs)); // When Ctrl+D selections are active, draw bar at right edge (col+1) let render_col = if has_extra_sels && extra_cursor_shape == CursorShape::Bar { extra_pos.col + 1 @@ -2990,6 +3080,8 @@ pub(super) fn draw_bottom_panel_tabs( y: f64, w: f64, line_height: f64, + has_terminal: bool, + has_debug_output: bool, ) { let (br, bg, bb) = theme.tab_bar_bg.to_cairo(); let (fr, fg2, fb) = theme.status_fg.to_cairo(); @@ -3012,14 +3104,21 @@ pub(super) fn draw_bottom_panel_tabs( layout.set_font_description(Some(&ui_font_desc)); layout.set_attributes(None); - let tabs: &[(&str, render::BottomPanelKind)] = &[ - ("TERMINAL", render::BottomPanelKind::Terminal), - ("DEBUG CONSOLE", render::BottomPanelKind::DebugOutput), + let all_tabs: &[(&str, render::BottomPanelKind, bool)] = &[ + ("TERMINAL", render::BottomPanelKind::Terminal, has_terminal), + ( + "DEBUG CONSOLE", + render::BottomPanelKind::DebugOutput, + has_debug_output, + ), ]; let padding = 12.0; let mut cursor_x = x + padding; - for (label, kind) in tabs { + for (label, kind, visible) in all_tabs { + if !visible { + continue; + } let is_active = screen.bottom_tabs.active == *kind; let (lr, lg, lb) = if is_active { (ar, ag, ab) @@ -3041,6 +3140,13 @@ pub(super) fn draw_bottom_panel_tabs( cursor_x += tab_w + padding * 2.0; } + // Close button (×) at right edge + let close_x = x + w - padding - 10.0; + cr.set_source_rgb(fr, fg2, fb); + layout.set_text("\u{00d7}"); // × + cr.move_to(close_x, y); + pangocairo::show_layout(cr, layout); + // Restore the original monospace font. layout.set_font_description(Some(&saved_font)); } @@ -3280,7 +3386,8 @@ pub(super) fn draw_debug_sidebar( 0.0 }; // Track background. - cr.set_source_rgba(0.3, 0.3, 0.3, 0.3); + let (st_r, st_g, st_b) = theme.scrollbar_track.to_cairo(); + cr.set_source_rgba(st_r, st_g, st_b, 0.3); cr.rectangle(sb_x, section_start_y, sb_w, track_h); cr.fill().ok(); // Thumb. @@ -3496,7 +3603,8 @@ pub(super) fn draw_terminal_panel( let div_x = x + half_w; // Fill both halves with terminal default bg. - cr.set_source_rgb(30.0 / 255.0, 30.0 / 255.0, 30.0 / 255.0); + let (tbgr, tbgg, tbgb) = theme.terminal_bg.to_cairo(); + cr.set_source_rgb(tbgr, tbgg, tbgb); cr.rectangle(x, content_y, w - SB_W, content_h); cr.fill().ok(); @@ -3539,7 +3647,8 @@ pub(super) fn draw_terminal_panel( let cell_area_w = w - SB_W; // Fill the entire content area with the default terminal background first. - cr.set_source_rgb(30.0 / 255.0, 30.0 / 255.0, 30.0 / 255.0); + let (tbgr, tbgg, tbgb) = theme.terminal_bg.to_cairo(); + cr.set_source_rgb(tbgr, tbgg, tbgb); cr.rectangle(x, content_y, cell_area_w, content_h); cr.fill().ok(); @@ -3686,6 +3795,112 @@ pub(super) fn draw_status_line( pangocairo::show_layout(cr, layout); } +/// Draw a per-window status bar with styled segments. +#[allow(clippy::too_many_arguments)] +fn draw_window_status_bar( + cr: &Context, + layout: &pango::Layout, + theme: &Theme, + status: &render::WindowStatusLine, + x: f64, + y: f64, + width: f64, + line_height: f64, + segment_zones: &mut Vec<(f64, f64, crate::core::engine::StatusAction)>, +) { + // Fill background using the first segment's bg (derived from theme, not status_bg) + let fill_bg = status + .left_segments + .first() + .or(status.right_segments.first()) + .map(|s| s.bg) + .unwrap_or(theme.background); + let (br, bg, bb) = fill_bg.to_cairo(); + cr.set_source_rgb(br, bg, bb); + cr.rectangle(x, y, width, line_height); + cr.fill().ok(); + + layout.set_attributes(None); + layout.set_width(-1); + layout.set_ellipsize(pango::EllipsizeMode::None); + + // Draw left segments + segment_zones.clear(); + let mut cx = x; + for seg in &status.left_segments { + let (sr, sg, sb) = seg.bg.to_cairo(); + cr.set_source_rgb(sr, sg, sb); + // Measure segment width + layout.set_text(&seg.text); + if seg.bold { + let attrs = pango::AttrList::new(); + attrs.insert(pango::AttrInt::new_weight(pango::Weight::Bold)); + layout.set_attributes(Some(&attrs)); + } else { + layout.set_attributes(None); + } + let (seg_w, _) = layout.pixel_size(); + let seg_w = seg_w as f64; + if let Some(ref action) = seg.action { + segment_zones.push((cx - x, cx - x + seg_w, action.clone())); + } + // Draw segment background + cr.rectangle(cx, y, seg_w, line_height); + cr.fill().ok(); + // Draw segment text + let (fr, fg, fb) = seg.fg.to_cairo(); + cr.set_source_rgb(fr, fg, fb); + cr.move_to(cx, y); + pangocairo::show_layout(cr, layout); + cx += seg_w; + if cx >= x + width { + break; + } + } + + // Draw right segments, right-aligned + let mut right_total_w = 0.0; + for seg in &status.right_segments { + layout.set_text(&seg.text); + if seg.bold { + let attrs = pango::AttrList::new(); + attrs.insert(pango::AttrInt::new_weight(pango::Weight::Bold)); + layout.set_attributes(Some(&attrs)); + } else { + layout.set_attributes(None); + } + let (seg_w, _) = layout.pixel_size(); + right_total_w += seg_w as f64; + } + let mut rx = (x + width - right_total_w).max(cx); + for seg in &status.right_segments { + let (sr, sg, sb) = seg.bg.to_cairo(); + cr.set_source_rgb(sr, sg, sb); + layout.set_text(&seg.text); + if seg.bold { + let attrs = pango::AttrList::new(); + attrs.insert(pango::AttrInt::new_weight(pango::Weight::Bold)); + layout.set_attributes(Some(&attrs)); + } else { + layout.set_attributes(None); + } + let (seg_w, _) = layout.pixel_size(); + let seg_w = seg_w as f64; + if let Some(ref action) = seg.action { + segment_zones.push((rx - x, rx - x + seg_w, action.clone())); + } + cr.rectangle(rx, y, seg_w, line_height); + cr.fill().ok(); + let (fr, fg, fb) = seg.fg.to_cairo(); + cr.set_source_rgb(fr, fg, fb); + cr.move_to(rx, y); + pangocairo::show_layout(cr, layout); + rx += seg_w; + } + + layout.set_attributes(None); +} + pub(super) fn draw_wildmenu( cr: &Context, layout: &pango::Layout, @@ -3981,7 +4196,8 @@ pub(super) fn draw_menu_dropdown( } let item_count = data.open_items.len() as f64; let popup_width = 220.0_f64; - let popup_height = (item_count + 1.0) * line_height; + let pad = 4.0; // small top/bottom padding + let popup_height = item_count * line_height + pad * 2.0; let popup_y = anchor_y; // Background — use hover_bg (adapts to light/dark themes). @@ -3996,13 +4212,12 @@ pub(super) fn draw_menu_dropdown( cr.rectangle(popup_x, popup_y, popup_width, popup_height); let _ = cr.stroke(); - // Items — each occupies one line_height row starting at popup_y + line_height - // (row 0 is the "header" behind the menu bar). + // Items — each occupies one line_height row starting at popup_y + pad. let (fr, fg_c, fb) = theme.foreground.to_cairo(); let (sr, sg, sb) = theme.line_number_fg.to_cairo(); cr.set_source_rgb(fr, fg_c, fb); for (i, item) in data.open_items.iter().enumerate() { - let row_top = popup_y + (i as f64 + 1.0) * line_height; + let row_top = popup_y + pad + i as f64 * line_height; if item.separator { cr.set_source_rgb(sr, sg, sb); let sep_y = row_top + line_height * 0.5; @@ -5753,7 +5968,7 @@ pub(super) fn draw_debug_toolbar( cursor_x += 8.0; } cr.move_to(cursor_x, y + height * 0.7); - let text = format!("{} ({}) ", btn.icon, btn.key_hint); + let text = format!("{} ({}) ", btn.label, btn.key_hint); let _ = cr.show_text(&text); cursor_x += text.len() as f64 * 7.0; } diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index 8b3d5c1d..59d191a2 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -68,15 +68,23 @@ type DiffBtnMap = HashMap; /// Only populated when split buttons are visible (active group in multi-group, or single-group mode). type SplitBtnMap = HashMap; +/// Cached action menu button pixel range per group: group_id -> (start_x, end_x). +type ActionBtnMap = HashMap; + /// Cached dialog button hit rects: Vec<(x, y, w, h)> populated by draw_dialog_popup. type DialogBtnRects = Vec<(f64, f64, f64, f64)>; +/// Cached per-window status segment hit zones: window_id -> Vec<(start_x, end_x, action)>. +/// Populated by draw_window_status_bar, consumed by click hit-testing. +type StatusSegmentMap = HashMap>; + /// Return type of draw_tab_bar: (tab_slot_positions, diff_btn_positions, split_btn_widths, visible_tab_count). type TabBarDrawResult = ( Vec<(f64, f64)>, Option<(f64, f64, f64, f64, f64, f64)>, Option<(f64, f64)>, usize, + Option<(f64, f64)>, // action menu button (start_x, end_x) ); struct App { @@ -176,6 +184,9 @@ struct App { /// Cached diff toolbar button pixel positions, populated during draw_tab_bar. diff_btn_map: Rc>, split_btn_map: Rc>, + action_btn_map: Rc>, + /// Cached per-window status bar segment hit zones from draw_window_status_bar. + status_segment_map: Rc>, /// Cached nav arrow pixel hit rects from draw_menu_bar: (back_x, back_end, fwd_x, fwd_end, unit_end). #[allow(dead_code, clippy::type_complexity)] nav_arrow_rects: Rc>, @@ -837,57 +848,6 @@ impl SimpleComponent for App { #[watch] set_visible: model.active_panel == SidebarPanel::Explorer, - // Toolbar with file operation buttons - #[name = "explorer_toolbar"] - gtk4::Box { - set_orientation: gtk4::Orientation::Horizontal, - set_margin_all: 5, - set_spacing: 5, - set_css_classes: &["explorer-toolbar"], - - gtk4::Button { - set_label: icons::FILE_GENERIC.nerd, - set_tooltip_text: Some("New File"), - set_width_request: 32, - set_height_request: 32, - connect_clicked[sender, file_tree_view] => move |_| { - let parent_dir = selected_parent_dir(&file_tree_view); - sender.input(Msg::StartInlineNewFile(parent_dir)); - } - }, - - gtk4::Button { - set_label: icons::FOLDER.nerd, - set_tooltip_text: Some("New Folder"), - set_width_request: 32, - set_height_request: 32, - connect_clicked[sender, file_tree_view] => move |_| { - let parent_dir = selected_parent_dir(&file_tree_view); - sender.input(Msg::StartInlineNewFolder(parent_dir)); - } - }, - - gtk4::Button { - set_label: icons::TRASH.nerd, - set_tooltip_text: Some("Delete"), - set_width_request: 32, - set_height_request: 32, - connect_clicked[sender, file_tree_view] => move |_| { - // Get selected row - if let Some(selection) = file_tree_view.selection().selected() { - let (model, iter) = selection; - // Column 2 contains the full path - let path_str: String = model.get_value(&iter, 2).get().unwrap_or_default(); - if !path_str.is_empty() { - let path = PathBuf::from(path_str); - sender.input(Msg::ConfirmDeletePath(path)); - } - } - } - }, - - }, - // Scrollable tree view #[name = "file_tree_scroll"] gtk4::ScrolledWindow { @@ -928,9 +888,14 @@ impl SimpleComponent for App { sender.input(Msg::ToggleFocusSearch); return gtk4::glib::Propagation::Stop; } - // Arrow keys + Enter: let TreeView handle natively - // (Enter fires row_activated which handles dirs+files) - if matches!(key_name.as_str(), "Up" | "Down" | "Left" | "Right" | "Return" | "KP_Enter" | "space") { + // Enter: use our handler that syncs cursor→selection first + // (native row_activated uses selection which lags behind arrow-key cursor) + if matches!(key_name.as_str(), "Return" | "KP_Enter") { + sender.input(Msg::ExplorerActivateSelected); + return gtk4::glib::Propagation::Stop; + } + // Arrow keys + space: let TreeView handle natively + if matches!(key_name.as_str(), "Up" | "Down" | "Left" | "Right" | "space") { return gtk4::glib::Propagation::Proceed; } @@ -1914,6 +1879,8 @@ impl SimpleComponent for App { let mut e = Engine::new(); icons::set_nerd_fonts(e.settings.use_nerd_fonts); e.plugin_init(); + // Fetch fresh extension registry in background (updates ignore_error_sources, etc.) + e.ext_refresh(); if let Some(ref path) = file_path { // CLI argument: open only the specified file/directory, skip session restore if path.is_dir() { @@ -2035,6 +2002,9 @@ impl SimpleComponent for App { Rc::new(RefCell::new(HashMap::new())); let diff_btn_map_cell: Rc> = Rc::new(RefCell::new(HashMap::new())); let split_btn_map_cell: Rc> = Rc::new(RefCell::new(HashMap::new())); + let action_btn_map_cell: Rc> = Rc::new(RefCell::new(HashMap::new())); + let status_segment_map_cell: Rc> = + Rc::new(RefCell::new(HashMap::new())); let tab_visible_counts_cell: Rc>> = Rc::new(RefCell::new(Vec::new())); #[allow(clippy::type_complexity)] @@ -2152,6 +2122,8 @@ impl SimpleComponent for App { tab_slot_positions: tab_slot_positions_cell.clone(), diff_btn_map: diff_btn_map_cell.clone(), split_btn_map: split_btn_map_cell.clone(), + action_btn_map: action_btn_map_cell.clone(), + status_segment_map: status_segment_map_cell.clone(), nav_arrow_rects: nav_arrow_rects_cell.clone(), tab_visible_counts: tab_visible_counts_cell.clone(), terminal_sb_dragging: false, @@ -2418,22 +2390,20 @@ impl SimpleComponent for App { } let popup_w = 220.0_f64; let popup_y = 0.0; - let popup_h = (items.len() as f64 + 1.0) * line_height; + let menu_pad = 4.0; + let popup_h = items.len() as f64 * line_height + menu_pad * 2.0; if y >= popup_y && y < popup_y + popup_h && x >= popup_x && x < popup_x + popup_w { - let raw_idx = ((y - popup_y) / line_height) as usize; - let item_real = raw_idx.saturating_sub(1); - if raw_idx >= 1 - && item_real < items.len() - && !items[item_real].separator - { - let action = items[item_real].action.to_string(); + let item_idx = + ((y - popup_y - menu_pad) / line_height).floor() as usize; + if item_idx < items.len() && !items[item_idx].separator { + let action = items[item_idx].action.to_string(); drop(engine); sender_dd - .send(Msg::MenuActivateItem(open_idx, item_real, action)) + .send(Msg::MenuActivateItem(open_idx, item_idx, action)) .ok(); } else { drop(engine); @@ -2473,21 +2443,19 @@ impl SimpleComponent for App { } let popup_w = 220.0_f64; let popup_y = 0.0; - let popup_h = (items.len() as f64 + 1.0) * line_height; + let menu_pad = 4.0; + let popup_h = items.len() as f64 * line_height + menu_pad * 2.0; if y >= popup_y && y < popup_y + popup_h && x >= popup_x && x < popup_x + popup_w { - let raw_idx = ((y - popup_y) / line_height) as usize; - let item_real = raw_idx.saturating_sub(1); - if raw_idx >= 1 - && item_real < items.len() - && !items[item_real].separator - { - if engine.menu_highlighted_item != Some(item_real) { + let item_idx = + ((y - popup_y - menu_pad) / line_height).floor() as usize; + if item_idx < items.len() && !items[item_idx].separator { + if engine.menu_highlighted_item != Some(item_idx) { drop(engine); - sender_motion.send(Msg::MenuHighlight(Some(item_real))).ok(); + sender_motion.send(Msg::MenuHighlight(Some(item_idx))).ok(); } } else if engine.menu_highlighted_item.is_some() { drop(engine); @@ -3176,17 +3144,16 @@ impl SimpleComponent for App { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let (dir_fg_hex, file_fg_hex) = { let theme = Theme::from_name(&engine.borrow().settings.colorscheme); - let file_fg = if theme.is_light() { - theme.foreground.to_hex() - } else { - theme.status_fg.to_hex() - }; - (theme.explorer_dir_fg.to_hex(), file_fg) + ( + theme.explorer_dir_fg.to_hex(), + theme.explorer_file_fg.to_hex(), + ) }; build_file_tree_with_root( &tree_store, &cwd, engine.borrow().settings.show_hidden_files, + engine.borrow().settings.explorer_sort_case_insensitive, &dir_fg_hex, &file_fg_hex, ); @@ -3198,7 +3165,7 @@ impl SimpleComponent for App { let nf_font = format!("Symbols Nerd Font, {user_font}"); // Setup TreeView columns - // Single column with icon + filename (so they indent together) + // Icon + filename column (indent together via GTK expander) let col = gtk4::TreeViewColumn::new(); // Icon cell renderer (non-expanding) — uses bundled nerd font for glyph support @@ -3210,6 +3177,7 @@ impl SimpleComponent for App { // Filename cell renderer (expanding) — made editable on demand for inline rename let name_cell = gtk4::CellRendererText::new(); + name_cell.set_property("ellipsize", gtk4::pango::EllipsizeMode::End); col.pack_start(&name_cell, true); col.add_attribute(&name_cell, "text", 1); col.add_attribute(&name_cell, "foreground", 3); @@ -3257,6 +3225,49 @@ impl SimpleComponent for App { } } }); + { + let ts = tree_store.clone(); + name_cell.connect_editing_started(move |_cell, editable, tree_path| { + // For renames, pre-select only the stem (filename without + // extension) so the user can type a new name while keeping + // the extension. New-file entries start empty, so no selection. + use gtk4::prelude::{EditableExt, TreeModelExt}; + let path_str: String = ts + .iter(&tree_path) + .and_then(|iter| ts.get_value(&iter, 2).get::().ok()) + .unwrap_or_default(); + // Skip marker rows (new file/folder creation) + if path_str.starts_with("__NEW_FILE__") + || path_str.starts_with("__NEW_FOLDER__") + || path_str.is_empty() + { + return; + } + // Get filename from path + let name = std::path::Path::new(&path_str) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if name.is_empty() { + return; + } + let is_dir = std::path::Path::new(&path_str).is_dir(); + let stem_end = if is_dir { + name.len() + } else { + name.rfind('.').filter(|&i| i > 0).unwrap_or(name.len()) + }; + // The editable widget is typically an Entry implementing Editable. + // Upcast to Object then try downcasting to Editable. + let widget: gtk4::Widget = editable.clone().upcast(); + if let Ok(e) = widget.dynamic_cast::() { + // Defer selection to idle so GTK finishes setting up the editor. + gtk4::glib::idle_add_local_once(move || { + e.select_region(0, stem_end as i32); + }); + } + }); + } name_cell.connect_editing_canceled(move |_cell| { // Disable editable when editing is cancelled name_cell_for_cancel.set_property("editable", false); @@ -3268,14 +3279,22 @@ impl SimpleComponent for App { }); } - // Indicator cell renderer (right-aligned, non-expanding) for M/error/warning badges + col.set_expand(true); + // Fixed sizing lets the column shrink to fit available space + // instead of growing to the natural width of the longest filename. + col.set_sizing(gtk4::TreeViewColumnSizing::Fixed); + widgets.file_tree_view.append_column(&col); + + // Separate right-aligned column for indicators (M/error/warning badges). + // A dedicated column ensures indicators are always visible regardless of + // tree width — packing them into the name column caused clipping. + let indicator_col = gtk4::TreeViewColumn::new(); let indicator_cell = gtk4::CellRendererText::new(); indicator_cell.set_property("xalign", 1.0f32); - col.pack_end(&indicator_cell, false); - col.add_attribute(&indicator_cell, "text", 4); - col.add_attribute(&indicator_cell, "foreground", 5); - - widgets.file_tree_view.append_column(&col); + indicator_col.pack_start(&indicator_cell, false); + indicator_col.add_attribute(&indicator_cell, "text", 4); + indicator_col.add_attribute(&indicator_cell, "foreground", 5); + widgets.file_tree_view.append_column(&indicator_col); // Set the model on the TreeView widgets.file_tree_view.set_model(Some(&tree_store)); @@ -3289,18 +3308,16 @@ impl SimpleComponent for App { .connect_row_expanded(move |_tree_view, iter, _tree_path| { let e = engine_ref.borrow(); let show_hidden = e.settings.show_hidden_files; + let case_insensitive = e.settings.explorer_sort_case_insensitive; let theme = Theme::from_name(&e.settings.colorscheme); let dir_fg_hex = theme.explorer_dir_fg.to_hex(); - let file_fg_hex = if theme.is_light() { - theme.foreground.to_hex() - } else { - theme.status_fg.to_hex() - }; + let file_fg_hex = theme.explorer_file_fg.to_hex(); drop(e); tree_row_expanded( &tree_store_ref, iter, show_hidden, + case_insensitive, &dir_fg_hex, &file_fg_hex, ); @@ -3398,8 +3415,14 @@ impl SimpleComponent for App { Some(PathBuf::from(s)) } }); - let Some(target) = selected_path else { return }; - let is_dir = target.is_dir(); + // If clicking empty space below last entry, use workspace root + let (target, is_dir) = if let Some(path) = selected_path { + let d = path.is_dir(); + (path, d) + } else { + let root = engine_ctx.borrow().cwd.clone(); + (root, true) + }; // Build gio::Menu from engine-generated items (single source of truth). engine_ctx @@ -3442,8 +3465,13 @@ impl SimpleComponent for App { { let s = sender_rc.clone(); let pd = parent_dir.clone(); + let pop_ref = ctx_pop_rc.clone(); let a = gtk4::gio::SimpleAction::new("new_file", None); a.connect_activate(move |_, _| { + // Close popover first so it doesn't steal focus from inline editor + if let Some(ref p) = *pop_ref.borrow() { + p.popdown(); + } s.input(Msg::StartInlineNewFile(pd.clone())); }); add_action(&actions, &a); @@ -3451,8 +3479,13 @@ impl SimpleComponent for App { { let s = sender_rc.clone(); let pd = parent_dir.clone(); + let pop_ref = ctx_pop_rc.clone(); let a = gtk4::gio::SimpleAction::new("new_folder", None); a.connect_activate(move |_, _| { + // Close popover first so it doesn't steal focus from inline editor + if let Some(ref p) = *pop_ref.borrow() { + p.popdown(); + } s.input(Msg::StartInlineNewFolder(pd.clone())); }); add_action(&actions, &a); @@ -3468,24 +3501,28 @@ impl SimpleComponent for App { if let Some(ref p) = *pop_ref.borrow() { p.popdown(); } - // Start inline cell editing on idle so the popover - // has time to close and release focus. + // Delay editing start so the popover has time to + // fully close — otherwise GTK fires editing_canceled + // immediately when focus shifts. let tv2 = tv.clone(); let nc2 = nc.clone(); - gtk4::glib::idle_add_local_once(move || { - nc2.set_property("editable", true); - if let Some(column) = tv2.column(0) { - if let Some((model, iter)) = tv2.selection().selected() { - let tree_path = model.path(&iter); - gtk4::prelude::TreeViewExt::set_cursor( - &tv2, - &tree_path, - Some(&column), - true, - ); + gtk4::glib::timeout_add_local_once( + std::time::Duration::from_millis(50), + move || { + nc2.set_property("editable", true); + if let Some(column) = tv2.column(0) { + if let Some((model, iter)) = tv2.selection().selected() { + let tree_path = model.path(&iter); + gtk4::prelude::TreeViewExt::set_cursor( + &tv2, + &tree_path, + Some(&column), + true, + ); + } } - } - }); + }, + ); }); add_action(&actions, &a); } @@ -3779,38 +3816,18 @@ impl SimpleComponent for App { let widget = g.widget(); let width = widget.width() as f64; let height = widget.height() as f64; - let wildmenu_px = if engine.wildmenu_items.is_empty() { - 0.0 - } else { - lh - }; - let status_h = lh * 2.0 + wildmenu_px; - let dbg_px = if engine.debug_toolbar_visible { - lh - } else { - 0.0 - }; - let qf_px = if engine.quickfix_open && !engine.quickfix_items.is_empty() { - 6.0 * lh - } else { - 0.0 - }; - let term_px = if engine.terminal_open || engine.bottom_panel_open { - (engine.session.terminal_panel_rows as f64 + 2.0) * lh + let editor_bottom = gtk_editor_bottom(&engine, width, height, lh); + let tab_row_h = (lh * 1.6).ceil(); + let tab_bar_h = if engine.settings.breadcrumbs { + tab_row_h + lh } else { - 0.0 + tab_row_h }; - let editor_bottom = height - status_h - dbg_px - qf_px - term_px; let content_bounds = core::window::WindowRect::new(0.0, 0.0, width, editor_bottom); let dividers = engine.group_layout.dividers(content_bounds, &mut 0); // Check if click is in a scrollbar zone (rightmost 10px of any // window rect). If so, skip divider claim to let the scrollbar // handle the click instead. - let tab_bar_h = if engine.settings.breadcrumbs { - lh * 2.0 - } else { - lh - }; let (window_rects, _) = engine.calculate_group_window_rects(content_bounds, tab_bar_h); let in_scrollbar = window_rects.iter().any(|(_, r)| { @@ -3820,7 +3837,18 @@ impl SimpleComponent for App { && y >= r.y && y < r.y + r.height }); - if !in_scrollbar { + // Check if click is in any group's tab bar region. + let group_rects = engine + .group_layout + .calculate_group_rects(content_bounds, tab_bar_h); + let in_tab_bar = group_rects.iter().any(|(gid, grect)| { + if engine.is_tab_bar_hidden(*gid) { + return false; + } + let ty = grect.y - tab_bar_h; + y >= ty && y < ty + tab_bar_h && x >= grect.x && x < grect.x + grect.width + }); + if !in_scrollbar && !in_tab_bar { for div in ÷rs { let hit = match div.direction { core::window::SplitDirection::Vertical => { @@ -3979,11 +4007,13 @@ impl SimpleComponent for App { let tab_slots_for_draw = tab_slot_positions_cell.clone(); let diff_btn_for_draw = diff_btn_map_cell.clone(); let split_btn_for_draw = split_btn_map_cell.clone(); + let action_btn_for_draw = action_btn_map_cell.clone(); let dialog_btn_for_draw = model.dialog_btn_rects.clone(); let editor_hover_rect_for_draw = model.editor_hover_popup_rect.clone(); let editor_hover_links_for_draw = model.editor_hover_link_rects.clone(); let mouse_pos_for_draw = mouse_pos_cell.clone(); let tab_vis_for_draw = tab_visible_counts_cell.clone(); + let status_seg_for_draw = model.status_segment_map.clone(); widgets .drawing_area .set_draw_func(move |_, cr, width, height| { @@ -4003,11 +4033,13 @@ impl SimpleComponent for App { &tab_slots_for_draw, &diff_btn_for_draw, &split_btn_for_draw, + &action_btn_for_draw, &dialog_btn_for_draw, &editor_hover_rect_for_draw, &editor_hover_links_for_draw, mouse_pos_for_draw.get(), &tab_vis_for_draw, + &status_seg_for_draw, ); })); if let Err(e) = result { @@ -4039,6 +4071,8 @@ impl SimpleComponent for App { let tab_slots_rc = tab_slot_positions_cell.clone(); let diff_btn_rc = diff_btn_map_cell.clone(); let split_btn_rc = split_btn_map_cell.clone(); + let action_btn_rc = action_btn_map_cell.clone(); + let status_seg_rc = status_segment_map_cell.clone(); let rc_gesture = gtk4::GestureClick::new(); rc_gesture.set_button(3); rc_gesture.connect_pressed(move |gesture, _n_press, x, y| { @@ -4058,6 +4092,8 @@ impl SimpleComponent for App { &tab_slots_rc.borrow(), &diff_btn_rc.borrow(), &split_btn_rc.borrow(), + &action_btn_rc.borrow(), + &status_seg_rc.borrow(), ); match target { ClickTarget::TabBar => { @@ -4232,6 +4268,8 @@ impl SimpleComponent for App { &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), + &self.action_btn_map.borrow(), + &self.status_segment_map.borrow(), ) { engine.add_cursor_at_pos(line, col); } @@ -4294,6 +4332,8 @@ impl SimpleComponent for App { &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), + &self.action_btn_map.borrow(), + &self.status_segment_map.borrow(), ); } } @@ -4476,6 +4516,7 @@ impl SimpleComponent for App { if let Ok(new_settings) = core::settings::Settings::load_with_validation() { let mut engine = self.engine.borrow_mut(); engine.settings = new_settings; + engine.ensure_spell_checker(); engine.message = "Settings reloaded from disk".to_string(); drop(engine); @@ -4941,6 +4982,7 @@ impl App { return; } + let theme = Theme::from_name(&engine.settings.colorscheme); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let mut last_file: Option = None; @@ -4951,7 +4993,8 @@ impl App { let rel = m.file.strip_prefix(&cwd).unwrap_or(&m.file); let file_label = gtk4::Label::new(None); let header_markup = format!( - "{}", + "{}", + theme.function.to_hex(), gtk4::glib::markup_escape_text(&rel.display().to_string()) ); file_label.set_markup(&header_markup); @@ -4968,7 +5011,8 @@ impl App { let snippet = format!(" {}: {}", m.line + 1, m.line_text.trim()); let row_label = gtk4::Label::new(None); let result_markup = format!( - "{}", + "{}", + theme.foreground.to_hex(), gtk4::glib::markup_escape_text(&snippet) ); row_label.set_markup(&result_markup); @@ -5188,8 +5232,13 @@ impl App { cursor_indicator.set_valign(gtk4::Align::Start); cursor_indicator.set_hexpand(false); cursor_indicator.set_vexpand(false); - cursor_indicator.set_draw_func(|_, cr, w, h| { - cr.set_source_rgba(0.5, 0.5, 0.5, 0.8); + let thumb_color = { + let engine = self.engine.borrow(); + Theme::from_name(&engine.settings.colorscheme).scrollbar_thumb + }; + cursor_indicator.set_draw_func(move |_, cr, w, h| { + let (r, g, b) = thumb_color.to_cairo(); + cr.set_source_rgba(r, g, b, 0.8); cr.rectangle(0.0, 0.0, w as f64, h as f64); let _ = cr.fill(); }); @@ -5725,8 +5774,17 @@ impl App { da.queue_draw(); } } + // Check whether the explorer cell renderer is actively being edited. + // Many tree operations (indicator update, refresh) must be deferred + // while editing is active or they destroy the GTK cell editor widget. + let cell_editing = self.name_cell.borrow().as_ref().is_some_and(|nc| { + use gtk4::prelude::CellRendererExt; + nc.is_editing() + }); // Explorer refresh after confirmed file move. - if self.engine.borrow().explorer_needs_refresh { + // Defer while a cell is being inline-edited — store.clear() would + // destroy the active editor widget. + if !cell_editing && self.engine.borrow().explorer_needs_refresh { self.engine.borrow_mut().explorer_needs_refresh = false; sender.input(Msg::RefreshFileTree); } @@ -5841,13 +5899,29 @@ impl App { } // Tick swap file writes (only does work when updatetime elapsed). self.engine.borrow_mut().tick_swap_files(); + // Auto-dismiss completed notifications after timeout; force redraw for spinner animation. + { + let mut engine = self.engine.borrow_mut(); + if engine.has_active_notifications() { + self.draw_needed.set(true); + } + let had_notifs = !engine.notifications.is_empty(); + engine.tick_notifications(); + if had_notifs && engine.notifications.is_empty() { + self.draw_needed.set(true); + } + } // Update explorer tree indicators (modified/diagnostics) every ~1s. - if self.last_tree_indicator_update.elapsed() >= std::time::Duration::from_secs(1) { + // Skip while a cell is being edited (guard computed above). + if !cell_editing + && self.last_tree_indicator_update.elapsed() >= std::time::Duration::from_secs(1) + { self.last_tree_indicator_update = std::time::Instant::now(); if let Some(ref store) = self.tree_store { let engine = self.engine.borrow(); let (git_statuses, diag_counts) = engine.explorer_indicators(); let theme = Theme::from_name(&engine.settings.colorscheme); + let default_fg = theme.explorer_file_fg.to_hex(); update_tree_indicators( store, &git_statuses, @@ -5857,6 +5931,7 @@ impl App { &theme.git_deleted.to_hex(), &theme.diagnostic_error.to_hex(), &theme.diagnostic_warning.to_hex(), + &default_fg, ); } } @@ -6067,15 +6142,18 @@ impl App { self.draw_needed.set(true); } else { // ── Status bar branch click — open branch picker ───────────── + // (only when per-window status is off — global bar exists) if self.cached_line_height > 0.0 { let lh = self.cached_line_height; let engine = self.engine.borrow(); + let per_window_status = engine.settings.window_status_line; let wildmenu_px = if engine.wildmenu_items.is_empty() { 0.0 } else { lh }; - let status_bar_height = lh * 2.0 + wildmenu_px; + let global_status_rows = if per_window_status { 1.0 } else { 2.0 }; + let status_bar_height = lh * global_status_rows + wildmenu_px; let status_y = height - status_bar_height; if y >= status_y && y < status_y + lh && engine.git_branch.is_some() { // Reconstruct branch column range (matching build_status_line logic) @@ -6136,7 +6214,12 @@ impl App { if engine.terminal_open || engine.bottom_panel_open { let term_px = (engine.session.terminal_panel_rows as f64 + 2.0) * self.cached_line_height; - let status_h = 2.0 * self.cached_line_height; + let global_status_rows = if engine.settings.window_status_line { + 0.0 + } else { + 1.0 + }; + let status_h = (1.0 + global_status_rows) * self.cached_line_height; let toolbar_px = if engine.debug_toolbar_visible { self.cached_line_height } else { @@ -6168,6 +6251,15 @@ impl App { // Sans-serif chars are ~60% of monospace width; use that estimate. let cw = self.cached_char_width.max(1.0) * 0.6; let padding = 12.0; + // Close button (×) at right edge + if x >= width - padding - 10.0 { + let mut engine = self.engine.borrow_mut(); + engine.bottom_panel_open = false; + engine.close_terminal(); + drop(engine); + sender.input(Msg::Resize); + return; + } let terminal_label = "TERMINAL"; let debug_label = "DEBUG CONSOLE"; let terminal_w = padding + terminal_label.len() as f64 * cw + padding; @@ -6298,49 +6390,53 @@ impl App { let engine = self.engine.borrow(); if !engine.group_layout.is_single_group() { let lh = self.cached_line_height; - let wildmenu_px = if engine.wildmenu_items.is_empty() { - 0.0 - } else { - lh - }; - let status_h = lh * 2.0 + wildmenu_px; - let dbg_px = if engine.debug_toolbar_visible { - lh - } else { - 0.0 - }; - let qf_px = if engine.quickfix_open && !engine.quickfix_items.is_empty() { - 6.0 * lh - } else { - 0.0 - }; - let term_px = if engine.terminal_open || engine.bottom_panel_open { - (engine.session.terminal_panel_rows as f64 + 2.0) * lh + let tab_row_h = (lh * 1.6).ceil(); + let tab_bar_h = if engine.settings.breadcrumbs { + tab_row_h + lh } else { - 0.0 + tab_row_h }; - let editor_bottom = height - status_h - dbg_px - qf_px - term_px; + let editor_bottom = gtk_editor_bottom(&engine, width, height, lh); let content_bounds = core::window::WindowRect::new(0.0, 0.0, width, editor_bottom); - let dividers = engine.group_layout.dividers(content_bounds, &mut 0); - for div in ÷rs { - let hit = match div.direction { - core::window::SplitDirection::Vertical => { - (x - div.position).abs() < 6.0 - && y >= div.cross_start - && y < div.cross_start + div.cross_size - } - core::window::SplitDirection::Horizontal => { - (y - div.position).abs() < 6.0 - && x >= div.cross_start - && x < div.cross_start + div.cross_size + + // Compute tab bar regions so we can exclude them from + // divider drag — tab bar clicks should go to tab handlers. + let group_rects = engine + .group_layout + .calculate_group_rects(content_bounds, tab_bar_h); + let in_tab_bar = group_rects.iter().any(|(gid, grect)| { + if engine.is_tab_bar_hidden(*gid) { + return false; + } + let ty = grect.y - tab_bar_h; + y >= ty + && y < ty + tab_bar_h + && x >= grect.x + && x < grect.x + grect.width + }); + + if !in_tab_bar { + let dividers = engine.group_layout.dividers(content_bounds, &mut 0); + for div in ÷rs { + let hit = match div.direction { + core::window::SplitDirection::Vertical => { + (x - div.position).abs() < 6.0 + && y >= div.cross_start + && y < div.cross_start + div.cross_size + } + core::window::SplitDirection::Horizontal => { + (y - div.position).abs() < 6.0 + && x >= div.cross_start + && x < div.cross_start + div.cross_size + } + }; + if hit { + let si = div.split_index; + drop(engine); + self.group_divider_dragging = Some(si); + return; } - }; - if hit { - let si = div.split_index; - drop(engine); - self.group_divider_dragging = Some(si); - return; } } } @@ -6384,7 +6480,7 @@ impl App { if engine.is_vscode_mode() { engine.vscode_clear_selection(); } - let click_result = handle_mouse_click( + let (click_result, engine_action) = handle_mouse_click( &mut engine, x, y, @@ -6396,7 +6492,24 @@ impl App { &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), + &self.action_btn_map.borrow(), + &self.status_segment_map.borrow(), ); + match engine_action { + Some(core::engine::EngineAction::ToggleSidebar) => { + drop(engine); + sender.input(Msg::ToggleSidebar); + self.draw_needed.set(true); + return; + } + Some(core::engine::EngineAction::OpenTerminal) => { + drop(engine); + sender.input(Msg::ToggleTerminal); + self.draw_needed.set(true); + return; + } + _ => {} + } match click_result { Some(true) => { drop(engine); @@ -6408,6 +6521,25 @@ impl App { // Buffer click — fire hooks and reveal file } None => { + // Check if the click opened an editor action menu. + if engine.context_menu.as_ref().is_some_and(|cm| { + matches!( + cm.target, + core::engine::ContextMenuTarget::EditorActionMenu { .. } + ) + }) { + let group_id = + match &engine.context_menu.as_ref().unwrap().target { + core::engine::ContextMenuTarget::EditorActionMenu { + group_id, + } => *group_id, + _ => unreachable!(), + }; + drop(engine); + self.show_action_menu_popover(group_id, x, y, sender); + self.draw_needed.set(true); + return; + } // Tab bar / split button click — skip hooks. // Record drag start position for tab drag-and-drop. self.tab_drag_start = Some((x, y)); @@ -6525,6 +6657,8 @@ impl App { &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), + &self.action_btn_map.borrow(), + &self.status_segment_map.borrow(), ); if let ClickTarget::TabBar = target { // The tab was already switched by pixel_to_click_target. @@ -6624,7 +6758,12 @@ impl App { // Terminal panel resize drag. } else if self.terminal_resize_dragging { if self.cached_line_height > 0.0 { - let status_h = 2.0 * self.cached_line_height; + let global_status_rows = if self.engine.borrow().settings.window_status_line { + 0.0 + } else { + 1.0 + }; + let status_h = (1.0 + global_status_rows) * self.cached_line_height; let available = (height - y - status_h).max(0.0); let new_rows = ((available / self.cached_line_height) as u16) .saturating_sub(2) @@ -6644,7 +6783,12 @@ impl App { if term_rows > 0 { let term_px = (self.engine.borrow().session.terminal_panel_rows as f64 + 2.0) * self.cached_line_height; - let status_h = 2.0 * self.cached_line_height; + let global_status_rows = if self.engine.borrow().settings.window_status_line { + 0.0 + } else { + 1.0 + }; + let status_h = (1.0 + global_status_rows) * self.cached_line_height; let toolbar_px = if self.engine.borrow().debug_toolbar_visible { self.cached_line_height } else { @@ -6671,7 +6815,12 @@ impl App { if engine.terminal_open || engine.bottom_panel_open { let term_px = (engine.session.terminal_panel_rows as f64 + 2.0) * self.cached_line_height; - let status_h = 2.0 * self.cached_line_height; + let global_status_rows = if engine.settings.window_status_line { + 0.0 + } else { + 1.0 + }; + let status_h = (1.0 + global_status_rows) * self.cached_line_height; let toolbar_px = if engine.debug_toolbar_visible { self.cached_line_height } else { @@ -6713,6 +6862,8 @@ impl App { &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), + &self.action_btn_map.borrow(), + &self.status_segment_map.borrow(), ); self.draw_needed.set(true); } @@ -6783,6 +6934,84 @@ impl App { self.draw_needed.set(true); } + fn show_action_menu_popover( + &mut self, + group_id: core::window::GroupId, + x: f64, + y: f64, + _sender: &ComponentSender, + ) { + let da = match self.drawing_area.borrow().as_ref() { + Some(da) => da.clone(), + None => return, + }; + + // Extract the items from the engine context menu (already populated). + let items: Vec = { + let engine = self.engine.borrow(); + engine + .context_menu + .as_ref() + .map(|cm| cm.items.clone()) + .unwrap_or_default() + }; + // Close the engine-side context menu; GTK handles it natively. + self.engine.borrow_mut().close_context_menu(); + + let menu = build_gio_menu_from_engine_items(&items, "actmenu"); + + let enabled_map: std::collections::HashMap = items + .iter() + .map(|it| (it.action.clone(), it.enabled)) + .collect(); + + let actions = gtk4::gio::SimpleActionGroup::new(); + + // Register an action for each menu item that delegates to engine. + for item in &items { + let action_name = item.action.clone(); + let engine_ref = self.engine.clone(); + let draw_ref = self.draw_needed.clone(); + let gid = group_id; + let a = gtk4::gio::SimpleAction::new(&action_name, None); + let act = action_name.clone(); + a.connect_activate(move |_, _| { + let mut e = engine_ref.borrow_mut(); + e.active_group = gid; + // Re-open the context menu so confirm() can find items. + e.open_editor_action_menu(gid, 0, 0); + // Find and select the matching item. + if let Some(ref mut cm) = e.context_menu { + if let Some(idx) = cm.items.iter().position(|i| i.action == act) { + cm.selected = idx; + } + } + e.context_menu_confirm(); + draw_ref.set(true); + }); + if enabled_map.get(&action_name) == Some(&false) { + a.set_enabled(false); + } + actions.add_action(&a); + } + + da.insert_action_group("actmenu", Some(&actions)); + + let n_rows = menu_row_count(&menu); + swap_ctx_popover(&self.active_ctx_popover, { + let popover = gtk4::PopoverMenu::from_model(Some(&menu)); + popover.set_parent(&da); + popover.set_pointing_to(Some(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover.set_has_arrow(false); + popover.set_position(gtk4::PositionType::Bottom); + popover.set_size_request(-1, n_rows * 22 + 14); + popover + }); + if let Some(ref p) = *self.active_ctx_popover.borrow() { + p.popup(); + } + } + fn handle_tab_right_click( &mut self, group_id: core::window::GroupId, @@ -7442,6 +7671,9 @@ impl App { EngineAction::ToggleSidebar => { sender.input(Msg::ToggleSidebar); } + EngineAction::OpenTerminal => { + sender.input(Msg::NewTerminalTab); + } _ => {} } } @@ -8745,27 +8977,33 @@ impl App { } } ExplorerAction::Rename => { - // Trigger GTK native inline cell editing + // Trigger GTK native inline cell editing. + // Slight delay so any pending focus changes settle. let tv_ref = self.file_tree_view.clone(); let nc_ref = self.name_cell.clone(); - gtk4::glib::idle_add_local_once(move || { - if let Some(ref tv) = *tv_ref.borrow() { - if let Some(ref nc) = *nc_ref.borrow() { - nc.set_property("editable", true); - if let Some(column) = tv.column(0) { - if let Some((model, iter)) = tv.selection().selected() { - let tree_path = model.path(&iter); - gtk4::prelude::TreeViewExt::set_cursor( - tv, - &tree_path, - Some(&column), - true, - ); + gtk4::glib::timeout_add_local_once( + std::time::Duration::from_millis(50), + move || { + if let Some(ref tv) = *tv_ref.borrow() { + if let Some(ref nc) = *nc_ref.borrow() { + nc.set_property("editable", true); + if let Some(column) = tv.column(0) { + if let Some((model, iter)) = + tv.selection().selected() + { + let tree_path = model.path(&iter); + gtk4::prelude::TreeViewExt::set_cursor( + tv, + &tree_path, + Some(&column), + true, + ); + } } } } - } - }); + }, + ); } ExplorerAction::MoveFile => { // Move not yet supported via keyboard in GTK @@ -8783,18 +9021,17 @@ impl App { let cwd = self.engine.borrow().cwd.clone(); let (dir_fg_hex, file_fg_hex) = { let theme = Theme::from_name(&self.engine.borrow().settings.colorscheme); - let file_fg = if theme.is_light() { - theme.foreground.to_hex() - } else { - theme.status_fg.to_hex() - }; - (theme.explorer_dir_fg.to_hex(), file_fg) + ( + theme.explorer_dir_fg.to_hex(), + theme.explorer_file_fg.to_hex(), + ) }; store.clear(); build_file_tree_with_root( store, &cwd, self.engine.borrow().settings.show_hidden_files, + self.engine.borrow().settings.explorer_sort_case_insensitive, &dir_fg_hex, &file_fg_hex, ); @@ -8803,6 +9040,11 @@ impl App { let engine = self.engine.borrow(); let (git_statuses, diag_counts) = engine.explorer_indicators(); let theme = Theme::from_name(&engine.settings.colorscheme); + let default_fg = if theme.is_light() { + theme.foreground.to_hex() + } else { + theme.status_fg.to_hex() + }; update_tree_indicators( store, &git_statuses, @@ -8812,6 +9054,7 @@ impl App { &theme.git_deleted.to_hex(), &theme.diagnostic_error.to_hex(), &theme.diagnostic_warning.to_hex(), + &default_fg, ); } if let Some(ref tv) = *self.file_tree_view.borrow() { @@ -8942,27 +9185,31 @@ impl App { ); // Start inline editing on the new row. - // Wrapped in catch_unwind because GTK set_cursor with - // start_editing=true can abort the process if it panics - // inside an extern "C" callback. + // Delay slightly so the context menu popover has time to + // fully close and release focus — otherwise GTK fires + // `editing_canceled` immediately when focus shifts from + // the popover to the cell editor. let tv = tree_view.clone(); let name_cell_ref = self.name_cell.clone(); let new_row_path = tree_store.path(&new_iter); - gtk4::glib::idle_add_local_once(move || { - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - if let Some(ref nc) = *name_cell_ref.borrow() { - nc.set_property("editable", true); - if let Some(column) = tv.column(0) { - gtk4::prelude::TreeViewExt::set_cursor( - &tv, - &new_row_path, - Some(&column), - true, - ); + gtk4::glib::timeout_add_local_once( + std::time::Duration::from_millis(50), + move || { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(ref nc) = *name_cell_ref.borrow() { + nc.set_property("editable", true); + if let Some(column) = tv.column(0) { + gtk4::prelude::TreeViewExt::set_cursor( + &tv, + &new_row_path, + Some(&column), + true, + ); + } } - } - })); - }); + })); + }, + ); } } } @@ -9499,32 +9746,20 @@ fn calculate_gutter_width(mode: LineNumberMode, total_lines: usize, char_width: } } -/// Compute editor window rects with the same formula used by draw_editor and -/// sync_scrollbar, so event handlers can do hit-testing without duplicating the -/// layout logic. -fn compute_editor_window_rects( - engine: &Engine, - da_width: f64, - da_height: f64, - line_height: f64, -) -> Vec<(core::WindowId, core::WindowRect)> { - let tab_row_height = (line_height * 1.6).ceil(); - let tab_bar_height = if engine.settings.breadcrumbs { - tab_row_height + line_height - } else { - tab_row_height - }; +/// Compute the editor area bottom Y coordinate. Must match draw_editor (draw.rs) +/// so that group rects and divider positions are consistent across draw and click. +fn gtk_editor_bottom(engine: &Engine, _da_width: f64, da_height: f64, line_height: f64) -> f64 { let wildmenu_px = if engine.wildmenu_items.is_empty() { 0.0 } else { line_height }; - let status_bar_height = line_height * 2.0 + wildmenu_px; - let debug_toolbar_px = if engine.debug_toolbar_visible { - line_height + let global_status_rows = if engine.settings.window_status_line { + 1.0 } else { - 0.0 + 2.0 }; + let status_bar_height = line_height * global_status_rows + wildmenu_px; let qf_px = if engine.quickfix_open && !engine.quickfix_items.is_empty() { 6.0 * line_height } else { @@ -9535,11 +9770,34 @@ fn compute_editor_window_rects( } else { 0.0 }; + let debug_toolbar_px = if engine.debug_toolbar_visible { + line_height + } else { + 0.0 + }; + da_height - status_bar_height - debug_toolbar_px - qf_px - term_px +} + +/// Compute editor window rects with the same formula used by draw_editor and +/// sync_scrollbar, so event handlers can do hit-testing without duplicating the +/// layout logic. +fn compute_editor_window_rects( + engine: &Engine, + da_width: f64, + da_height: f64, + line_height: f64, +) -> Vec<(core::WindowId, core::WindowRect)> { + let tab_row_height = (line_height * 1.6).ceil(); + let tab_bar_height = if engine.settings.breadcrumbs { + tab_row_height + line_height + } else { + tab_row_height + }; let editor_bounds = core::WindowRect::new( 0.0, 0.0, da_width, - da_height - status_bar_height - debug_toolbar_px - qf_px - term_px, + gtk_editor_bottom(engine, da_width, da_height, line_height), ); let (rects, _dividers) = engine.calculate_group_window_rects(editor_bounds, tab_bar_height); rects diff --git a/src/gtk/tree.rs b/src/gtk/tree.rs index a356d0c8..db776f20 100644 --- a/src/gtk/tree.rs +++ b/src/gtk/tree.rs @@ -10,6 +10,7 @@ pub(super) fn build_file_tree_with_root( store: >k4::TreeStore, root: &Path, show_hidden: bool, + case_insensitive: bool, dir_fg_hex: &str, file_fg_hex: &str, ) { @@ -25,9 +26,9 @@ pub(super) fn build_file_tree_with_root( (0, &""), (1, &root_name), (2, &root.to_string_lossy().to_string()), - (3, &dir_fg_hex), + (3, &file_fg_hex), (4, &""), - (5, &dir_fg_hex), + (5, &file_fg_hex), ], ); build_file_tree_shallow( @@ -35,6 +36,7 @@ pub(super) fn build_file_tree_with_root( Some(&root_iter), root, show_hidden, + case_insensitive, dir_fg_hex, file_fg_hex, ); @@ -48,6 +50,7 @@ pub(super) fn build_file_tree_shallow( parent: Option<>k4::TreeIter>, path: &Path, show_hidden: bool, + case_insensitive: bool, dir_fg_hex: &str, file_fg_hex: &str, ) { @@ -65,7 +68,15 @@ pub(super) fn build_file_tree_shallow( match (a_is_dir, b_is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, - _ => a.file_name().cmp(&b.file_name()), + _ => { + if case_insensitive { + let an = a.file_name().to_string_lossy().to_lowercase(); + let bn = b.file_name().to_string_lossy().to_lowercase(); + an.cmp(&bn) + } else { + a.file_name().cmp(&b.file_name()) + } + } } }); @@ -88,7 +99,7 @@ pub(super) fn build_file_tree_shallow( crate::icons::file_icon(ext) }; - let fg_hex: &str = if is_dir { dir_fg_hex } else { file_fg_hex }; + let fg_hex: &str = file_fg_hex; let iter = store.insert_with_values( parent, None, @@ -126,6 +137,7 @@ pub(super) fn tree_row_expanded( store: >k4::TreeStore, iter: >k4::TreeIter, show_hidden: bool, + case_insensitive: bool, dir_fg_hex: &str, file_fg_hex: &str, ) { @@ -139,16 +151,20 @@ pub(super) fn tree_row_expanded( if let Some(child) = store.iter_children(Some(iter)) { let child_path: String = store.get_value(&child, 2).get().unwrap_or_default(); if child_path == TREE_DUMMY_PATH { - // Remove the dummy and populate real children. - store.remove(&child); + // Populate real children BEFORE removing the dummy so the + // directory never has zero children — GTK auto-collapses a + // row the instant its last child is removed, which caused + // the "first click swallowed" bug. build_file_tree_shallow( store, Some(iter), Path::new(&dir_path), show_hidden, + case_insensitive, dir_fg_hex, file_fg_hex, ); + store.remove(&child); } // If the first child is NOT the dummy, the directory was already // populated (e.g. collapsed and re-expanded) — nothing to do. @@ -167,6 +183,7 @@ pub(super) fn update_tree_indicators( deleted_color: &str, error_color: &str, warning_color: &str, + default_fg: &str, ) { use gtk4::prelude::TreeModelExt; #[allow(clippy::too_many_arguments)] @@ -180,13 +197,18 @@ pub(super) fn update_tree_indicators( deleted_color: &str, error_color: &str, warning_color: &str, + default_fg: &str, ) { let Some(iter) = store.iter_children(parent) else { return; }; loop { let path_str: String = store.get_value(&iter, 2).get().unwrap_or_default(); - if !path_str.is_empty() && path_str != TREE_DUMMY_PATH { + if !path_str.is_empty() + && path_str != TREE_DUMMY_PATH + && !path_str.starts_with("__NEW_FILE__") + && !path_str.starts_with("__NEW_FOLDER__") + { let p = PathBuf::from(&path_str); let canon = p.canonicalize().unwrap_or_else(|_| p.clone()); let git_label = git_statuses.get(&canon).copied(); @@ -229,10 +251,14 @@ pub(super) fn update_tree_indicators( let text = parts.join(" "); store.set_value(&iter, 4, &text.into()); store.set_value(&iter, 5, &color.into()); + // Set name foreground (column 3) to match the indicator color. + store.set_value(&iter, 3, &color.into()); } else { store.set_value(&iter, 4, &"".into()); // Use a valid color to avoid GTK "Don't know color ''" warnings. store.set_value(&iter, 5, &modified_color.into()); + // Reset name color to default. + store.set_value(&iter, 3, &default_fg.into()); } } // Recurse into children @@ -246,6 +272,7 @@ pub(super) fn update_tree_indicators( deleted_color, error_color, warning_color, + default_fg, ); if !store.iter_next(&iter) { break; @@ -262,6 +289,7 @@ pub(super) fn update_tree_indicators( deleted_color, error_color, warning_color, + default_fg, ); } diff --git a/src/icons.rs b/src/icons.rs index 48d73680..3ca4bb28 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -131,7 +131,7 @@ pub const DIFF_PREV: Icon = Icon::new("\u{F0143}", "<"); pub const DIFF_NEXT: Icon = Icon::new("\u{F0140}", ">"); pub const DIFF_FOLD: Icon = Icon::new("\u{F0233}", "="); pub const SPLIT_RIGHT: Icon = Icon::new("\u{F0932}", "|"); -pub const SPLIT_DOWN: Icon = Icon::new("\u{F0931}", "_"); +pub const SPLIT_DOWN: Icon = Icon::new("\u{f0d7}", "_"); // ─── File Icon Lookup ──────────────────────────────────────────────────────── diff --git a/src/render.rs b/src/render.rs index 6611ca00..e312262c 100644 --- a/src/render.rs +++ b/src/render.rs @@ -437,6 +437,30 @@ pub struct EditorGroupSplitData { pub num_groups: usize, } +// ─── Per-window status line ────────────────────────────────────────────────── + +// Re-export from core for use by backends. +pub use crate::core::engine::StatusAction; + +/// A styled segment of a per-window status line (e.g. mode badge, filename, cursor position). +#[derive(Debug, Clone)] +pub struct StatusSegment { + pub text: String, + pub fg: Color, + pub bg: Color, + pub bold: bool, + /// Action triggered when this segment is clicked, or `None` for non-interactive segments. + pub action: Option, +} + +/// Per-window status line data (Vim-style). Active windows get a rich, +/// colorful bar; inactive windows get a dimmed minimal bar. +#[derive(Debug, Clone)] +pub struct WindowStatusLine { + pub left_segments: Vec, + pub right_segments: Vec, +} + // ─── RenderedWindow ─────────────────────────────────────────────────────────── /// All data needed to render one editor window (pane). @@ -492,6 +516,8 @@ pub struct RenderedWindow { pub tabstop: usize, /// Whether to draw cursorline highlight (from `settings.cursorline`). pub cursorline: bool, + /// Per-window status line (Vim-style), or `None` when the setting is off. + pub status_line: Option, } // ─── CommandLineData ────────────────────────────────────────────────────────── @@ -1243,7 +1269,7 @@ pub static MENU_STRUCTURE: &[(&str, char, &[MenuItemData])] = &[ label: "Toggle Terminal", shortcut: "Ctrl+T", vscode_shortcut: "", - action: "term", + action: "terminal", enabled: true, separator: false, }, @@ -1509,7 +1535,7 @@ pub static MENU_STRUCTURE: &[(&str, char, &[MenuItemData])] = &[ label: "New Terminal", shortcut: "", vscode_shortcut: "", - action: "term", + action: "terminal", enabled: true, separator: false, }, @@ -1548,30 +1574,32 @@ pub static MENU_STRUCTURE: &[(&str, char, &[MenuItemData])] = &[ ]; /// Static debug toolbar button definitions. +/// Icons use the Unicode fallback glyphs (▶ ⏸ ⏹ ↻ etc.) which render +/// correctly in both TUI (any font) and GTK (no Nerd Font subset needed). pub static DEBUG_BUTTONS: &[DebugButton] = &[ DebugButton { - icon: icons::DBG_CONTINUE.nerd, + icon: icons::DBG_CONTINUE.fallback, label: "Continue", key_hint: "F5", action: "continue", enabled: true, }, DebugButton { - icon: icons::DBG_PAUSE.nerd, + icon: icons::DBG_PAUSE.fallback, label: "Pause", key_hint: "F6", action: "pause", enabled: true, }, DebugButton { - icon: icons::DBG_STOP.nerd, + icon: icons::DBG_STOP.fallback, label: "Stop", key_hint: "Shift+F5", action: "stop", enabled: true, }, DebugButton { - icon: icons::DBG_RESTART.nerd, + icon: icons::DBG_RESTART.fallback, label: "Restart", key_hint: "Ctrl+Shift+F5", action: "restart", @@ -1579,21 +1607,21 @@ pub static DEBUG_BUTTONS: &[DebugButton] = &[ }, // separator goes here (rendered between index 3 and 4) DebugButton { - icon: icons::DBG_STEP_OVER.nerd, + icon: icons::DBG_STEP_OVER.fallback, label: "Step Over", key_hint: "F10", action: "stepover", enabled: true, }, DebugButton { - icon: icons::DBG_RESTART.nerd, + icon: icons::DBG_RESTART.fallback, label: "Step Into", key_hint: "F11", action: "stepin", enabled: true, }, DebugButton { - icon: icons::DBG_STEP_OUT.nerd, + icon: icons::DBG_STEP_OUT.fallback, label: "Step Out", key_hint: "Shift+F11", action: "stepout", @@ -1759,6 +1787,18 @@ pub struct Theme { pub type_name: Color, pub variable: Color, pub number: Color, + pub control_flow: Color, + pub operator: Color, + pub punctuation: Color, + pub macro_call: Color, + pub attribute: Color, + pub lifetime: Color, + pub constant: Color, + pub escape: Color, + pub boolean: Color, + pub property: Color, + pub parameter: Color, + pub module: Color, /// Fallback foreground for unrecognised scopes. pub default_fg: Color, @@ -1799,6 +1839,14 @@ pub struct Theme { pub status_bg: Color, pub status_fg: Color, + // Per-window status line mode text tints + pub status_mode_normal_bg: Color, + pub status_mode_insert_bg: Color, + pub status_mode_visual_bg: Color, + pub status_mode_replace_bg: Color, + pub status_inactive_bg: Color, + pub status_inactive_fg: Color, + // Wildmenu (command Tab completion bar) pub wildmenu_bg: Color, pub wildmenu_fg: Color, @@ -1905,8 +1953,24 @@ pub struct Theme { // Explorer sidebar (TUI) /// Foreground for directory names in the file explorer. pub explorer_dir_fg: Color, + /// Foreground for file names in the file explorer (muted grey). + pub explorer_file_fg: Color, /// Background tint for rows whose file is open in a buffer. pub explorer_active_bg: Color, + + // Scrollbar + /// Scrollbar thumb (draggable part). + pub scrollbar_thumb: Color, + /// Scrollbar track (gutter behind thumb). + pub scrollbar_track: Color, + + // Integrated terminal + /// Default background for the integrated terminal pane. + pub terminal_bg: Color, + + // Activity bar + /// Foreground for activity bar icons. + pub activity_bar_fg: Color, } impl Theme { @@ -1923,12 +1987,24 @@ impl Theme { foreground: Color::from_hex("#e5e5e5"), keyword: Color::from_hex("#c678dd"), + control_flow: Color::from_hex("#c678dd"), string_lit: Color::from_hex("#98c379"), comment: Color::from_hex("#5c6370"), function: Color::from_hex("#61afef"), type_name: Color::from_hex("#e5c07b"), variable: Color::from_hex("#e06c75"), number: Color::from_hex("#d19a66"), + operator: Color::from_hex("#56b6c2"), + punctuation: Color::from_hex("#abb2bf"), + macro_call: Color::from_hex("#61afef"), + attribute: Color::from_hex("#e5c07b"), + lifetime: Color::from_hex("#e06c75"), + constant: Color::from_hex("#d19a66"), + escape: Color::from_hex("#56b6c2"), + boolean: Color::from_hex("#d19a66"), + property: Color::from_hex("#e06c75"), + parameter: Color::from_hex("#e06c75"), + module: Color::from_hex("#e5c07b"), default_fg: Color::from_hex("#abb2bf"), // (0.3, 0.5, 0.7) with alpha 0.3 @@ -1959,11 +2035,16 @@ impl Theme { tab_preview_inactive_fg: Color::from_hex("#7f7f7f"), tab_active_accent: Color::from_hex("#61afef"), - // (0.2, 0.2, 0.3) status_bg: Color::from_hex("#33334c"), - // (0.9, 0.9, 0.9) status_fg: Color::from_hex("#e5e5e5"), + status_mode_normal_bg: Color::from_hex("#61afef"), + status_mode_insert_bg: Color::from_hex("#98c379"), + status_mode_visual_bg: Color::from_hex("#c678dd"), + status_mode_replace_bg: Color::from_hex("#e06c75"), + status_inactive_bg: Color::from_hex("#262626"), + status_inactive_fg: Color::from_hex("#808080"), + wildmenu_bg: Color::from_hex("#33334c"), wildmenu_fg: Color::from_hex("#abb2bf"), wildmenu_sel_bg: Color::from_hex("#e5c07b"), @@ -2063,7 +2144,13 @@ impl Theme { bracket_match_bg: Color::from_hex("#3a3d41"), explorer_dir_fg: Color::from_hex("#61afef"), // function blue + explorer_file_fg: Color::from_hex("#aab1be"), // muted grey (matches OneDark sidebar) explorer_active_bg: Color::from_hex("#333842"), // current-file tint + + scrollbar_thumb: Color::from_hex("#5a5a5a"), + scrollbar_track: Color::from_hex("#1a1a1a"), + terminal_bg: Color::from_hex("#1e1e1e"), + activity_bar_fg: Color::from_hex("#c8c8d2"), } } @@ -2075,12 +2162,24 @@ impl Theme { foreground: Color::from_hex("#ebdbb2"), keyword: Color::from_hex("#fb4934"), + control_flow: Color::from_hex("#fb4934"), string_lit: Color::from_hex("#b8bb26"), comment: Color::from_hex("#928374"), function: Color::from_hex("#8ec07c"), type_name: Color::from_hex("#fabd2f"), variable: Color::from_hex("#83a598"), number: Color::from_hex("#d3869b"), + operator: Color::from_hex("#8ec07c"), + punctuation: Color::from_hex("#ebdbb2"), + macro_call: Color::from_hex("#8ec07c"), + attribute: Color::from_hex("#fabd2f"), + lifetime: Color::from_hex("#fb4934"), + constant: Color::from_hex("#d3869b"), + escape: Color::from_hex("#8ec07c"), + boolean: Color::from_hex("#d3869b"), + property: Color::from_hex("#83a598"), + parameter: Color::from_hex("#83a598"), + module: Color::from_hex("#fabd2f"), default_fg: Color::from_hex("#ebdbb2"), selection: Color::from_hex("#458588"), @@ -2104,6 +2203,13 @@ impl Theme { status_bg: Color::from_hex("#504945"), status_fg: Color::from_hex("#ebdbb2"), + status_mode_normal_bg: Color::from_hex("#83a598"), + status_mode_insert_bg: Color::from_hex("#b8bb26"), + status_mode_visual_bg: Color::from_hex("#d3869b"), + status_mode_replace_bg: Color::from_hex("#fb4934"), + status_inactive_bg: Color::from_hex("#303030"), + status_inactive_fg: Color::from_hex("#808080"), + wildmenu_bg: Color::from_hex("#504945"), wildmenu_fg: Color::from_hex("#ebdbb2"), wildmenu_sel_bg: Color::from_hex("#fabd2f"), @@ -2186,7 +2292,13 @@ impl Theme { bracket_match_bg: Color::from_hex("#504945"), explorer_dir_fg: Color::from_hex("#83a598"), // gruvbox blue + explorer_file_fg: Color::from_hex("#bdae93"), // gruvbox muted explorer_active_bg: Color::from_hex("#45403d"), // current-file tint + + scrollbar_thumb: Color::from_hex("#665c54"), + scrollbar_track: Color::from_hex("#282828"), + terminal_bg: Color::from_hex("#282828"), + activity_bar_fg: Color::from_hex("#bdae93"), } } @@ -2198,12 +2310,24 @@ impl Theme { foreground: Color::from_hex("#c0caf5"), keyword: Color::from_hex("#bb9af7"), + control_flow: Color::from_hex("#bb9af7"), string_lit: Color::from_hex("#9ece6a"), comment: Color::from_hex("#565f89"), function: Color::from_hex("#7aa2f7"), type_name: Color::from_hex("#e0af68"), variable: Color::from_hex("#f7768e"), number: Color::from_hex("#ff9e64"), + operator: Color::from_hex("#89ddff"), + punctuation: Color::from_hex("#a9b1d6"), + macro_call: Color::from_hex("#7aa2f7"), + attribute: Color::from_hex("#e0af68"), + lifetime: Color::from_hex("#f7768e"), + constant: Color::from_hex("#ff9e64"), + escape: Color::from_hex("#89ddff"), + boolean: Color::from_hex("#ff9e64"), + property: Color::from_hex("#73daca"), + parameter: Color::from_hex("#e0af68"), + module: Color::from_hex("#e0af68"), default_fg: Color::from_hex("#a9b1d6"), selection: Color::from_hex("#364a82"), @@ -2227,6 +2351,13 @@ impl Theme { status_bg: Color::from_hex("#292e42"), status_fg: Color::from_hex("#c0caf5"), + status_mode_normal_bg: Color::from_hex("#7aa2f7"), + status_mode_insert_bg: Color::from_hex("#9ece6a"), + status_mode_visual_bg: Color::from_hex("#bb9af7"), + status_mode_replace_bg: Color::from_hex("#f7768e"), + status_inactive_bg: Color::from_hex("#262626"), + status_inactive_fg: Color::from_hex("#808080"), + wildmenu_bg: Color::from_hex("#292e42"), wildmenu_fg: Color::from_hex("#c0caf5"), wildmenu_sel_bg: Color::from_hex("#e0af68"), @@ -2309,7 +2440,13 @@ impl Theme { bracket_match_bg: Color::from_hex("#364a82"), explorer_dir_fg: Color::from_hex("#7aa2f7"), // tokyo blue + explorer_file_fg: Color::from_hex("#a9b1d6"), // tokyo muted explorer_active_bg: Color::from_hex("#2f3550"), // current-file tint + + scrollbar_thumb: Color::from_hex("#565f89"), + scrollbar_track: Color::from_hex("#1a1b26"), + terminal_bg: Color::from_hex("#1a1b26"), + activity_bar_fg: Color::from_hex("#a9b1d6"), } } @@ -2321,12 +2458,24 @@ impl Theme { foreground: Color::from_hex("#839496"), keyword: Color::from_hex("#859900"), + control_flow: Color::from_hex("#859900"), string_lit: Color::from_hex("#2aa198"), comment: Color::from_hex("#586e75"), function: Color::from_hex("#268bd2"), type_name: Color::from_hex("#b58900"), variable: Color::from_hex("#dc322f"), number: Color::from_hex("#2aa198"), + operator: Color::from_hex("#859900"), + punctuation: Color::from_hex("#93a1a1"), + macro_call: Color::from_hex("#268bd2"), + attribute: Color::from_hex("#b58900"), + lifetime: Color::from_hex("#dc322f"), + constant: Color::from_hex("#2aa198"), + escape: Color::from_hex("#cb4b16"), + boolean: Color::from_hex("#2aa198"), + property: Color::from_hex("#268bd2"), + parameter: Color::from_hex("#93a1a1"), + module: Color::from_hex("#b58900"), default_fg: Color::from_hex("#93a1a1"), selection: Color::from_hex("#073642"), @@ -2350,6 +2499,13 @@ impl Theme { status_bg: Color::from_hex("#073642"), status_fg: Color::from_hex("#93a1a1"), + status_mode_normal_bg: Color::from_hex("#268bd2"), + status_mode_insert_bg: Color::from_hex("#859900"), + status_mode_visual_bg: Color::from_hex("#6c71c4"), + status_mode_replace_bg: Color::from_hex("#dc322f"), + status_inactive_bg: Color::from_hex("#121212"), + status_inactive_fg: Color::from_hex("#6c6c6c"), + wildmenu_bg: Color::from_hex("#073642"), wildmenu_fg: Color::from_hex("#93a1a1"), wildmenu_sel_bg: Color::from_hex("#b58900"), @@ -2432,7 +2588,13 @@ impl Theme { bracket_match_bg: Color::from_hex("#0d4a5a"), explorer_dir_fg: Color::from_hex("#268bd2"), // solarized blue + explorer_file_fg: Color::from_hex("#93a1a1"), // solarized base1 explorer_active_bg: Color::from_hex("#0a4050"), // current-file tint + + scrollbar_thumb: Color::from_hex("#586e75"), + scrollbar_track: Color::from_hex("#002b36"), + terminal_bg: Color::from_hex("#002b36"), + activity_bar_fg: Color::from_hex("#93a1a1"), } } @@ -2443,13 +2605,25 @@ impl Theme { active_background: Color::from_hex("#252526"), foreground: Color::from_hex("#d4d4d4"), - keyword: Color::from_hex("#569cd6"), // blue + keyword: Color::from_hex("#569cd6"), // blue (storage: let, fn, struct) + control_flow: Color::from_hex("#c586c0"), // purple (if, else, for, return) string_lit: Color::from_hex("#ce9178"), // salmon - comment: Color::from_hex("#6a9955"), // green - function: Color::from_hex("#dcdcaa"), // yellow - type_name: Color::from_hex("#4ec9b0"), // teal - variable: Color::from_hex("#9cdcfe"), // light blue - number: Color::from_hex("#b5cea8"), // light green + comment: Color::from_hex("#6a9955"), // green + function: Color::from_hex("#dcdcaa"), // yellow + type_name: Color::from_hex("#4ec9b0"), // teal + variable: Color::from_hex("#9cdcfe"), // light blue + number: Color::from_hex("#b5cea8"), // light green + operator: Color::from_hex("#d4d4d4"), + punctuation: Color::from_hex("#d4d4d4"), + macro_call: Color::from_hex("#dcdcaa"), + attribute: Color::from_hex("#4ec9b0"), + lifetime: Color::from_hex("#569cd6"), + constant: Color::from_hex("#4fc1ff"), + escape: Color::from_hex("#d7ba7d"), + boolean: Color::from_hex("#569cd6"), + property: Color::from_hex("#9cdcfe"), + parameter: Color::from_hex("#9cdcfe"), + module: Color::from_hex("#4ec9b0"), default_fg: Color::from_hex("#d4d4d4"), selection: Color::from_hex("#264f78"), @@ -2473,6 +2647,13 @@ impl Theme { status_bg: Color::from_hex("#007acc"), status_fg: Color::from_hex("#ffffff"), + status_mode_normal_bg: Color::from_hex("#007acc"), + status_mode_insert_bg: Color::from_hex("#16825d"), + status_mode_visual_bg: Color::from_hex("#68217a"), + status_mode_replace_bg: Color::from_hex("#c72e0f"), + status_inactive_bg: Color::from_hex("#262626"), + status_inactive_fg: Color::from_hex("#808080"), + wildmenu_bg: Color::from_hex("#252526"), wildmenu_fg: Color::from_hex("#d4d4d4"), wildmenu_sel_bg: Color::from_hex("#04395e"), @@ -2555,7 +2736,13 @@ impl Theme { bracket_match_bg: Color::from_hex("#3a3d41"), explorer_dir_fg: Color::from_hex("#dcdcaa"), // warm yellow (like function names) + explorer_file_fg: Color::from_hex("#bbbbbb"), // VSCode default sidebar fg explorer_active_bg: Color::from_hex("#2a2d3e"), // current-file tint + + scrollbar_thumb: Color::from_hex("#5a5a5a"), + scrollbar_track: Color::from_hex("#1e1e1e"), + terminal_bg: Color::from_hex("#1e1e1e"), + activity_bar_fg: Color::from_hex("#c8c8d2"), } } @@ -2566,13 +2753,25 @@ impl Theme { active_background: Color::from_hex("#f3f3f3"), foreground: Color::from_hex("#333333"), - keyword: Color::from_hex("#0000ff"), // blue + keyword: Color::from_hex("#0000ff"), // blue (storage) + control_flow: Color::from_hex("#af00db"), // purple (if, else, for, return) string_lit: Color::from_hex("#a31515"), // red - comment: Color::from_hex("#008000"), // green - function: Color::from_hex("#795e26"), // brown - type_name: Color::from_hex("#267f99"), // teal - variable: Color::from_hex("#001080"), // dark blue - number: Color::from_hex("#098658"), // green + comment: Color::from_hex("#008000"), // green + function: Color::from_hex("#795e26"), // brown + type_name: Color::from_hex("#267f99"), // teal + variable: Color::from_hex("#001080"), // dark blue + number: Color::from_hex("#098658"), // green + operator: Color::from_hex("#333333"), + punctuation: Color::from_hex("#333333"), + macro_call: Color::from_hex("#795e26"), + attribute: Color::from_hex("#267f99"), + lifetime: Color::from_hex("#0000ff"), + constant: Color::from_hex("#0070c1"), + escape: Color::from_hex("#ee0000"), + boolean: Color::from_hex("#0000ff"), + property: Color::from_hex("#001080"), + parameter: Color::from_hex("#001080"), + module: Color::from_hex("#267f99"), default_fg: Color::from_hex("#333333"), selection: Color::from_hex("#add6ff"), @@ -2596,6 +2795,13 @@ impl Theme { status_bg: Color::from_hex("#007acc"), status_fg: Color::from_hex("#ffffff"), + status_mode_normal_bg: Color::from_hex("#007acc"), + status_mode_insert_bg: Color::from_hex("#16825d"), + status_mode_visual_bg: Color::from_hex("#68217a"), + status_mode_replace_bg: Color::from_hex("#c72e0f"), + status_inactive_bg: Color::from_hex("#e0e0e0"), + status_inactive_fg: Color::from_hex("#666666"), + wildmenu_bg: Color::from_hex("#f3f3f3"), wildmenu_fg: Color::from_hex("#333333"), wildmenu_sel_bg: Color::from_hex("#0060c0"), @@ -2677,7 +2883,13 @@ impl Theme { bracket_match_bg: Color::from_hex("#dddddd"), explorer_dir_fg: Color::from_hex("#795e26"), // warm brown dirs + explorer_file_fg: Color::from_hex("#3b3b3b"), // VSCode light sidebar fg explorer_active_bg: Color::from_hex("#dce5f0"), // current-file tint + + scrollbar_thumb: Color::from_hex("#b0b0b0"), + scrollbar_track: Color::from_hex("#f3f3f3"), + terminal_bg: Color::from_hex("#ffffff"), + activity_bar_fg: Color::from_hex("#646e6e"), } } @@ -2880,6 +3092,22 @@ impl Theme { theme.sidebar_sel_bg_inactive = c; theme.explorer_active_bg = c; } + if let Some(c) = color("sideBar.foreground") { + theme.explorer_file_fg = c; + } + + // ── Scrollbar / terminal / activity bar ───────────────────────── + if let Some(c) = color("scrollbarSlider.background") { + theme.scrollbar_thumb = c; + // VSCode doesn't have a separate track colour; derive from background + theme.scrollbar_track = theme.background; + } + if let Some(c) = color("terminal.background") { + theme.terminal_bg = c; + } + if let Some(c) = color("activityBar.foreground") { + theme.activity_bar_fg = c; + } // ── Breadcrumbs ────────────────────────────────────────────────── if let Some(c) = color("breadcrumb.background") { @@ -2979,10 +3207,17 @@ impl Theme { }; for scope in &scopes { match *scope { - "keyword" | "keyword.control" | "keyword.operator" | "storage" - | "storage.type" | "storage.modifier" => { + "keyword" | "storage" | "storage.type" | "storage.modifier" => { theme.keyword = fg; } + "keyword.control" + | "keyword.control.flow" + | "keyword.control.conditional" + | "keyword.control.loop" + | "keyword.control.trycatch" + | "keyword.control.import" => { + theme.control_flow = fg; + } "string" | "string.quoted" | "string.quoted.double" @@ -3028,6 +3263,34 @@ impl Theme { } "entity.name.function.macro" | "support.function.macro" => { theme.semantic_macro = fg; + theme.macro_call = fg; + } + "keyword.operator" + | "keyword.operator.expression" + | "keyword.operator.logical" => { + theme.operator = fg; + } + "punctuation" + | "punctuation.definition" + | "punctuation.bracket" + | "punctuation.separator" => { + theme.punctuation = fg; + } + "entity.other.attribute-name" | "meta.attribute" => { + theme.attribute = fg; + } + "storage.modifier.lifetime" | "punctuation.definition.lifetime" => { + theme.lifetime = fg; + } + "constant" | "constant.language" | "constant.other" => { + theme.constant = fg; + theme.boolean = fg; + } + "constant.character.escape" => { + theme.escape = fg; + } + "entity.name.namespace" | "entity.name.module" => { + theme.module = fg; } _ => {} } @@ -3053,13 +3316,28 @@ impl Theme { /// Return the foreground colour for a Tree-sitter scope name. pub fn scope_color(&self, scope: &str) -> Color { match scope { - "keyword" | "operator" => self.keyword, + "keyword" => self.keyword, + "keyword.control" => self.control_flow, + "operator" => self.operator, "string" => self.string_lit, "comment" => self.comment, - "function" | "method" => self.function, - "type" | "class" | "struct" => self.type_name, + "function" | "function.call" | "method" | "method.call" => self.function, + "type" | "class" | "struct" | "enum" | "interface" => self.type_name, "variable" => self.variable, "number" => self.number, + "boolean" => self.boolean, + "constant" => self.constant, + "punctuation" + | "punctuation.bracket" + | "punctuation.delimiter" + | "punctuation.special" => self.punctuation, + "macro" | "macro_call" => self.macro_call, + "attribute" => self.attribute, + "lifetime" => self.lifetime, + "escape" => self.escape, + "module" | "namespace" => self.module, + "parameter" => self.parameter, + "property" | "field" => self.property, _ => self.default_fg, } } @@ -3077,14 +3355,25 @@ impl Theme { "decorator" => self.semantic_decorator, "macro" => self.semantic_macro, // Reuse existing syntax colors for standard token types - "keyword" | "modifier" => self.keyword, + "keyword" | "modifier" => { + // rust-analyzer sends "controlFlow" modifier for if/else/for/while/return etc. + if modifiers.iter().any(|m| m == "controlFlow") { + self.control_flow + } else { + self.keyword + } + } "function" | "method" => self.function, "type" | "class" | "struct" | "enum" => self.type_name, "variable" => self.variable, "string" | "regexp" => self.string_lit, "comment" => self.comment, "number" => self.number, - "operator" => self.keyword, + "operator" => self.operator, + "boolean" => self.boolean, + "lifetime" => self.lifetime, + "attribute" | "attributeBracket" => self.attribute, + "builtinType" => self.type_name, _ => return None, }; let bold = modifiers @@ -3130,12 +3419,17 @@ pub fn build_screen_layout( let tab_bar = build_tab_bar(engine); + let per_window_status = engine.settings.window_status_line; + let windows = window_rects .iter() .map(|(window_id, rect)| { - let visible_lines = (rect.height / line_height).floor() as usize; + let mut visible_lines = (rect.height / line_height).floor() as usize; + if per_window_status && visible_lines > 1 { + visible_lines -= 1; // reserve bottom row for per-window status bar + } let is_active = *window_id == active_window_id; - build_rendered_window( + let mut rw = build_rendered_window( engine, theme, *window_id, @@ -3145,11 +3439,21 @@ pub fn build_screen_layout( is_active, multi_window, color_headings, - ) + ); + if per_window_status { + rw.status_line = Some(build_window_status_line( + engine, theme, *window_id, is_active, + )); + } + rw }) .collect(); - let (status_left, status_right, status_branch_range) = build_status_line(engine); + let (status_left, status_right, status_branch_range) = if per_window_status { + (String::new(), String::new(), None) + } else { + build_status_line(engine) + }; let command = build_command_line(engine); let wildmenu = if engine.wildmenu_items.is_empty() { @@ -4612,6 +4916,7 @@ fn build_rendered_window( active_indent_col: None, tabstop: engine.settings.tabstop.max(1) as usize, cursorline: engine.settings.cursorline, + status_line: None, }; let window = match engine.windows.get(&window_id) { @@ -5458,6 +5763,7 @@ fn build_rendered_window( } }, cursorline: engine.settings.cursorline, + status_line: None, } } @@ -6211,6 +6517,335 @@ fn build_status_line(engine: &Engine) -> (String, String, Option<(usize, usize)> (left, right, branch_range) } +/// Build a per-window status line for a given window. +/// Active windows get a rich, colorful bar; inactive windows get dimmed minimal info. +pub fn build_window_status_line( + engine: &Engine, + theme: &Theme, + window_id: WindowId, + is_active: bool, +) -> WindowStatusLine { + let window = engine.windows.get(&window_id); + let buffer_state = window.and_then(|w| engine.buffer_manager.get(w.buffer_id)); + let view = window.map(|w| &w.view); + + // Filename + let filename = buffer_state + .and_then(|s| s.file_path.as_ref()) + .and_then(|p| p.file_name()) + .map(|f| f.to_string_lossy().into_owned()) + .or_else(|| buffer_state.and_then(|s| s.scratch_name.as_ref()).cloned()) + .unwrap_or_else(|| "[No Name]".to_string()); + + let dirty = buffer_state.is_some_and(|s| s.dirty); + let cursor = view.map(|v| &v.cursor); + // Filetype from path + let filetype = buffer_state + .and_then(|s| s.file_path.as_ref()) + .and_then(|p| crate::core::lsp::language_id_from_path(p)) + .unwrap_or_default(); + + // Derive per-window status bar colors from the editor background. + // Active: bg shifted ~10% from editor bg (lighter on dark themes, darker on light). + // Inactive: uses theme's status_inactive_bg/fg. + let lum = 0.299 * theme.background.r as f64 + + 0.587 * theme.background.g as f64 + + 0.114 * theme.background.b as f64; + let bar_bg = if lum < 128.0 { + theme.background.lighten(0.10) + } else { + theme.background.darken(0.10) + }; + let bar_fg = theme.foreground; + + // Mode text color — use the mode badge color as a subtle text tint + let mode_color = match engine.mode { + Mode::Insert => theme.status_mode_insert_bg, + Mode::Visual | Mode::VisualLine | Mode::VisualBlock => theme.status_mode_visual_bg, + Mode::Replace => theme.status_mode_replace_bg, + _ => bar_fg, // normal mode: just use regular fg + }; + + // Indentation display text + let indent_text = if engine.settings.expand_tab { + format!("Spaces: {} ", engine.settings.tabstop) + } else { + format!("Tab Size: {} ", engine.settings.tabstop) + }; + + // Line ending display + let line_ending_str = buffer_state.map(|s| s.line_ending.as_str()).unwrap_or("LF"); + + if is_active { + // ── Active: MODE filename [+] branch | filetype indent encoding eol Ln:Col ── + let mode_str = engine.mode_str(); + + let mut left = vec![ + StatusSegment { + text: format!(" {} ", mode_str), + fg: mode_color, + bg: bar_bg, + bold: true, + action: None, + }, + StatusSegment { + text: format!(" {}", filename), + fg: bar_fg, + bg: bar_bg, + bold: true, + action: None, + }, + ]; + + if dirty { + left.push(StatusSegment { + text: " [+]".to_string(), + fg: bar_fg, + bg: bar_bg, + bold: false, + action: None, + }); + } + + // Recording indicator + if let Some(reg) = engine.macro_recording { + left.push(StatusSegment { + text: format!(" [rec @{}]", reg), + fg: theme.status_mode_replace_bg, + bg: bar_bg, + bold: true, + action: None, + }); + } + + // Git branch + if let Some(b) = engine.git_branch.as_deref() { + let mut branch_text = b.to_string(); + if engine.sc_ahead > 0 || engine.sc_behind > 0 { + let mut parts = Vec::new(); + if engine.sc_ahead > 0 { + parts.push(format!("↑{}", engine.sc_ahead)); + } + if engine.sc_behind > 0 { + parts.push(format!("↓{}", engine.sc_behind)); + } + branch_text = format!("{} {}", branch_text, parts.join(" ")); + } + left.push(StatusSegment { + text: format!(" {}", branch_text), + fg: bar_fg, + bg: bar_bg, + bold: false, + action: Some(StatusAction::SwitchBranch), + }); + } + + // LSP status segment — server_has_responded in LspManager already tracks + // whether the server is fully ready (responded to hover/definition/etc.). + let lsp_status = window + .map(|w| engine.lsp_status_for_buffer(w.buffer_id)) + .unwrap_or(crate::core::lsp_manager::LspStatus::None); + + // Right side: LSP filetype indent utf-8 LF/CRLF Ln:Col + let mut right = Vec::new(); + { + use crate::core::lsp_manager::LspStatus; + let (lsp_text, lsp_fg) = match &lsp_status { + LspStatus::Running(name) => (Some(format!("{} ", name)), bar_fg), + LspStatus::Initializing(name) => { + let label = if name.is_empty() { "LSP" } else { name }; + (Some(format!("{}… ", label)), theme.status_inactive_fg) + } + LspStatus::Installing => (Some("LSP↓ ".to_string()), theme.status_inactive_fg), + LspStatus::Crashed => (Some("LSP✗ ".to_string()), theme.status_mode_replace_bg), + LspStatus::None => (None, bar_fg), + }; + if let Some(text) = lsp_text { + right.push(StatusSegment { + text, + fg: lsp_fg, + bg: bar_bg, + bold: false, + action: Some(StatusAction::LspInfo), + }); + } + } + if !filetype.is_empty() { + right.push(StatusSegment { + text: format!("{} ", filetype), + fg: bar_fg, + bg: bar_bg, + bold: false, + action: Some(StatusAction::ChangeLanguage), + }); + } + right.push(StatusSegment { + text: indent_text.clone(), + fg: bar_fg, + bg: bar_bg, + bold: false, + action: Some(StatusAction::ChangeIndentation), + }); + right.push(StatusSegment { + text: "utf-8 ".to_string(), + fg: bar_fg, + bg: bar_bg, + bold: false, + action: Some(StatusAction::ChangeEncoding), + }); + right.push(StatusSegment { + text: format!("{} ", line_ending_str), + fg: bar_fg, + bg: bar_bg, + bold: false, + action: Some(StatusAction::ChangeLineEnding), + }); + if let Some(c) = cursor { + right.push(StatusSegment { + text: format!(" Ln {}, Col {} ", c.line + 1, c.col + 1), + fg: bar_fg, + bg: bar_bg, + bold: false, + action: Some(StatusAction::GoToLine), + }); + } + + // Notification indicator — spinner for in-progress, bell for done + if !engine.notifications.is_empty() { + let nf = crate::icons::nerd_fonts_enabled(); + let has_active = engine.has_active_notifications(); + let has_done = engine.has_done_notifications(); + let (icon, fg_color) = if has_active { + // Spinner icon for in-progress operations + let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let elapsed = engine + .notifications + .iter() + .filter(|n| !n.done) + .map(|n| n.created_at) + .min() + .map(|t| t.elapsed().as_millis() as usize / 100) + .unwrap_or(0); + let frame = frames[elapsed % frames.len()]; + (format!("{frame}"), theme.function) // use function color (blue-ish) for spinner + } else if has_done { + // Bell icon for completed notifications + let bell: &str = if nf { "󰂞" } else { "*" }; + (bell.to_string(), theme.string_lit) // use string color (green-ish) for done + } else { + (String::new(), bar_fg) + }; + if !icon.is_empty() { + // Show the most recent notification message (truncated) + let msg = engine + .notifications + .last() + .map(|n| { + if n.message.len() > 30 { + format!("{}…", &n.message[..29]) + } else { + n.message.clone() + } + }) + .unwrap_or_default(); + let action = if has_done { + Some(StatusAction::DismissNotifications) + } else { + None + }; + right.push(StatusSegment { + text: format!(" {icon} {msg} "), + fg: fg_color, + bg: bar_bg, + bold: false, + action, + }); + } + } + + // Layout toggle buttons — dim when inactive, normal when active + let toggle_fg = |active: bool| { + if active { + bar_fg + } else { + theme.status_inactive_fg + } + }; + + let nf = crate::icons::nerd_fonts_enabled(); + + let sidebar_active = engine.session.explorer_visible; + right.push(StatusSegment { + text: if nf { " 󰘖 " } else { " [S] " }.to_string(), + fg: toggle_fg(sidebar_active), + bg: bar_bg, + bold: false, + action: Some(StatusAction::ToggleSidebar), + }); + + let panel_active = engine.terminal_open || engine.bottom_panel_open; + right.push(StatusSegment { + text: if nf { " 󰆍 " } else { " [P] " }.to_string(), + fg: toggle_fg(panel_active), + bg: bar_bg, + bold: false, + action: Some(StatusAction::TogglePanel), + }); + + if engine.menu_bar_toggleable { + let menu_active = engine.menu_bar_visible; + right.push(StatusSegment { + text: if nf { " 󰍜 " } else { " [M] " }.to_string(), + fg: toggle_fg(menu_active), + bg: bar_bg, + bold: false, + action: Some(StatusAction::ToggleMenuBar), + }); + } + + WindowStatusLine { + left_segments: left, + right_segments: right, + } + } else { + // ── Inactive window: filename [+] | Ln:Col ── + let mut left = vec![StatusSegment { + text: format!(" {}", filename), + fg: theme.status_inactive_fg, + bg: theme.status_inactive_bg, + bold: false, + action: None, + }]; + + if dirty { + left.push(StatusSegment { + text: " [+]".to_string(), + fg: theme.status_inactive_fg, + bg: theme.status_inactive_bg, + bold: false, + action: None, + }); + } + + let right = if let Some(c) = cursor { + vec![StatusSegment { + text: format!("Ln {}, Col {} ", c.line + 1, c.col + 1), + fg: theme.status_inactive_fg, + bg: theme.status_inactive_bg, + bold: false, + action: None, + }] + } else { + vec![] + }; + + WindowStatusLine { + left_segments: left, + right_segments: right, + } + } +} + fn build_command_line(engine: &Engine) -> CommandLineData { let (text, right_align, show_cursor, cursor_anchor_text) = match engine.mode { Mode::Command if engine.history_search_active => { @@ -6460,4 +7095,290 @@ mod tests { assert_eq!(first_line.spell_errors[0].start_col, 4); assert_eq!(first_line.spell_errors[0].end_col, 8); } + + // ── Per-window status line tests ───────────────────────────────────────── + + #[test] + fn test_window_status_line_active() { + use crate::core::engine::Engine; + let mut engine = Engine::new(); + engine.settings.window_status_line = true; + engine.buffer_mut().insert(0, "hello world\nsecond line\n"); + + let theme = Theme::onedark(); + let wid = engine.active_window_id(); + let status = build_window_status_line(&engine, &theme, wid, true); + + // Active window should have a mode badge as the first left segment + assert!(!status.left_segments.is_empty()); + assert!( + status.left_segments[0].text.contains("NORMAL"), + "expected NORMAL mode badge, got '{}'", + status.left_segments[0].text + ); + assert!(status.left_segments[0].bold); + + // Should have right segments with cursor position + assert!(!status.right_segments.is_empty()); + let right_text: String = status + .right_segments + .iter() + .map(|s| s.text.clone()) + .collect(); + assert!( + right_text.contains("Ln 1"), + "expected cursor position, got '{}'", + right_text + ); + } + + #[test] + fn test_window_status_line_inactive() { + use crate::core::engine::Engine; + let mut engine = Engine::new(); + engine.settings.window_status_line = true; + engine.buffer_mut().insert(0, "hello\n"); + + let theme = Theme::onedark(); + let wid = engine.active_window_id(); + let status = build_window_status_line(&engine, &theme, wid, false); + + // Inactive should NOT have mode badge + assert!(!status.left_segments.is_empty()); + assert!( + !status.left_segments[0].text.contains("NORMAL"), + "inactive status should not contain mode badge" + ); + // All segments should use inactive colors + for seg in &status.left_segments { + assert_eq!(seg.fg, theme.status_inactive_fg); + } + } + + #[test] + fn test_window_status_line_dirty_indicator() { + use crate::core::engine::Engine; + let mut engine = Engine::new(); + engine.settings.window_status_line = true; + engine.buffer_mut().insert(0, "text\n"); + engine + .buffer_manager + .get_mut(engine.active_buffer_id()) + .unwrap() + .dirty = true; + + let theme = Theme::onedark(); + let wid = engine.active_window_id(); + let status = build_window_status_line(&engine, &theme, wid, true); + + let left_text: String = status + .left_segments + .iter() + .map(|s| s.text.clone()) + .collect(); + assert!( + left_text.contains("[+]"), + "expected dirty indicator, got '{}'", + left_text + ); + } + + #[test] + fn test_window_status_line_insert_mode() { + use crate::core::engine::Engine; + let mut engine = Engine::new(); + engine.settings.window_status_line = true; + engine.mode = crate::core::Mode::Insert; + + let theme = Theme::onedark(); + let wid = engine.active_window_id(); + let status = build_window_status_line(&engine, &theme, wid, true); + + assert!(status.left_segments[0].text.contains("INSERT")); + // Mode color used as text tint, not background + assert_eq!(status.left_segments[0].fg, theme.status_mode_insert_bg); + // Background is derived from theme.background.lighten(0.10) + assert_eq!(status.left_segments[0].bg, theme.background.lighten(0.10)); + } + + #[test] + fn test_build_screen_layout_per_window_status() { + use crate::core::engine::Engine; + use crate::core::window::WindowRect; + + let mut engine = Engine::new(); + engine.settings.window_status_line = true; + engine + .buffer_mut() + .insert(0, "line 1\nline 2\nline 3\nline 4\nline 5\n"); + + let wid = engine.active_window_id(); + let rects = vec![(wid, WindowRect::new(0.0, 0.0, 80.0, 24.0))]; + let theme = Theme::onedark(); + let layout = build_screen_layout(&engine, &theme, &rects, 1.0, 1.0, false); + + // Each window should have a status_line + assert!(layout.windows[0].status_line.is_some()); + + // visible_lines should be rect height - 1 (status bar takes 1 row) + assert_eq!( + layout.windows[0].lines.len(), + 5, // only 5 lines of content, less than 23 visible lines + "lines should contain the buffer's actual lines" + ); + + // Global status bar should be empty + assert!(layout.status_left.is_empty()); + assert!(layout.status_right.is_empty()); + } + + #[test] + fn test_build_screen_layout_no_per_window_status() { + use crate::core::engine::Engine; + use crate::core::window::WindowRect; + + let mut engine = Engine::new(); + engine.settings.window_status_line = false; + engine.buffer_mut().insert(0, "hello\n"); + + let wid = engine.active_window_id(); + let rects = vec![(wid, WindowRect::new(0.0, 0.0, 80.0, 24.0))]; + let theme = Theme::onedark(); + let layout = build_screen_layout(&engine, &theme, &rects, 1.0, 1.0, false); + + // No per-window status line + assert!(layout.windows[0].status_line.is_none()); + + // Global status bar should be populated + assert!(!layout.status_left.is_empty()); + } + + #[test] + fn test_status_segments_have_actions() { + use crate::core::engine::Engine; + let mut engine = Engine::new(); + engine.settings.window_status_line = true; + engine.buffer_mut().insert(0, "hello\n"); + + let theme = Theme::onedark(); + let wid = engine.active_window_id(); + let status = build_window_status_line(&engine, &theme, wid, true); + + // Right segments should include GoToLine on cursor position + let goto = status + .right_segments + .iter() + .find(|s| s.action == Some(StatusAction::GoToLine)); + assert!(goto.is_some(), "expected GoToLine action on Ln/Col segment"); + + // Right segments should include ChangeIndentation + let indent = status + .right_segments + .iter() + .find(|s| s.action == Some(StatusAction::ChangeIndentation)); + assert!( + indent.is_some(), + "expected ChangeIndentation action on indent segment" + ); + + // Right segments should include ChangeEncoding + let enc = status + .right_segments + .iter() + .find(|s| s.action == Some(StatusAction::ChangeEncoding)); + assert!(enc.is_some(), "expected ChangeEncoding action"); + + // Right segments should include ChangeLineEnding + let eol = status + .right_segments + .iter() + .find(|s| s.action == Some(StatusAction::ChangeLineEnding)); + assert!(eol.is_some(), "expected ChangeLineEnding action"); + + // Inactive window segments should have no actions + let inactive = build_window_status_line(&engine, &theme, wid, false); + for seg in inactive + .left_segments + .iter() + .chain(inactive.right_segments.iter()) + { + assert_eq!(seg.action, None, "inactive segments should have no actions"); + } + } + + #[test] + fn test_status_line_ending_segment() { + use crate::core::engine::Engine; + let mut engine = Engine::new(); + engine.settings.window_status_line = true; + // Default is LF + let theme = Theme::onedark(); + let wid = engine.active_window_id(); + let status = build_window_status_line(&engine, &theme, wid, true); + let eol_seg = status + .right_segments + .iter() + .find(|s| s.action == Some(StatusAction::ChangeLineEnding)) + .expect("expected line ending segment"); + assert!( + eol_seg.text.contains("LF"), + "expected LF, got '{}'", + eol_seg.text + ); + } + + #[test] + fn test_status_indentation_segment() { + use crate::core::engine::Engine; + let mut engine = Engine::new(); + engine.settings.window_status_line = true; + engine.settings.expand_tab = true; + engine.settings.tabstop = 4; + + let theme = Theme::onedark(); + let wid = engine.active_window_id(); + let status = build_window_status_line(&engine, &theme, wid, true); + let indent_seg = status + .right_segments + .iter() + .find(|s| s.action == Some(StatusAction::ChangeIndentation)) + .expect("expected indent segment"); + assert!( + indent_seg.text.contains("Spaces: 4"), + "expected 'Spaces: 4', got '{}'", + indent_seg.text + ); + } + + #[test] + fn test_line_ending_detection() { + use crate::core::buffer_manager::LineEnding; + assert_eq!(LineEnding::detect("hello\nworld\n"), LineEnding::LF); + assert_eq!(LineEnding::detect("hello\r\nworld\r\n"), LineEnding::Crlf); + assert_eq!(LineEnding::detect("no newline"), LineEnding::LF); + assert_eq!(LineEnding::detect(""), LineEnding::LF); + } + + #[test] + fn test_lsp_status_no_manager() { + use crate::core::engine::Engine; + // Engine::new() has no lsp_manager — LSP segment should not appear + let mut engine = Engine::new(); + engine.settings.window_status_line = true; + engine.buffer_mut().insert(0, "hello\n"); + + let theme = Theme::onedark(); + let wid = engine.active_window_id(); + let status = build_window_status_line(&engine, &theme, wid, true); + + // No LSP segment when no manager is running + let lsp_seg = status + .right_segments + .iter() + .find(|s| s.action == Some(StatusAction::LspInfo)); + assert!( + lsp_seg.is_none(), + "should not show LSP segment without lsp_manager" + ); + } } diff --git a/src/tui_main/mod.rs b/src/tui_main/mod.rs index d1c2939c..a42aa5c0 100644 --- a/src/tui_main/mod.rs +++ b/src/tui_main/mod.rs @@ -125,9 +125,6 @@ fn matches_tui_key(binding: &str, code: KeyCode, mods: KeyModifiers) -> bool { const SIDEBAR_WIDTH: u16 = 30; const ACTIVITY_BAR_WIDTH: u16 = 3; -/// Number of terminal columns the explorer toolbar occupies: -/// 3 Nerd Font icons × 3 cols each (2-col icon + 1 space) = 9. -const EXPLORER_TOOLBAR_LEN: u16 = 9; // ─── Activity bar panels ────────────────────────────────────────────────────── @@ -170,6 +167,8 @@ struct TuiSidebar { search_scroll_top: usize, /// Whether to show dotfiles in the explorer (mirrors Settings.show_hidden_files). show_hidden_files: bool, + /// Sort explorer entries case-insensitively (mirrors Settings.explorer_sort_case_insensitive). + sort_case_insensitive: bool, /// When true, the activity bar (toolbar) has keyboard focus. toolbar_focused: bool, /// Currently highlighted row in the activity bar (0=hamburger, 1-6=panels, 7=settings). @@ -198,6 +197,7 @@ impl TuiSidebar { replace_input_focused: false, search_scroll_top: 0, show_hidden_files: false, + sort_case_insensitive: true, toolbar_focused: false, toolbar_selected: 1, // Start on Explorer pending_ctrl_w: false, @@ -229,6 +229,7 @@ impl TuiSidebar { 1, &self.expanded, self.show_hidden_files, + self.sort_case_insensitive, &mut self.rows, ); } @@ -295,6 +296,7 @@ fn collect_rows( depth: usize, expanded: &HashSet, show_hidden: bool, + case_insensitive: bool, out: &mut Vec, ) { let entries = match fs::read_dir(dir) { @@ -302,14 +304,22 @@ fn collect_rows( Err(_) => return, }; let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); - // Dirs first, then alphabetical + // Dirs first, then alphabetical (optionally case-insensitive) entries.sort_by(|a, b| { let ad = a.path().is_dir(); let bd = b.path().is_dir(); match (ad, bd) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, - _ => a.file_name().cmp(&b.file_name()), + _ => { + if case_insensitive { + let an = a.file_name().to_string_lossy().to_lowercase(); + let bn = b.file_name().to_string_lossy().to_lowercase(); + an.cmp(&bn) + } else { + a.file_name().cmp(&b.file_name()) + } + } } }); for entry in entries { @@ -329,7 +339,14 @@ fn collect_rows( is_expanded, }); if is_expanded { - collect_rows(&path, depth + 1, expanded, show_hidden, out); + collect_rows( + &path, + depth + 1, + expanded, + show_hidden, + case_insensitive, + out, + ); } } } @@ -824,6 +841,8 @@ pub fn run(file_path: Option, debug_log_path: Option) { let mut engine = Engine::new(); icons::set_nerd_fonts(engine.settings.use_nerd_fonts); engine.plugin_init(); + // Fetch fresh extension registry in background (updates ignore_error_sources, etc.) + engine.ext_refresh(); if let Some(path) = file_path { // CLI argument: open only the specified file/directory, skip session restore if path.is_dir() { @@ -948,6 +967,9 @@ fn event_loop( ) { let mut theme = Theme::from_name(&engine.settings.colorscheme); + // TUI menu bar can be fully hidden (unlike GTK where it's the title bar). + engine.menu_bar_toggleable = true; + // Initialise sidebar from session/settings let initial_visible = if engine.settings.autohide_panels { false @@ -957,6 +979,7 @@ fn event_loop( let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let mut sidebar = TuiSidebar::new(root, initial_visible); sidebar.show_hidden_files = engine.settings.show_hidden_files; + sidebar.sort_case_insensitive = engine.settings.explorer_sort_case_insensitive; // Optional active prompt (for sidebar CRUD operations) @@ -1025,6 +1048,10 @@ fn event_loop( let mut close_tab_confirm = false; let mut needs_redraw = true; + // Track whether a large overlay popup was visible last frame so we can + // force a full redraw when it disappears (prevents stale characters from + // the popup lingering due to ratatui's incremental diff). + let mut had_popup_overlay = false; // Link hit rects from the hover popup render: (x, y, w, h, url). let mut hover_link_rects: Vec<(u16, u16, u16, u16, String)> = Vec::new(); // Bounding rect of the panel hover popup (x, y, w, h) — used to suppress dismiss on mouse-over. @@ -1183,6 +1210,17 @@ fn event_loop( } } + // Detect when a large overlay popup (picker, folder picker, dialog) + // was visible last frame but isn't now. Force a full redraw so + // ratatui's incremental diff doesn't leave stale popup characters + // in the editor area. + let has_popup = + screen.map(|s| s.picker.is_some()).unwrap_or(false) || folder_picker.is_some(); + if had_popup_overlay && !has_popup { + terminal.clear().ok(); + } + had_popup_overlay = has_popup; + let mut tab_visible_counts: Vec<(crate::core::window::GroupId, usize)> = Vec::new(); terminal .draw(|frame| { @@ -1263,6 +1301,9 @@ fn event_loop( let poll_timeout = if engine.tab_switcher_open { // Short poll when tab switcher is open so we can auto-confirm quickly Duration::from_millis(10) + } else if engine.has_active_notifications() { + // Animate spinner at ~10fps when background operations are running + Duration::from_millis(100) } else if needs_redraw { min_frame .saturating_sub(last_draw.elapsed()) @@ -1327,6 +1368,7 @@ fn event_loop( // Auto-refresh explorer and SC panel to reflect external filesystem changes. if sidebar.visible && last_sidebar_refresh.elapsed() >= Duration::from_secs(2) { sidebar.show_hidden_files = engine.settings.show_hidden_files; + sidebar.sort_case_insensitive = engine.settings.explorer_sort_case_insensitive; sidebar.build_rows(); if sidebar.active_panel == TuiPanel::Git || sidebar.active_panel == TuiPanel::Explorer @@ -1355,6 +1397,7 @@ fn event_loop( crate::core::settings::Settings::load_with_validation() { engine.settings = new_settings; + engine.ensure_spell_checker(); engine.message = "Settings reloaded".to_string(); needs_redraw = true; } @@ -1429,6 +1472,12 @@ fn event_loop( } // Tick swap file writes (only does work when updatetime elapsed). engine.tick_swap_files(); + // Auto-dismiss completed notifications after timeout. + // Force redraw every idle tick when notifications are visible (spinner animation). + if !engine.notifications.is_empty() { + needs_redraw = true; + } + engine.tick_notifications(); continue; } @@ -1659,6 +1708,8 @@ fn event_loop( engine.open_folder(&path); sidebar = TuiSidebar::new(engine.cwd.clone(), sidebar.visible); sidebar.show_hidden_files = engine.settings.show_hidden_files; + sidebar.sort_case_insensitive = + engine.settings.explorer_sort_case_insensitive; if let Some(fp) = engine.file_path().cloned() { let h = terminal .size() @@ -1686,6 +1737,8 @@ fn event_loop( } sidebar = TuiSidebar::new(engine.cwd.clone(), sidebar.visible); sidebar.show_hidden_files = engine.settings.show_hidden_files; + sidebar.sort_case_insensitive = + engine.settings.explorer_sort_case_insensitive; // Reveal the active file from the restored session if let Some(path) = engine.file_path().cloned() { let h = terminal @@ -3310,15 +3363,20 @@ fn event_loop( } else { key_name.clone() }; + let ctx = engine.context_menu_target_path(); let (consumed, action) = engine.handle_context_menu_key(&effective_key); if consumed { if let Some(act) = action { - handle_explorer_context_action( - &act, - engine, - &sidebar, - terminal.size().ok(), - ); + if let Some((ctx_path, ctx_is_dir)) = ctx { + handle_explorer_context_action( + &act, + engine, + &sidebar, + terminal.size().ok(), + ctx_path, + ctx_is_dir, + ); + } } needs_redraw = true; continue; @@ -3378,6 +3436,8 @@ fn event_loop( // just refresh the sidebar to reflect the new cwd. sidebar = TuiSidebar::new(engine.cwd.clone(), sidebar.visible); sidebar.show_hidden_files = engine.settings.show_hidden_files; + sidebar.sort_case_insensitive = + engine.settings.explorer_sort_case_insensitive; needs_redraw = true; } else if action == EngineAction::SaveWorkspaceAsDialog { // For TUI, save workspace to current directory immediately @@ -3713,6 +3773,11 @@ fn event_loop( // Resize the terminal PTY to match the full new terminal width. let term_rows = engine.session.terminal_panel_rows; engine.terminal_resize(new_w, term_rows); + // Force ratatui to do a full redraw. Terminal emulators reflow + // screen content on resize, which can leave the physical display + // out of sync with ratatui's previous-frame buffer. Clearing + // resets both buffers so the next draw emits every cell. + terminal.clear().ok(); } _ => {} } @@ -3725,21 +3790,19 @@ fn event_loop( /// Process explorer-specific context menu actions that need sidebar prompts. /// Tab context menu actions (close, split, etc.) are handled directly by /// `context_menu_confirm()` in the engine. +/// +/// `ctx_path` / `ctx_is_dir` come from the context menu target — callers +/// extract them *before* `context_menu_confirm()` consumes the menu. fn handle_explorer_context_action( action: &str, engine: &mut Engine, sidebar: &TuiSidebar, terminal_size: Option, + ctx_path: PathBuf, + ctx_is_dir: bool, ) { - // Get the path from the engine's last context menu target. - // Note: context_menu_confirm() already took the menu, so we reconstruct - // the path from the sidebar's selected row. - let idx = sidebar.selected; - let (path, is_dir) = if idx < sidebar.rows.len() { - (sidebar.rows[idx].path.clone(), sidebar.rows[idx].is_dir) - } else { - return; - }; + let path = ctx_path; + let is_dir = ctx_is_dir; match action { "new_file" | "new_folder" => { @@ -3784,7 +3847,10 @@ fn handle_explorer_context_action( fn set_cell(buf: &mut ratatui::buffer::Buffer, x: u16, y: u16, ch: char, fg: RColor, bg: RColor) { let area = buf.area; if x < area.x + area.width && y < area.y + area.height { - buf[(x, y)].set_char(ch).set_fg(fg).set_bg(bg); + let cell = &mut buf[(x, y)]; + cell.set_char(ch).set_fg(fg).set_bg(bg); + cell.modifier = Modifier::empty(); + cell.underline_color = RColor::Reset; } } @@ -3809,11 +3875,19 @@ fn set_cell_wide( // column). let mut s = String::with_capacity(4); s.push(ch); - buf[(x, y)].set_symbol(&s).set_fg(fg).set_bg(bg); + let cell = &mut buf[(x, y)]; + cell.set_symbol(&s).set_fg(fg).set_bg(bg); + cell.modifier = Modifier::empty(); + cell.underline_color = RColor::Reset; if x + 1 < area.x + area.width { - let next = &mut buf[(x + 1, y)]; - next.reset(); - next.set_skip(true); + // Mark as wide-char continuation: empty symbol tells ratatui this + // cell is the trailing half of a double-width glyph. Unlike + // set_skip(true), ratatui WILL emit the background colour so the + // terminal doesn't show a black rectangle. + let cont = &mut buf[(x + 1, y)]; + cont.set_symbol("").set_fg(fg).set_bg(bg); + cont.modifier = Modifier::empty(); + cont.underline_color = RColor::Reset; } } } @@ -3834,9 +3908,7 @@ fn set_cell_styled( let cell = &mut buf[(x, y)]; cell.set_char(ch).set_fg(fg).set_bg(bg); cell.modifier = modifier; - if let Some(ul) = underline_color { - cell.underline_color = ul; - } + cell.underline_color = underline_color.unwrap_or(RColor::Reset); } } diff --git a/src/tui_main/mouse.rs b/src/tui_main/mouse.rs index b97792e4..b4a7ac5a 100644 --- a/src/tui_main/mouse.rs +++ b/src/tui_main/mouse.rs @@ -493,6 +493,10 @@ pub(super) fn handle_mouse( && editor_row >= wy && editor_row < wy + wh { + // Skip per-window status bar row + if rw.status_line.is_some() && wh > 1 && editor_row == wy + wh - 1 { + break; + } let view_row = (editor_row - wy) as usize; let drag_rl = rw.lines.get(view_row); let buf_line = drag_rl @@ -811,14 +815,16 @@ pub(super) fn handle_mouse( if sidebar.visible && col >= ab_width && col < ab_width + sidebar_width { if sidebar.active_panel == TuiPanel::Explorer { let sidebar_row = row.saturating_sub(menu_rows); - if sidebar_row >= 1 { - let tree_row = (sidebar_row as usize).saturating_sub(1) + sidebar.scroll_top; - if tree_row < sidebar.rows.len() { - sidebar.selected = tree_row; - let path = sidebar.rows[tree_row].path.clone(); - let is_dir = sidebar.rows[tree_row].is_dir; - engine.open_explorer_context_menu(path, is_dir, col, row); - } + let tree_row = sidebar_row as usize + sidebar.scroll_top; + if tree_row < sidebar.rows.len() { + sidebar.selected = tree_row; + let path = sidebar.rows[tree_row].path.clone(); + let is_dir = sidebar.rows[tree_row].is_dir; + engine.open_explorer_context_menu(path, is_dir, col, row); + } else { + // Empty space below last entry → context menu for root folder + let root = sidebar.root.clone(); + engine.open_explorer_context_menu(root, true, col, row); } } return sidebar_width; @@ -903,13 +909,18 @@ pub(super) fn handle_mouse( if visual_row == inner_row { if item.enabled { engine.context_menu.as_mut().unwrap().selected = idx; + let ctx = engine.context_menu_target_path(); if let Some(act) = engine.context_menu_confirm() { - handle_explorer_context_action( - &act, - engine, - sidebar, - *terminal_size, - ); + if let Some((ctx_path, ctx_is_dir)) = ctx { + handle_explorer_context_action( + &act, + engine, + sidebar, + *terminal_size, + ctx_path, + ctx_is_dir, + ); + } } } return sidebar_width; @@ -1191,9 +1202,13 @@ pub(super) fn handle_mouse( } // ── Command line click — start text selection ────────────────────────────── + // Skip when click is in the activity bar column (settings button lives there). { use crate::core::Mode; - if row + 1 == term_height && matches!(engine.mode, Mode::Command | Mode::Search) { + if row + 1 == term_height + && col >= ab_width + && matches!(engine.mode, Mode::Command | Mode::Search) + { let char_idx = col as usize; let buf_len = engine.command_buffer.chars().count(); engine.command_cursor = char_idx.saturating_sub(1).min(buf_len); @@ -1203,6 +1218,7 @@ pub(super) fn handle_mouse( } // Also allow selection on the message/command line in Normal mode. if row + 1 == term_height + && col >= ab_width && matches!( engine.mode, Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock @@ -1222,7 +1238,9 @@ pub(super) fn handle_mouse( } // ── Status bar branch click — open branch picker ─────────────────────── - if row + 2 == term_height { + // (only when global status bar exists — per-window status replaces it) + // Skip when click is in the activity bar column (settings button lives there). + if row + 2 == term_height && !engine.settings.window_status_line && col >= ab_width { if let MouseEventKind::Down(MouseButton::Left) = ev.kind { if let Some(layout) = last_layout { if let Some((start, end)) = layout.status_branch_range { @@ -1237,8 +1255,8 @@ pub(super) fn handle_mouse( return sidebar_width; } - // Bottom row is cmd — ignore - if row + 1 >= term_height { + // Bottom row is cmd — ignore (but not in the activity bar column) + if row + 1 >= term_height && col >= ab_width { return sidebar_width; } @@ -1392,6 +1410,46 @@ pub(super) fn handle_mouse( } } + // ── Bottom panel tab bar click (shared row above Terminal / Debug Output) ── + { + let bottom_panel_visible = engine.terminal_open || engine.bottom_panel_open; + if bottom_panel_visible && col >= editor_left { + let dt_rows: u16 = if engine.debug_toolbar_visible { 1 } else { 0 }; + let wildmenu_rows: u16 = if !engine.wildmenu_items.is_empty() { + 1 + } else { + 0 + }; + let global_status_rows: u16 = if engine.settings.window_status_line { + 0 + } else { + 1 + }; + let panel_height = engine.session.terminal_panel_rows + 2; + // Bottom panel y = term_height - cmd(1) - status - wildmenu - debug_toolbar - panel + let tab_bar_row = term_height + .saturating_sub(1 + global_status_rows + wildmenu_rows + dt_rows + panel_height); + if row == tab_bar_row { + let term_width = terminal_size.map(|s| s.width).unwrap_or(80); + let rel_col = col - editor_left; // column relative to editor area + // Close button (×) at rightmost 2 cols of editor area + if col >= term_width.saturating_sub(2) { + engine.bottom_panel_open = false; + engine.close_terminal(); + return sidebar_width; + } + // Tab label click — switch between Terminal and Debug Output. + // Labels: " Terminal " (12 chars), " Debug Output " (16 chars) + if rel_col < 12 { + engine.bottom_panel_kind = render::BottomPanelKind::Terminal; + } else if rel_col < 28 { + engine.bottom_panel_kind = render::BottomPanelKind::DebugOutput; + } + return sidebar_width; + } + } + } + // ── Debug output panel click (scrollbar) ────────────────────────────────── { let debug_output_open = engine.bottom_panel_kind == render::BottomPanelKind::DebugOutput @@ -1509,23 +1567,14 @@ pub(super) fn handle_mouse( // ── Activity bar ────────────────────────────────────────────────────────── if col < ab_width { - // Activity bar is in the main content area, below the menu bar row (if visible) - // and above the debug toolbar row (if visible). - let qf_rows: u16 = if engine.quickfix_open { 6 } else { 0 }; - let strip_rows: u16 = if engine.terminal_open { - engine.session.terminal_panel_rows + 1 - } else { - 0 - }; + // Activity bar spans full height below the menu bar row (matching GTK layout). let menu_rows: u16 = if engine.menu_bar_visible { 1 } else { 0 }; - let dbg_rows: u16 = if engine.debug_toolbar_visible { 1 } else { 0 }; // Activity bar starts at row `menu_rows` in absolute terminal coordinates. if row < menu_rows { return sidebar_width; // click in menu bar area, ignore } let bar_row = row - menu_rows; // row relative to activity bar start - let bar_height = - term_height.saturating_sub(2 + qf_rows + strip_rows + menu_rows + dbg_rows); + let bar_height = term_height.saturating_sub(menu_rows); let settings_row = bar_height.saturating_sub(1); // Row 0: hamburger (menu bar toggle) if bar_row == 0 { @@ -1690,65 +1739,26 @@ pub(super) fn handle_mouse( } else if sidebar.active_panel == TuiPanel::Explorer { sidebar.has_focus = true; engine.explorer_has_focus = true; - // tree_height = (total height - 2 status rows) - 1 header row - let tree_height = term_height.saturating_sub(3) as usize; + // tree_height = total height - 2 status rows (no header) + let tree_height = term_height.saturating_sub(2) as usize; let total_rows = sidebar.rows.len(); // Click on the scrollbar column → jump-scroll + arm drag - if col == sb_col && total_rows > tree_height && sidebar_row >= 1 { - let rel_row = sidebar_row.saturating_sub(1) as usize; + if col == sb_col && total_rows > tree_height { + let rel_row = sidebar_row as usize; let ratio = rel_row as f64 / tree_height as f64; let new_top = (ratio * total_rows as f64) as usize; sidebar.scroll_top = new_top.min(total_rows.saturating_sub(tree_height)); let menu_rows: u16 = if engine.menu_bar_visible { 1 } else { 0 }; *dragging_generic_sb = Some(SidebarScrollDrag { - track_abs_start: 1 + menu_rows, + track_abs_start: menu_rows, track_len: tree_height as u16, total: total_rows, }); return sidebar_width; } - if sidebar_row == 0 { - // Header row: check if a toolbar button was clicked. - // Toolbar is right-aligned: 5 NF icons × 3 cols = 15. - let toolbar_start = ab_width + sidebar_width - EXPLORER_TOOLBAR_LEN; - if col >= toolbar_start { - let btn = (col - toolbar_start) / 3; // 0=new-file 1=new-folder 2=delete - let idx = sidebar.selected; - let selected_is_dir = idx < sidebar.rows.len() && sidebar.rows[idx].is_dir; - match btn { - 0 | 1 if idx < sidebar.rows.len() => { - let target = if selected_is_dir { - sidebar.rows[idx].path.clone() - } else { - sidebar.rows[idx] - .path - .parent() - .unwrap_or(&sidebar.root) - .to_path_buf() - }; - // Expand the target dir so the new entry row is visible - sidebar.expanded.insert(target.clone()); - sidebar.build_rows(); - if btn == 0 { - engine.start_explorer_new_file(target); - } else { - engine.start_explorer_new_folder(target); - } - } - 2 => { - if idx < sidebar.rows.len() { - let path = sidebar.rows[idx].path.clone(); - engine.confirm_delete_file(&path); - } - } - _ => {} - } - } - return sidebar_width; - } - let tree_row = (sidebar_row as usize).saturating_sub(1) + sidebar.scroll_top; + let tree_row = sidebar_row as usize + sidebar.scroll_top; if tree_row < sidebar.rows.len() { // Record potential drag source for DnD. *explorer_drag_src = Some(tree_row); @@ -2248,7 +2258,7 @@ pub(super) fn handle_mouse( // Split buttons exist on active group, or all groups in diff mode. let had_split = was_active || engine.is_in_diff_view(); let split_cols = if had_split { TAB_SPLIT_BOTH_COLS } else { 0 }; - let split_end = bar_width; + let split_end = bar_width.saturating_sub(TAB_ACTION_BTN_COLS); let split_start = split_end.saturating_sub(split_cols); let diff_end = split_start; let diff_start = diff_end.saturating_sub(diff_total_cols); @@ -2277,15 +2287,19 @@ pub(super) fn handle_mouse( } } else if had_split && local_col >= split_start + && local_col < split_start + TAB_SPLIT_BOTH_COLS && bar_width >= TAB_SPLIT_BOTH_COLS { - // Hit-test split buttons (rightmost). + // Hit-test split buttons. let in_split = local_col - split_start; if in_split >= TAB_SPLIT_BTN_COLS { engine.open_editor_group(SplitDirection::Horizontal); } else { engine.open_editor_group(SplitDirection::Vertical); } + } else if local_col >= bar_width.saturating_sub(TAB_ACTION_BTN_COLS) { + // Editor action menu button ("…") at far right. + engine.open_editor_action_menu(group_id, col, row + 1); } } return sidebar_width; @@ -2346,7 +2360,7 @@ pub(super) fn handle_mouse( } else { 0 }; - let split_end = bar_width; + let split_end = bar_width.saturating_sub(TAB_ACTION_BTN_COLS); let split_start = split_end.saturating_sub(TAB_SPLIT_BOTH_COLS); let diff_end = split_start; let diff_start = diff_end.saturating_sub(diff_total_cols); @@ -2369,13 +2383,19 @@ pub(super) fn handle_mouse( } else { engine.diff_toggle_hide_unchanged(); } - } else if local_col >= split_start && bar_width >= TAB_SPLIT_BOTH_COLS { + } else if local_col >= split_start + && local_col < split_start + TAB_SPLIT_BOTH_COLS + && bar_width >= TAB_SPLIT_BOTH_COLS + { let in_split = local_col - split_start; if in_split >= TAB_SPLIT_BTN_COLS { engine.open_editor_group(SplitDirection::Horizontal); } else { engine.open_editor_group(SplitDirection::Vertical); } + } else if local_col >= bar_width.saturating_sub(TAB_ACTION_BTN_COLS) { + // Editor action menu button ("…") at far right. + engine.open_editor_action_menu(engine.active_group, col, row + 1); } } return sidebar_width; @@ -2422,6 +2442,35 @@ pub(super) fn handle_mouse( let wh = rw.rect.height as u16; if rel_col >= wx && rel_col < wx + ww && editor_row >= wy && editor_row < wy + wh { + // Per-window status bar click — hit-test segments for actions. + if rw.status_line.is_some() && wh > 1 && editor_row == wy + wh - 1 { + if let Some(ref status) = rw.status_line { + let click_col = (rel_col - wx) as usize; + if let Some(action) = + status_segment_hit_test(status, ww as usize, click_col) + { + if let Some(ea) = engine.handle_status_action(&action) { + use crate::core::engine::EngineAction; + match ea { + EngineAction::ToggleSidebar => { + sidebar.visible = !sidebar.visible; + } + EngineAction::OpenTerminal => { + let cols = + terminal_size.as_ref().map(|s| s.width).unwrap_or(80); + engine.terminal_new_tab( + cols, + engine.session.terminal_panel_rows, + ); + } + _ => {} + } + } + } + } + return sidebar_width; + } + let viewport_lines = wh as usize; let has_v_scrollbar = rw.total_lines > viewport_lines; let gutter = rw.gutter_char_width as u16; @@ -2563,3 +2612,40 @@ pub(super) fn handle_mouse( sidebar_width } + +/// Walk status line segments and find which action (if any) is at `click_col`. +fn status_segment_hit_test( + status: &crate::render::WindowStatusLine, + width: usize, + click_col: usize, +) -> Option { + // Compute right-side total width + let right_width: usize = status + .right_segments + .iter() + .map(|s| s.text.chars().count()) + .sum(); + let right_start = width.saturating_sub(right_width); + + // Check left segments + let mut col = 0; + for seg in &status.left_segments { + let seg_len = seg.text.chars().count(); + if click_col >= col && click_col < col + seg_len { + return seg.action.clone(); + } + col += seg_len; + } + + // Check right segments + let mut col = right_start; + for seg in &status.right_segments { + let seg_len = seg.text.chars().count(); + if click_col >= col && click_col < col + seg_len { + return seg.action.clone(); + } + col += seg_len; + } + + None +} diff --git a/src/tui_main/panels.rs b/src/tui_main/panels.rs index b474eb30..e771c471 100644 --- a/src/tui_main/panels.rs +++ b/src/tui_main/panels.rs @@ -9,12 +9,7 @@ pub(super) fn render_activity_bar( engine: &Engine, ) { let bar_bg = rc(theme.tab_bar_bg); - // Icon color adapts to theme brightness for readability. - let icon_fg = if theme.is_light() { - RColor::Rgb(100, 100, 110) - } else { - RColor::Rgb(200, 200, 210) - }; + let icon_fg = rc(theme.activity_bar_fg); let accent_fg = rc(theme.cursor); // left-edge accent bar for active panel let toolbar_sel_bg = rc(theme.cursor); // highlight for toolbar-focused selection @@ -127,11 +122,8 @@ pub(super) fn render_sidebar( theme: &Theme, explorer_drop_target: Option, ) { - let header_fg = rc(theme.status_fg); - let header_bg = rc(theme.status_bg); - let default_fg = rc(theme.foreground); + let default_fg = rc(theme.explorer_file_fg); let row_bg = rc(theme.tab_bar_bg); - let dir_fg = rc(theme.explorer_dir_fg); let active_bg = rc(theme.explorer_active_bg); // The single active buffer path (the file shown in the active window) @@ -143,7 +135,6 @@ pub(super) fn render_sidebar( } else { rc(theme.sidebar_sel_bg_inactive) }; - let sel_fg = default_fg; // Extension panel (plugin-provided) if sidebar.ext_panel_name.is_some() { @@ -197,55 +188,6 @@ pub(super) fn render_sidebar( } } - let header_y = area.y; - // Fill header - for x in area.x..area.x + area.width { - set_cell(buf, x, header_y, ' ', header_fg, header_bg); - } - // " EXPLORER" label - let label = " EXPLORER"; - let mut x = area.x; - for ch in label.chars() { - if x >= area.x + area.width { - break; - } - set_cell(buf, x, header_y, ch, header_fg, header_bg); - x += 1; - } - // Toolbar buttons (right-aligned, Nerd Font icons): - // new-file new-folder delete refresh explorer-mode - // Each icon occupies 2 terminal cols (Nerd Font) + 1 space = 3 cols per button. - // EXPLORER_TOOLBAR_LEN = 9 (3 NF icons × 3 cols each). - // When a file (not folder) is selected, new-file/new-folder icons are dimmed. - let selected_is_dir = { - let idx = sidebar.selected; - idx < sidebar.rows.len() && sidebar.rows[idx].is_dir - }; - let dim_fg = rc(theme.line_number_fg); // dimmed color for unavailable buttons - let icons: &[(char, bool, ratatui::style::Color)] = &[ - ( - '\u{f15b}', - selected_is_dir, - if selected_is_dir { header_fg } else { dim_fg }, - ), // new file - ( - '\u{f07b}', - selected_is_dir, - if selected_is_dir { header_fg } else { dim_fg }, - ), // new folder - ('\u{f1f8}', true, header_fg), // delete - ]; - let toolbar_len = EXPLORER_TOOLBAR_LEN; - if toolbar_len < area.width { - let mut tx = area.x + area.width - toolbar_len; - for &(icon, _enabled, fg) in icons { - set_cell(buf, tx, header_y, icon, fg, header_bg); - tx += 2; // icon is 2-cols wide (Nerd Font) - set_cell(buf, tx, header_y, ' ', header_fg, header_bg); - tx += 1; - } - } - // ── Explorer indicators (git status + diagnostics) ───────────────── let (git_statuses, diag_counts) = engine.explorer_indicators(); let git_added_fg = rc(theme.git_added); @@ -255,7 +197,7 @@ pub(super) fn render_sidebar( let diag_warning_fg = rc(theme.diagnostic_warning); // ── Tree rows ──────────────────────────────────────────────────────── - let tree_height = area.height.saturating_sub(1) as usize; + let tree_height = area.height as usize; // Determine where a new-entry row should be inserted (right after parent dir). // `new_entry_after_row` is the sidebar.rows index after which we inject the @@ -281,7 +223,7 @@ pub(super) fn render_sidebar( // Handle new-entry-at-top: if scroll_top == 0, render the new entry first if new_entry_at_top && !new_entry_rendered && sidebar.scroll_top == 0 { let ne = engine.explorer_new_entry.as_ref().unwrap(); - let screen_y = area.y + 1; + let screen_y = area.y; // depth 0: parent is root, so child is at depth 0 render_new_entry_row(buf, area, screen_y, ne, 0, theme); visual_row += 1; @@ -294,7 +236,7 @@ pub(super) fn render_sidebar( row_iter_idx += 1; let i = visual_row; - let screen_y = area.y + 1 + i as u16; + let screen_y = area.y + i as u16; if screen_y >= area.y + area.height { break; } @@ -318,68 +260,97 @@ pub(super) fn render_sidebar( g: 60, b: 80, }); // muted blue highlight - let (fg, bg) = if is_drop_target { - (sel_fg, drop_bg) - } else if is_selected { - let fg = if row.is_dir { dir_fg } else { sel_fg }; - (fg, sel_bg) - } else if is_active { - (default_fg, active_bg) - } else if row.is_dir { - (dir_fg, row_bg) + // Determine name color: error > warning > git modified > default. + let canon = row.path.canonicalize().unwrap_or_else(|_| row.path.clone()); + let name_fg = if let Some(&(errors, warnings)) = diag_counts.get(&canon) { + if errors > 0 { + diag_error_fg + } else if warnings > 0 { + diag_warning_fg + } else { + default_fg + } + } else if let Some(&label) = git_statuses.get(&canon) { + match label { + 'A' | '?' => git_added_fg, + 'D' => git_deleted_fg, + _ => git_modified_fg, + } } else { - (default_fg, row_bg) + default_fg }; - // Build row string: indent + chevron/icon + name - let indent = " ".repeat(row.depth); - let prefix = if row.is_dir { - if row.is_expanded { - "\u{25be} " // ▾ - } else { - "\u{25b8} " // ▸ - } + let (fg, bg) = if is_drop_target { + (name_fg, drop_bg) + } else if is_selected { + (name_fg, sel_bg) + } else if is_active { + (name_fg, active_bg) } else { - let ext = row.path.extension().and_then(|e| e.to_str()).unwrap_or(""); - // We format as " {icon} " — two spaces, icon, space - // Rendered char-by-char below - let _ = ext; // used in the render step - " " + (name_fg, row_bg) }; let mut x = area.x; - // Indent - for ch in indent.chars() { + // Indent with subtle vertical guide lines (skip outermost levels) + let guide_fg = rc(theme.line_number_fg); + for level in 0..row.depth { if x >= area.x + area.width { break; } - set_cell(buf, x, screen_y, ch, fg, bg); - x += 1; - } - // Prefix (chevron or spaces) - for ch in prefix.chars() { - if x >= area.x + area.width { - break; + // Show guide lines (skip level 0 = root indent) + if level > 0 { + set_cell(buf, x, screen_y, '│', guide_fg, bg); + } else { + set_cell(buf, x, screen_y, ' ', fg, bg); } - set_cell(buf, x, screen_y, ch, fg, bg); x += 1; + // One space after guide = 2-col indent per level + if x < area.x + area.width { + set_cell(buf, x, screen_y, ' ', fg, bg); + x += 1; + } } - // File icon (only for files) - if !row.is_dir { - let ext = row.path.extension().and_then(|e| e.to_str()).unwrap_or(""); - let icon = crate::icons::file_icon(ext); - for ch in icon.chars() { - if x >= area.x + area.width { - break; - } - set_cell(buf, x, screen_y, ch, fg, bg); + // Layout: [chevron (2 cols)] [icon (2 cols)] [space] [name] + // Dirs: ▾/▸ + space, then folder icon + // Files: 2 spaces (no chevron), then file icon + // This keeps icons aligned at the same column for siblings. + if row.is_dir { + let chevron = if row.is_expanded { '▾' } else { '▸' }; + if x < area.x + area.width { + set_cell(buf, x, screen_y, chevron, fg, bg); x += 1; } - // Space after icon if x < area.x + area.width { set_cell(buf, x, screen_y, ' ', fg, bg); x += 1; } + } else { + // No chevron — 2 blank cols to align with dirs + for _ in 0..2 { + if x < area.x + area.width { + set_cell(buf, x, screen_y, ' ', fg, bg); + x += 1; + } + } + } + // Icon (file or folder) + let icon_str = if row.is_dir { + crate::icons::FOLDER.s() + } else { + let ext = row.path.extension().and_then(|e| e.to_str()).unwrap_or(""); + crate::icons::file_icon(ext) + }; + for ch in icon_str.chars() { + if x >= area.x + area.width { + break; + } + set_cell(buf, x, screen_y, ch, fg, bg); + x += 1; + } + // Space after icon + if x < area.x + area.width { + set_cell(buf, x, screen_y, ' ', fg, bg); + x += 1; } // Name — or inline rename input when active on this row let is_renaming = engine @@ -390,20 +361,47 @@ pub(super) fn render_sidebar( let rename = engine.explorer_rename.as_ref().unwrap(); let input_bg = rc(theme.background); let input_fg = rc(theme.foreground); - let input_start_x = x; - // Render the input text - for (byte_idx, ch) in rename.input.char_indices() { + let sel_bg = rc(theme.fuzzy_selected_bg); + // Compute selection range (byte offsets) + let (sel_lo, sel_hi) = rename + .selection_anchor + .map(|a| (a.min(rename.cursor), a.max(rename.cursor))) + .unwrap_or((0, 0)); + let has_selection = sel_lo != sel_hi; + // Available columns for the input text + let avail = (area.x + area.width).saturating_sub(x) as usize; + // Cursor char position (0-based) + let cursor_char = rename.input[..rename.cursor].chars().count(); + let total_chars = rename.input.chars().count(); + // Compute horizontal scroll offset (in chars) to keep cursor visible. + // Reserve 1 col for the cursor-at-end block. + let scroll = if total_chars < avail || cursor_char < avail.saturating_sub(1) { + 0 + } else { + cursor_char.saturating_sub(avail.saturating_sub(2)) + }; + // Render the input text starting from scroll offset + for (char_idx, (byte_idx, ch)) in rename.input.char_indices().enumerate() { + if char_idx < scroll { + continue; + } if x >= area.x + area.width { break; } - let is_cursor = byte_idx == rename.cursor; - let cell_fg = if is_cursor { input_bg } else { input_fg }; - let cell_bg = if is_cursor { input_fg } else { input_bg }; + let in_sel = has_selection && byte_idx >= sel_lo && byte_idx < sel_hi; + let is_cursor = byte_idx == rename.cursor && !has_selection; + let (cell_fg, cell_bg) = if is_cursor { + (input_bg, input_fg) + } else if in_sel { + (input_fg, sel_bg) + } else { + (input_fg, input_bg) + }; set_cell(buf, x, screen_y, ch, cell_fg, cell_bg); x += 1; } - // Cursor at end of input (append position) - if rename.cursor >= rename.input.len() && x < area.x + area.width { + // Cursor at end of input (append position) — only when no selection + if !has_selection && rename.cursor >= rename.input.len() && x < area.x + area.width { set_cell(buf, x, screen_y, ' ', input_bg, input_fg); x += 1; } @@ -412,7 +410,6 @@ pub(super) fn render_sidebar( set_cell(buf, x, screen_y, ' ', input_fg, input_bg); x += 1; } - let _ = input_start_x; } else { for ch in row.name.chars() { if x >= area.x + area.width { @@ -492,7 +489,7 @@ pub(super) fn render_sidebar( if row_idx == after_idx && visual_row < tree_height { let ne = engine.explorer_new_entry.as_ref().unwrap(); let parent_depth = row.depth; - let screen_y = area.y + 1 + visual_row as u16; + let screen_y = area.y + visual_row as u16; if screen_y < area.y + area.height { render_new_entry_row(buf, area, screen_y, ne, parent_depth, theme); visual_row += 1; @@ -517,7 +514,7 @@ pub(super) fn render_sidebar( let thumb_top = ((sidebar.scroll_top as f64 / total_rows as f64) * track_h).floor() as u16; let sb_x = area.x + area.width - 1; for dy in 0..visible_rows_count as u16 { - let y = area.y + 1 + dy; // +1 for header row + let y = area.y + dy; if y >= area.y + area.height { break; } @@ -550,7 +547,7 @@ fn render_new_entry_row( let mut x = area.x; - // Indent (child of parent, so depth + 1) + // Indent (child of parent, so depth + 1) — 2-col per level let indent = " ".repeat(depth + 1); for ch in indent.chars() { if x >= area.x + area.width { @@ -574,8 +571,19 @@ fn render_new_entry_row( x += 1; } - // Editable input with inverted cursor - for (byte_idx, ch) in entry.input.char_indices() { + // Editable input with inverted cursor — scroll if needed + let avail = (area.x + area.width).saturating_sub(x) as usize; + let cursor_char = entry.input[..entry.cursor].chars().count(); + let total_chars = entry.input.chars().count(); + let scroll = if total_chars < avail || cursor_char < avail.saturating_sub(1) { + 0 + } else { + cursor_char.saturating_sub(avail.saturating_sub(2)) + }; + for (char_idx, (byte_idx, ch)) in entry.input.char_indices().enumerate() { + if char_idx < scroll { + continue; + } if x >= area.x + area.width { break; } @@ -1536,9 +1544,9 @@ pub(super) fn render_source_control( let dim_fg = rc(theme.line_number_fg); let sel_bg = rc(theme.fuzzy_selected_bg); let row_bg = rc(theme.tab_bar_bg); - let add_fg = RColor::Rgb(90, 180, 90); - let del_fg = RColor::Rgb(220, 70, 60); - let mod_fg = RColor::Rgb(220, 180, 80); + let add_fg = rc(theme.git_added); + let del_fg = rc(theme.git_deleted); + let mod_fg = rc(theme.git_modified); // Build SC data from engine state via the render abstraction. let screen = render::build_screen_layout(engine, theme, &[], 1.0, 1.0, true); @@ -1717,9 +1725,9 @@ pub(super) fn render_source_control( let (fg, bg) = if is_focused { (hdr_bg, hdr_fg) // inverted = highlighted } else if is_hovered { - (item_fg, hover_bg) + (hdr_fg, hover_bg) } else { - (item_fg, btn_bg) + (hdr_fg, btn_bg) }; for px in bx..seg_end { set_cell(buf, px, btn_y, ' ', fg, bg); @@ -3002,11 +3010,7 @@ pub(super) fn render_ext_sidebar( let header_fg = rc(theme.status_fg); let header_bg = rc(theme.status_bg); - let sec_bg = ratatui::style::Color::Rgb( - (theme.status_bg.r as f64 * 0.85) as u8, - (theme.status_bg.g as f64 * 0.85) as u8, - (theme.status_bg.b as f64 * 0.85) as u8, - ); + let sec_bg = rc(theme.status_bg.darken(0.15)); let default_fg = rc(theme.foreground); let dim_fg = rc(theme.line_number_fg); let sel_bg = rc(theme.fuzzy_selected_bg); @@ -3375,7 +3379,7 @@ pub(super) fn render_debug_sidebar( let hdr_bg = rc(theme.status_bg); let item_fg = rc(theme.line_number_fg); let sel_bg = rc(theme.fuzzy_selected_bg); - let act_fg = rc(theme.tab_active_fg); + let act_fg = rc(theme.status_fg.lighten(0.2)); let row_bg = rc(theme.tab_bar_bg); // ── Row 0: header strip ────────────────────────────────────────────────── @@ -3398,21 +3402,21 @@ pub(super) fn render_debug_sidebar( // ── Row 1: Run / Stop button ───────────────────────────────────────────── let btn_y = area.y + 1; - let (btn_label, btn_fg) = if engine.dap_session_active && engine.dap_stopped_thread.is_some() { - ("\u{f04b} Continue", rc(Color::from_rgb(97, 186, 115))) - } else if engine.dap_session_active { - ("\u{f04d} Stop", rc(Color::from_rgb(220, 70, 56))) - } else { - ( - "\u{f04b} Start Debugging", - rc(Color::from_rgb(97, 186, 115)), - ) - }; + let (btn_label, btn_icon_fg) = + if engine.dap_session_active && engine.dap_stopped_thread.is_some() { + ("\u{f04b} Continue", rc(theme.git_added)) + } else if engine.dap_session_active { + ("\u{f04d} Stop", rc(theme.diagnostic_error)) + } else { + ("\u{f04b} Start Debugging", rc(theme.git_added)) + }; for x in area.x..area.x + area.width { - set_cell(buf, x, btn_y, ' ', btn_fg, hdr_bg); + set_cell(buf, x, btn_y, ' ', hdr_fg, hdr_bg); } + // Icon character gets the semantic color; label text uses status_fg for readability. for (i, ch) in btn_label.chars().enumerate().take(area.width as usize) { - set_cell(buf, area.x + i as u16, btn_y, ch, btn_fg, hdr_bg); + let fg = if i == 0 { btn_icon_fg } else { hdr_fg }; + set_cell(buf, area.x + i as u16, btn_y, ch, fg, hdr_bg); } // ── Sections with fixed-height allocation + per-section scrolling ────── @@ -3473,7 +3477,7 @@ pub(super) fn render_debug_sidebar( // are also stored on the sidebar data for reference.) let track_fg = rc(theme.separator); - let thumb_fg = RColor::Rgb(128, 128, 128); + let thumb_fg = rc(theme.scrollbar_thumb); let sb_bg = rc(theme.background); let mut row_y = area.y + 2; @@ -3576,6 +3580,8 @@ pub(super) fn render_bottom_panel_tabs( buf: &mut ratatui::buffer::Buffer, area: Rect, active: render::BottomPanelKind, + has_terminal: bool, + has_debug_output: bool, theme: &Theme, ) { if area.height == 0 { @@ -3590,12 +3596,23 @@ pub(super) fn render_bottom_panel_tabs( set_cell(buf, x, area.y, ' ', inactive_fg, tab_bg); } - let tabs = [ - (" Terminal ", render::BottomPanelKind::Terminal), - (" Debug Output ", render::BottomPanelKind::DebugOutput), + let all_tabs = [ + ( + " Terminal ", + render::BottomPanelKind::Terminal, + has_terminal, + ), + ( + " Debug Output ", + render::BottomPanelKind::DebugOutput, + has_debug_output, + ), ]; let mut cur_x = area.x; - for (label, kind) in &tabs { + for (label, kind, visible) in &all_tabs { + if !visible { + continue; + } let fg = if *kind == active { active_fg } else { @@ -3613,6 +3630,12 @@ pub(super) fn render_bottom_panel_tabs( break; } } + + // Close button (×) at right edge + let close_x = area.x + area.width.saturating_sub(2); + if close_x > cur_x { + set_cell(buf, close_x, area.y, '\u{00d7}', inactive_fg, tab_bg); // × + } } /// Render the debug output tab content with a scrollbar. @@ -3632,7 +3655,7 @@ pub(super) fn render_debug_output( let hdr_bg = rc(theme.status_bg); let item_fg = rc(theme.foreground); let row_bg = rc(theme.tab_bar_bg); - let sb_active = RColor::Rgb(128, 128, 128); + let sb_active = rc(theme.scrollbar_thumb); let sb_track = rc(theme.separator); // Header row @@ -3883,7 +3906,7 @@ pub(super) fn render_terminal_panel( if screen_row >= area.y + area.height { break; } - let term_bg = RColor::Rgb(30, 30, 30); + let term_bg = rc(theme.terminal_bg); // Clear both halves. for x in area.x..area.x + area.width.saturating_sub(1) { @@ -3891,18 +3914,26 @@ pub(super) fn render_terminal_panel( } // Left pane cells. - render_terminal_pane_cells(buf, left_rows, area.x, screen_row, half_w, row_idx); + render_terminal_pane_cells(buf, left_rows, area.x, screen_row, half_w, row_idx, theme); // Divider column. let div_fg = rc(theme.separator); set_cell(buf, div_col, screen_row, '│', div_fg, term_bg); // Right pane cells. - render_terminal_pane_cells(buf, &panel.rows, div_col + 1, screen_row, half_w, row_idx); + render_terminal_pane_cells( + buf, + &panel.rows, + div_col + 1, + screen_row, + half_w, + row_idx, + theme, + ); // Scrollbar in the last column. let (sb_char, sb_fg) = if row_idx >= thumb_start && row_idx < thumb_end { - ('█', RColor::Rgb(128, 128, 128)) + ('█', rc(theme.scrollbar_thumb)) } else { ('░', rc(theme.separator)) }; @@ -3926,17 +3957,25 @@ pub(super) fn render_terminal_panel( if screen_row >= area.y + area.height { break; } - let term_bg_default = RColor::Rgb(30, 30, 30); + let term_bg_default = rc(theme.terminal_bg); // Clear row with terminal default background (excluding scrollbar col). for x in area.x..area.x + cell_width { set_cell(buf, x, screen_row, ' ', hdr_fg, term_bg_default); } - render_terminal_pane_cells(buf, &panel.rows, area.x, screen_row, cell_width, row_idx); + render_terminal_pane_cells( + buf, + &panel.rows, + area.x, + screen_row, + cell_width, + row_idx, + theme, + ); // Scrollbar column — same colors as the editor scrollbar. let (sb_char, sb_fg) = if row_idx >= thumb_start && row_idx < thumb_end { - ('█', RColor::Rgb(128, 128, 128)) + ('█', rc(theme.scrollbar_thumb)) } else { ('░', rc(theme.separator)) }; @@ -3959,6 +3998,7 @@ pub(super) fn render_terminal_pane_cells( screen_row: u16, max_cols: u16, row_idx: usize, + theme: &Theme, ) { if row_idx >= rows.len() { return; @@ -3974,9 +4014,9 @@ pub(super) fn render_terminal_pane_cells( let (draw_fg, draw_bg) = if cell.is_cursor || cell.selected { (bg, fg) } else if cell.is_find_active { - (RColor::Rgb(0, 0, 0), RColor::Rgb(255, 165, 0)) + (rc(theme.search_match_fg), rc(theme.search_current_match_bg)) } else if cell.is_find_match { - (RColor::Rgb(255, 220, 0), RColor::Rgb(80, 65, 0)) + (rc(theme.search_match_fg), rc(theme.search_match_bg)) } else { (fg, bg) }; diff --git a/src/tui_main/render_impl.rs b/src/tui_main/render_impl.rs index 693dd0c3..695c2ef9 100644 --- a/src/tui_main/render_impl.rs +++ b/src/tui_main/render_impl.rs @@ -26,9 +26,16 @@ pub(super) fn build_screen_for_tui( } else { 0 }; - let content_rows = area - .height - .saturating_sub(2 + qf_height + term_height + menu_height + dbg_height + wildmenu_height); // status + cmd + panels + let per_window_status = engine.settings.window_status_line; + let global_status_rows: u16 = if per_window_status { 0 } else { 1 }; + let content_rows = area.height.saturating_sub( + 1 + global_status_rows + + qf_height + + term_height + + menu_height + + dbg_height + + wildmenu_height, + ); // cmd(1) + optional status(1) + panels let sidebar_cols = if sidebar.visible { sidebar_width + 1 } else { @@ -102,46 +109,17 @@ pub(super) fn draw_frame( ) { let area = frame.area(); - // ── Global vertical split: [menu] / [main] / [qf?] / [tabs?] / [term?] / [dbg?] / [status] / [cmd] ── - let qf_height: u16 = if screen.quickfix.is_some() { 6 } else { 0 }; - let terminal_open = screen.bottom_tabs.terminal.is_some(); - // Show the bottom panel when terminal is open OR when Debug Output tab is - // active and there are lines to display (DAP diagnostic output). - let debug_output_open = engine.bottom_panel_kind == render::BottomPanelKind::DebugOutput - && !screen.bottom_tabs.output_lines.is_empty(); - let bottom_panel_open = terminal_open || debug_output_open; - // 1 tab bar row + 1 header row + content rows - let bottom_panel_height: u16 = if bottom_panel_open { - engine.session.terminal_panel_rows + 2 - } else { - 0 - }; + // ── Top-level: [menu] / [content_area] ── let menu_bar_height: u16 = if screen.menu_bar.is_some() { 1 } else { 0 }; - let debug_toolbar_height: u16 = if screen.debug_toolbar.is_some() { 1 } else { 0 }; - let wildmenu_height: u16 = if screen.wildmenu.is_some() { 1 } else { 0 }; - let v_chunks = Layout::default() + let top_chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(menu_bar_height), - Constraint::Min(0), - Constraint::Length(qf_height), - Constraint::Length(bottom_panel_height), - Constraint::Length(debug_toolbar_height), - Constraint::Length(wildmenu_height), - Constraint::Length(1), - Constraint::Length(1), - ]) + .constraints([Constraint::Length(menu_bar_height), Constraint::Min(0)]) .split(area); - let menu_bar_area = v_chunks[0]; - let main_area = v_chunks[1]; - let quickfix_area = v_chunks[2]; - let bottom_panel_area = v_chunks[3]; - let debug_toolbar_area = v_chunks[4]; - let wildmenu_area = v_chunks[5]; - let status_area = v_chunks[6]; - let cmd_area = v_chunks[7]; - - // ── Horizontal split of main_area: [activity_bar] [sidebar?] [editor_col] ─ + let menu_bar_area = top_chunks[0]; + let content_area = top_chunks[1]; + + // ── Horizontal split: [activity_bar] [sidebar?] [editor_col] ─ + // Activity bar and sidebar span full height (like GTK layout). let ab_width = if engine.settings.autohide_panels && !sidebar.visible { 0 } else { @@ -159,10 +137,42 @@ pub(super) fn draw_frame( sidebar_constraint, Constraint::Min(0), ]) - .split(main_area); + .split(content_area); let activity_area = h_chunks[0]; let sidebar_sep_area = h_chunks[1]; - let editor_col = h_chunks[2]; + let right_col = h_chunks[2]; + + // ── Vertical split of editor column: [editor] / [qf?] / [bottom?] / [dbg?] / [wildmenu?] / [status?] / [cmd] ── + let qf_height: u16 = if screen.quickfix.is_some() { 6 } else { 0 }; + let bottom_panel_open = engine.terminal_open || engine.bottom_panel_open; + let bottom_panel_height: u16 = if bottom_panel_open { + engine.session.terminal_panel_rows + 2 + } else { + 0 + }; + let debug_toolbar_height: u16 = if screen.debug_toolbar.is_some() { 1 } else { 0 }; + let wildmenu_height: u16 = if screen.wildmenu.is_some() { 1 } else { 0 }; + let per_window_status = engine.settings.window_status_line; + let global_status_height: u16 = if per_window_status { 0 } else { 1 }; + let v_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), + Constraint::Length(qf_height), + Constraint::Length(bottom_panel_height), + Constraint::Length(debug_toolbar_height), + Constraint::Length(wildmenu_height), + Constraint::Length(global_status_height), + Constraint::Length(1), + ]) + .split(right_col); + let editor_col = v_chunks[0]; + let quickfix_area = v_chunks[1]; + let bottom_panel_area = v_chunks[2]; + let debug_toolbar_area = v_chunks[3]; + let wildmenu_area = v_chunks[4]; + let status_area = v_chunks[5]; + let cmd_area = v_chunks[6]; // The editor column includes the tab bar row(s). Window rects from // calculate_group_window_rects already have y >= 1 (tab_bar_height offset), @@ -536,6 +546,8 @@ pub(super) fn draw_frame( frame.buffer_mut(), tab_bar_area, engine.bottom_panel_kind.clone(), + engine.terminal_open, + !screen.bottom_tabs.output_lines.is_empty(), theme, ); match engine.bottom_panel_kind { @@ -567,13 +579,15 @@ pub(super) fn draw_frame( } // ── Status / command ────────────────────────────────────────────────────── - render_status_line( - frame.buffer_mut(), - status_area, - &screen.status_left, - &screen.status_right, - theme, - ); + if !per_window_status { + render_status_line( + frame.buffer_mut(), + status_area, + &screen.status_left, + &screen.status_right, + theme, + ); + } render_command_line(frame.buffer_mut(), cmd_area, &screen.command, theme); // Highlight command-line mouse selection (invert fg/bg for selected cells) @@ -647,12 +661,15 @@ pub(super) const TAB_CLOSE_CHAR: char = '×'; // U+00D7 MULTIPLICATION SIGN /// Terminal columns used by each tab's close button (the × itself + trailing space). pub(super) const TAB_CLOSE_COLS: u16 = 2; -// Split button glyphs: \u{F0932} (split-right), \u{F0931} (split-down). +// Split button glyphs: \u{F0932} (split-right), \u{f0d7} (caret-down / split-down). /// Terminal columns occupied by each split button (1 space + 2-wide NF glyph). pub(super) const TAB_SPLIT_BTN_COLS: u16 = 3; /// Total columns reserved for both split buttons. pub(super) const TAB_SPLIT_BOTH_COLS: u16 = TAB_SPLIT_BTN_COLS * 2; +/// Terminal columns for the editor action menu button ("…"). +pub(super) const TAB_ACTION_BTN_COLS: u16 = 3; + /// Terminal columns per diff toolbar button (1 space + 1 char + 1 space). pub(super) const DIFF_BTN_COLS: u16 = 3; /// Total columns for all three diff toolbar buttons. @@ -1023,7 +1040,8 @@ pub(super) fn render_tab_bar( } else { 0 }; - let reserved = diff_cols + split_cols; + let action_cols = TAB_ACTION_BTN_COLS; + let reserved = diff_cols + split_cols + action_cols; // Reserve columns at the right edge for buttons. let tab_end = if area.width >= reserved { @@ -1124,17 +1142,27 @@ pub(super) fn render_tab_bar( } } - // Draw split-right then split-down buttons at the right edge. - if show_split_btns && area.width >= split_cols { + // Draw split-right then split-down buttons, then the action menu button. + if show_split_btns && area.width >= split_cols + action_cols { let btn_fg = rc(theme.tab_inactive_fg); - let mut bx = area.x + area.width - split_cols; + let mut bx = area.x + area.width - split_cols - action_cols; // Split-right button (space + 2-wide NF glyph = 3 cols) set_cell(buf, bx, area.y, ' ', btn_fg, bar_bg); set_cell_wide(buf, bx + 1, area.y, '\u{F0932}', btn_fg, bar_bg); bx += TAB_SPLIT_BTN_COLS; - // Split-down button + // Split-down button (caret-down ▾) set_cell(buf, bx, area.y, ' ', btn_fg, bar_bg); - set_cell_wide(buf, bx + 1, area.y, '\u{F0931}', btn_fg, bar_bg); + set_cell(buf, bx + 1, area.y, '\u{f0d7}', btn_fg, bar_bg); + set_cell(buf, bx + 2, area.y, ' ', btn_fg, bar_bg); + } + + // Draw the editor action menu button ("…") at the far right. + if area.width >= action_cols { + let btn_fg = rc(theme.tab_inactive_fg); + let bx = area.x + area.width - action_cols; + set_cell(buf, bx, area.y, ' ', btn_fg, bar_bg); + set_cell(buf, bx + 1, area.y, '\u{22EF}', btn_fg, bar_bg); // ⋯ + set_cell(buf, bx + 2, area.y, ' ', btn_fg, bar_bg); } // Return the available tab bar width in columns so the engine can compute @@ -2578,6 +2606,21 @@ pub(super) fn render_window( window: &RenderedWindow, theme: &Theme, ) { + // Reserve bottom row for per-window status line when present + let status_bar_row = if window.status_line.is_some() && area.height > 1 { + Some(area.y + area.height - 1) + } else { + None + }; + let area = if status_bar_row.is_some() { + Rect { + height: area.height - 1, + ..area + } + } else { + area + }; + let window_bg = rc(if window.show_active_bg { theme.active_background } else { @@ -2776,11 +2819,13 @@ pub(super) fn render_window( DiagnosticSeverity::Information => theme.diagnostic_info, DiagnosticSeverity::Hint => theme.diagnostic_hint, }); - for col in dm.start_col..dm.end_col { - if col < window.scroll_left { + let vis_start = char_col_to_visual(&line.raw_text, dm.start_col, window.tabstop); + let vis_end = char_col_to_visual(&line.raw_text, dm.end_col, window.tabstop); + for vcol in vis_start..vis_end { + if vcol < window.scroll_left { continue; } - let vis_col = (col - window.scroll_left) as u16; + let vis_col = (vcol - window.scroll_left) as u16; if vis_col >= text_width { break; } @@ -2797,11 +2842,13 @@ pub(super) fn render_window( // Spell error underlines let spell_fg = rc(theme.spell_error); for sm in &line.spell_errors { - for col in sm.start_col..sm.end_col { - if col < window.scroll_left { + let vis_start = char_col_to_visual(&line.raw_text, sm.start_col, window.tabstop); + let vis_end = char_col_to_visual(&line.raw_text, sm.end_col, window.tabstop); + for vcol in vis_start..vis_end { + if vcol < window.scroll_left { continue; } - let vis_col = (col - window.scroll_left) as u16; + let vis_col = (vcol - window.scroll_left) as u16; if vis_col >= text_width { break; } @@ -2984,6 +3031,73 @@ pub(super) fn render_window( )); } } + + // ── Per-window status bar ──────────────────────────────────────────────── + if let (Some(status), Some(sy)) = (&window.status_line, status_bar_row) { + render_window_status_line(frame.buffer_mut(), area.x, sy, area.width, status, theme); + } +} + +/// Draw a per-window status line into the given row. +fn render_window_status_line( + buf: &mut ratatui::buffer::Buffer, + x: u16, + y: u16, + width: u16, + status: &crate::render::WindowStatusLine, + theme: &crate::render::Theme, +) { + use crate::render::StatusSegment; + + // Use the first segment's bg to fill any gaps in the row + let fill_bg = status + .left_segments + .first() + .or(status.right_segments.first()) + .map(|s| s.bg) + .unwrap_or(theme.background); + let bg = rc(fill_bg); + // Fill row with per-window status background + for col in 0..width { + set_cell(buf, x + col, y, ' ', bg, bg); + } + + let draw_segments = + |buf: &mut ratatui::buffer::Buffer, segments: &[StatusSegment], start_x: u16| { + let mut cx = start_x; + for seg in segments { + let fg = rc(seg.fg); + let seg_bg = rc(seg.bg); + for ch in seg.text.chars() { + if cx >= x + width { + return cx; + } + set_cell(buf, cx, y, ch, fg, seg_bg); + if seg.bold { + if let Some(cell) = buf.cell_mut(ratatui::layout::Position::new(cx, y)) { + cell.set_style( + ratatui::style::Style::default() + .add_modifier(ratatui::style::Modifier::BOLD), + ); + } + } + cx += 1; + } + } + cx + }; + + // Draw left segments from left edge + draw_segments(buf, &status.left_segments, x); + + // Draw right segments right-aligned + let right_width: u16 = status + .right_segments + .iter() + .map(|s| s.text.chars().count() as u16) + .sum(); + let right_start = (x + width).saturating_sub(right_width); + draw_segments(buf, &status.right_segments, right_start); } pub(super) fn render_scrollbar( @@ -3000,7 +3114,7 @@ pub(super) fn render_scrollbar( return; } let track_fg = rc(theme.separator); - let thumb_fg = RColor::Rgb(128, 128, 128); + let thumb_fg = rc(theme.scrollbar_thumb); let sb_bg = rc(theme.background); // Track height: reserve last row for h-scrollbar if present let track_h = if has_h_scrollbar { @@ -3040,7 +3154,7 @@ pub(super) fn render_h_scrollbar( if area.height == 0 || max_col == 0 || viewport_cols == 0 { return; } - let thumb_fg = RColor::Rgb(128, 128, 128); + let thumb_fg = rc(theme.scrollbar_thumb); let sb_bg = rc(theme.background); let corner_fg = rc(theme.separator); @@ -3281,7 +3395,7 @@ pub(super) fn render_separators( return; } let sep_fg = rc(theme.separator); - let thumb_fg = RColor::Rgb(128, 128, 128); + let thumb_fg = rc(theme.scrollbar_thumb); let track_fg = sep_fg; let sep_bg = rc(theme.background); @@ -3335,9 +3449,17 @@ pub(super) fn render_separators( } // Horizontal separator — also require horizontal overlap. + // Skip when the upper window has a per-window status bar (it replaces the separator). let h_overlap = a.rect.x.max(b.rect.x) < (a.rect.x + a.rect.width).min(b.rect.x + b.rect.width); - if (a.rect.y + a.rect.height - b.rect.y).abs() < 1.0 && h_overlap { + let upper_has_status = if (a.rect.y + a.rect.height - b.rect.y).abs() < 1.0 { + a.status_line.is_some() + } else if (b.rect.y + b.rect.height - a.rect.y).abs() < 1.0 { + b.status_line.is_some() + } else { + false + }; + if (a.rect.y + a.rect.height - b.rect.y).abs() < 1.0 && h_overlap && !upper_has_status { let sep_y = editor_area.y + (a.rect.y + a.rect.height) as u16; let x_start = editor_area.x + a.rect.x.max(b.rect.x) as u16; let x_end = diff --git a/tests/context_menu.rs b/tests/context_menu.rs index 1d127847..e767ea81 100644 --- a/tests/context_menu.rs +++ b/tests/context_menu.rs @@ -712,7 +712,7 @@ fn test_inline_rename_start() { let state = e.explorer_rename.as_ref().unwrap(); assert_eq!(state.path, path); assert_eq!(state.input, "old.txt"); - assert_eq!(state.cursor, 7); // "old.txt".len() + assert_eq!(state.cursor, 3); // "old" stem len (extension not selected) std::fs::remove_dir_all(&dir).ok(); } @@ -842,11 +842,14 @@ fn test_inline_rename_typing_and_cursor() { let state = e.explorer_rename.as_ref().unwrap(); assert_eq!(state.input, "dummy_rename_cursor.txt"); let initial_len = state.input.len(); + // Cursor starts at stem end (before ".txt"), not full name end + let stem_len = "dummy_rename_cursor".len(); // 19 + assert_eq!(state.cursor, stem_len); // Left arrow moves cursor back e.handle_explorer_rename_key("Left", None, false); let state = e.explorer_rename.as_ref().unwrap(); - assert_eq!(state.cursor, initial_len - 1); + assert_eq!(state.cursor, stem_len - 1); // Home moves cursor to start e.handle_explorer_rename_key("Home", None, false);