Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ All non-trivial work should be tracked via GitHub Issues.

**For all other changes:**

1. **Always work on a local branch off `develop`.** Never commit code directly to `develop`. Branch naming: `issue-{number}-{short-description}` or `{kind}-{short-description}`.
2. Do the work on that branch, committing as you go.
3. **Do NOT push the branch yet.** Keep it local until the user has run smoke tests or explicitly agreed testing is not needed.
1. **Claim the issue before starting work.** Multiple agents may be active concurrently — claim publicly so nobody picks up the same issue. Run `gh issue edit <N> --add-assignee @me`, create the feature branch from `develop` (`issue-{number}-{short-description}`), and push it empty so it appears on the remote as the claim signal. Pushing an empty branch is NOT opening a PR.
2. **Work on that branch**, committing as you go. Never commit code directly to `develop`. For non-issue work, use `{kind}-{short-description}` naming and you may skip the claim step.
3. **Do NOT open a PR yet.** Keep the branch in "commits pushed, no PR" state until the user has run smoke tests or explicitly agreed testing is not needed. Subsequent pushes to the claim branch are fine.
4. **Once approved, ask the user which landing path:**
- **Path A — merge locally + push.** For small/trivial changes: `git merge --ff-only <branch>`, push `develop`, delete the branch.
- **Path B — push branch + open PR.** For normal feature/bugfix work: push the branch, open a PR to `develop`. Reference "Closes #{number}" if it closes an issue.
5. **When the user confirms a merge that closes an issue**, immediately `gh issue close <number>`.
- **Path B — open PR.** For normal feature/bugfix work: open a PR to `develop` against the already-pushed branch. Reference "Closes #{number}" if it closes an issue.
5. **When the user confirms a merge that closes an issue**, immediately `gh issue close <number>` and unassign yourself.

**Creating issues:** Include full design context in the body — file paths, API details, expected behavior. Issues should be self-contained so a new session can pick one up.

Expand Down
3 changes: 2 additions & 1 deletion SUMMARIES/engine_mod.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# src/core/engine/mod.rs — 3,943 lines
# src/core/engine/mod.rs — 4,756 lines

Core engine definition. Contains the `Engine` struct (all editor state), enums, types, `new()` constructor, free functions, and `mod` declarations for all submodules.

Expand All @@ -20,6 +20,7 @@ Core engine definition. Contains the `Engine` struct (all editor state), enums,
- `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
- `BottomPanelGeometry` / `BottomPanelZone` — cached panel top_y/height/row_h + zone enum (TabBar/Toolbar/Content) for click dispatch via `resolve_bottom_panel_zone()` (#418)
- `UserKeymap` — user-defined key remapping
- `DiffPeekState` — inline diff peek popup state
- `SwapRecovery` — crash recovery swap file state
Expand Down
2 changes: 1 addition & 1 deletion SUMMARIES/gtk_draw.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# src/gtk/draw.rs — 5,122 lines
# src/gtk/draw.rs — 3,882 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`.

Expand Down
2 changes: 1 addition & 1 deletion SUMMARIES/gtk_mod.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# src/gtk/mod.rs — 10,973 lines
# src/gtk/mod.rs — 10,888 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.

Expand Down
40 changes: 40 additions & 0 deletions src/core/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,41 @@ pub enum TerminalToolbarHits {
TabStrip(quadraui::TabBarHits),
}

/// Cached vertical geometry of the bottom panel (tab bar + toolbar + content),
/// written at paint time so click handlers don't have to recompute the snapped
/// panel top from chrome/status/wildmenu heights. Units match the backend's
/// click coordinates: pixels for GTK, character rows for TUI.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BottomPanelGeometry {
/// Top edge of the tab-bar row (= panel top).
pub top_y: f64,
/// Total panel height in the same unit as `top_y`.
pub height: f64,
/// Y offset of the toolbar row relative to `top_y`.
/// GTK: `(line_height * 1.6).ceil()` (tab bar is taller than a normal row).
/// TUI: `1.0`.
pub toolbar_y: f64,
/// Y offset of the content area relative to `top_y`.
/// GTK: `toolbar_y + line_height`. TUI: `2.0`.
pub content_y: f64,
/// Height of one content row (line_height for GTK, 1.0 for TUI).
/// Used to compute `row_offset` within the content zone.
pub content_row_h: f64,
}

/// Click zone within the bottom panel as resolved by
/// [`Engine::resolve_bottom_panel_zone`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BottomPanelZone {
/// Row 0 — the shared "TERMINAL / DEBUG CONSOLE" tab strip.
TabBar,
/// Row 1 — the per-terminal toolbar (tab strip or find bar).
Toolbar,
/// Row 2+ — the panel content. `row_offset` is the row index relative
/// to the top of the content area (0 = first content row).
Content { row_offset: u16 },
}

/// Action resolved from a terminal toolbar click.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalToolbarAction {
Expand Down Expand Up @@ -2947,6 +2982,10 @@ pub struct Engine {
/// Cached hit data from the last paint of the terminal toolbar (find bar
/// or tab strip). Written at paint time; read by `resolve_terminal_toolbar_click`.
pub terminal_toolbar_hits: std::cell::RefCell<Option<TerminalToolbarHits>>,
/// Cached vertical geometry of the bottom panel from the last paint.
/// Written at paint time by both backends; read by `resolve_bottom_panel_zone`
/// so click handlers don't recompute the snapped panel top (#418).
pub bottom_panel_geometry: std::cell::RefCell<Option<BottomPanelGeometry>>,
/// Cached layout from the last paint of the command center (nav arrows + search box).
/// Written at paint time; read by click handlers.
pub command_center_layout: std::cell::RefCell<Option<quadraui::CommandCenterLayout>>,
Expand Down Expand Up @@ -3684,6 +3723,7 @@ impl Engine {
bottom_panel_kind: BottomPanelKind::Terminal,
bottom_tab_bar_hits: std::cell::RefCell::new(None),
terminal_toolbar_hits: std::cell::RefCell::new(None),
bottom_panel_geometry: std::cell::RefCell::new(None),
command_center_layout: std::cell::RefCell::new(None),
dap_pending_launch: None,
bottom_panel_open: false,
Expand Down
25 changes: 25 additions & 0 deletions src/core/engine/terminal_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,31 @@ impl Engine {
self.terminal_has_focus = false;
}

/// Resolve which zone of the bottom panel contains the click y-coordinate
/// using the geometry cached at paint time. Returns `None` if the panel
/// isn't currently painted or `y` is above the panel top. `y` is in the
/// caller's unit (pixels for GTK, character rows for TUI) — must match
/// what the backend wrote into [`BottomPanelGeometry`] at paint time.
pub fn resolve_bottom_panel_zone(&self, y: f64) -> Option<BottomPanelZone> {
let g = (*self.bottom_panel_geometry.borrow())?;
if y < g.top_y || y >= g.top_y + g.height {
return None;
}
let rel = y - g.top_y;
let zone = if rel < g.toolbar_y {
BottomPanelZone::TabBar
} else if rel < g.content_y {
BottomPanelZone::Toolbar
} else if g.content_row_h > 0.0 {
BottomPanelZone::Content {
row_offset: ((rel - g.content_y) / g.content_row_h) as u16,
}
} else {
BottomPanelZone::Content { row_offset: 0 }
};
Some(zone)
}

/// Dispatch a click on the bottom panel tab bar using the cached
/// `TabBarHits` from the last paint. Returns `true` if the click
/// was consumed (tab switch or panel close).
Expand Down
21 changes: 13 additions & 8 deletions src/gtk/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,14 +372,8 @@ pub(super) fn draw_editor(

// 5b. Draw completion popup (on top of everything else). Cache
// the layout so the click handler can hit-test items.
*completion_layout_out.borrow_mut() = draw_completion_popup(
cr,
&layout,
&screen,
&theme,
line_height,
char_width,
);
*completion_layout_out.borrow_mut() =
draw_completion_popup(cr, &layout, &screen, &theme, line_height, char_width);

// 5c. Draw hover popup (on top of everything else)
draw_hover_popup(
Expand Down Expand Up @@ -513,6 +507,15 @@ pub(super) fn draw_editor(
let y = height as f64 - status_bar_height - debug_toolbar_px - term_px;
(y, term_px)
};
engine
.bottom_panel_geometry
.replace(Some(crate::core::engine::BottomPanelGeometry {
top_y: term_y,
height: term_px,
toolbar_y: line_height,
content_y: 2.0 * line_height,
content_row_h: line_height,
}));
// Tab bar row (1 line high) at the top of the bottom panel area.
let hits = draw_bottom_panel_tabs(
backend,
Expand Down Expand Up @@ -696,6 +699,8 @@ pub(super) fn draw_editor(
});
}
}
} else {
engine.bottom_panel_geometry.replace(None);
}

// 5h. Draw debug toolbar strip if visible (above status bar)
Expand Down
116 changes: 16 additions & 100 deletions src/gtk/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6752,64 +6752,17 @@ impl App {
self.engine.borrow_mut().clear_sidebar_focus();
// Check if click lands in the terminal panel before general handling.
// Layout (bottom to top): status | toolbar | terminal | quickfix | DAP | editor
let in_terminal = if self.cached_line_height > 0.0 {
let engine = self.engine.borrow();
if engine.terminal_open || engine.bottom_panel_open {
// Pixel-exact terminal Y must match the draw function's
// special-case snapping for maximize (#386). The draw
// function caches hit regions on engine but this handler
// predates that — kept as-is until migrated to cached hits.
let lh = self.cached_line_height;
let tab_row_h = (lh * 1.6).ceil();
let per_window = engine.settings.window_status_line;
let bp_open = engine.terminal_open || engine.bottom_panel_open;
let has_sep =
per_window && !engine.settings.status_line_above_terminal && bp_open;
let sep_px = if has_sep { lh } else { 0.0 };
let wild_px = if engine.wildmenu_items.is_empty() {
0.0
} else {
lh
};
let status_h = lh * if per_window { 1.0 } else { 2.0 } + wild_px;
let dbg_px = if engine.debug_toolbar_visible {
lh
} else {
0.0
};
let term_y = if engine.terminal_maximized {
tab_row_h
} else {
let el = render::compute_editor_layout(&engine, height, lh, false);
height - status_h - dbg_px - sep_px - el.terminal_h
};
if y >= term_y {
// 0 = tab bar, 1 = toolbar, 2 = content
let zone = if y >= term_y + 2.0 * self.cached_line_height {
2
} else if y >= term_y + self.cached_line_height {
1
} else {
0
};
Some((term_y, zone))
} else {
None
}
} else {
None
}
} else {
None
};
if let Some((term_y, zone)) = in_terminal {
if zone == 0 {
// Geometry is cached at paint time on engine.bottom_panel_geometry (#418).
let zone = self.engine.borrow().resolve_bottom_panel_zone(y);
if let Some(zone) = zone {
use crate::core::engine::BottomPanelZone;
if matches!(zone, BottomPanelZone::TabBar) {
self.engine.borrow_mut().handle_bottom_tab_bar_click(x);
sender.input(Msg::Resize);
return;
}
self.engine.borrow_mut().terminal_has_focus = true;
if zone == 2 {
if let BottomPanelZone::Content { row_offset } = zone {
const SB_W: f64 = 6.0;
// In split mode: detect a click on the divider (start drag)
// or set keyboard focus to the appropriate pane.
Expand Down Expand Up @@ -6838,8 +6791,7 @@ impl App {
};
if !on_divider {
self.terminal_resize_dragging = false;
let row = ((y - term_y - 2.0 * self.cached_line_height)
/ self.cached_line_height) as u16;
let row = row_offset;
let col = (x / self.cached_char_width.max(1.0)) as u16;
self.engine.borrow_mut().terminal_scroll_reset();
if let Some(term) = self.engine.borrow_mut().active_terminal_mut() {
Expand Down Expand Up @@ -7447,52 +7399,15 @@ impl App {
self.draw_needed.set(true);
}
} else {
// Check if drag is in the terminal content area (text selection).
let in_terminal = if self.cached_line_height > 0.0 {
let engine = self.engine.borrow();
if engine.terminal_open || engine.bottom_panel_open {
// Pixel-exact terminal Y must match the draw function's
// special-case snapping for maximize (#386). The draw
// function caches hit regions on engine but this handler
// predates that — kept as-is until migrated to cached hits.
let lh = self.cached_line_height;
let tab_row_h = (lh * 1.6).ceil();
let per_window = engine.settings.window_status_line;
let bp_open = engine.terminal_open || engine.bottom_panel_open;
let has_sep =
per_window && !engine.settings.status_line_above_terminal && bp_open;
let sep_px = if has_sep { lh } else { 0.0 };
let wild_px = if engine.wildmenu_items.is_empty() {
0.0
} else {
lh
};
let status_h = lh * if per_window { 1.0 } else { 2.0 } + wild_px;
let dbg_px = if engine.debug_toolbar_visible {
lh
} else {
0.0
};
let term_y = if engine.terminal_maximized {
tab_row_h
} else {
let el = render::compute_editor_layout(&engine, height, lh, false);
height - status_h - dbg_px - sep_px - el.terminal_h
};
if y >= term_y + 2.0 * self.cached_line_height {
Some(term_y)
} else {
None
}
} else {
None
// Drag in the terminal content area (text selection). Geometry is
// cached at paint time on engine.bottom_panel_geometry (#418).
let content_row = match self.engine.borrow().resolve_bottom_panel_zone(y) {
Some(crate::core::engine::BottomPanelZone::Content { row_offset }) => {
Some(row_offset)
}
} else {
None
_ => None,
};
if let Some(term_y) = in_terminal {
let row =
((y - term_y - 2.0 * self.cached_line_height) / self.cached_line_height) as u16;
if let Some(row) = content_row {
let col = (x / self.cached_char_width.max(1.0)) as u16;
if let Some(term) = self.engine.borrow_mut().active_terminal_mut() {
if let Some(ref mut sel) = term.selection {
Expand Down Expand Up @@ -8207,7 +8122,8 @@ impl App {
self.draw_needed.set(true);
}
Msg::TerminalCopySelection => {
if let Some(text) = self.engine.borrow_mut().terminal_copy_selection() {
let text = self.engine.borrow_mut().terminal_copy_selection();
if let Some(text) = text {
let engine = self.engine.borrow();
if let Some(ref cb) = engine.clipboard_write {
let _ = cb(text.as_str());
Expand Down
Loading