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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ All non-trivial work should be tracked via GitHub Issues. Issues are the source
**Starting work on an issue:**
1. Create a feature branch from `develop`: `git checkout -b issue-{number}-{short-description} develop`
2. Do the work on that branch, committing as you go
3. When done, create a PR to `develop` using `gh pr create` — reference the issue with "Closes #{number}" in the PR body
4. The user reviews and merges the PR. When the user confirms the merge, immediately close the issue with `gh issue close <number> -c "Implemented in PR #N"` — do not rely on GitHub auto-close
3. **Do NOT push or create a PR until the user has run smoke tests and confirmed the changes work.** Commit locally, offer smoke tests, wait for approval before pushing.
4. When the user approves, push and create a PR to `develop` using `gh pr create` — reference the issue with "Closes #{number}" in the PR body
5. The user reviews and merges the PR. When the user confirms the merge, immediately close the issue with `gh issue close <number> -c "Implemented in PR #N"` — do not rely on GitHub auto-close

**Creating issues:**
- At session end, create issues for any planned but unstarted work discussed during the session
Expand Down
2 changes: 1 addition & 1 deletion src/core/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1227,7 +1227,7 @@ pub fn resolve_context_menu_click(
}

// Inside popup — find which item
let inner_row = click_row - py - 1; // -1 for top border
let inner_row = click_row.saturating_sub(py).saturating_sub(1); // -1 for top border
let mut visual_row: u16 = 0;
for (i, item) in items.iter().enumerate() {
if visual_row == inner_row && item.enabled {
Expand Down
153 changes: 153 additions & 0 deletions src/gtk/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,18 @@ pub(super) fn draw_editor(
line_height,
);
*dialog_btn_rects_out.borrow_mut() = btn_rects;

draw_context_menu_popup(
cr,
&layout,
&screen,
&theme,
width as f64,
height as f64,
char_width,
line_height,
mouse_pos,
);
}

/// Draw thin Cairo horizontal scrollbars that overlay the bottom of each editor
Expand Down Expand Up @@ -3430,6 +3442,147 @@ pub(super) fn draw_dialog_popup(
rects
}

/// Draw an engine-driven context menu popup on the DrawingArea.
/// Uses the same data as TUI/Win-GUI for visual consistency.
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_context_menu_popup(
cr: &Context,
_layout: &pango::Layout,
screen: &render::ScreenLayout,
theme: &Theme,
editor_width: f64,
editor_height: f64,
char_width: f64,
line_height: f64,
mouse_pos: (f64, f64),
) {
let Some(cm) = &screen.context_menu else {
return;
};
if cm.items.is_empty() {
return;
}

let pango_ctx = pangocairo::create_context(cr);
let ui_font_desc = FontDescription::from_string(UI_FONT);
let ui_layout = pango::Layout::new(&pango_ctx);
ui_layout.set_font_description(Some(&ui_font_desc));

// Calculate popup dimensions.
let sep_count = cm.items.iter().filter(|i| i.separator_after).count();
let max_label = cm.items.iter().map(|i| i.label.len()).max().unwrap_or(4);
let max_sc = cm.items.iter().map(|i| i.shortcut.len()).max().unwrap_or(0);
let content_cols = (max_label + max_sc + 6).clamp(20, 50);
let popup_w = content_cols as f64 * char_width;
let popup_h = (cm.items.len() + sep_count + 2) as f64 * line_height;

// Position: use char-cell coordinates from engine, scaled to pixels.
let raw_x = cm.screen_col as f64 * char_width;
let raw_y = cm.screen_row as f64 * line_height;
let px = raw_x.min(editor_width - popup_w);
let py = raw_y.min(editor_height - popup_h);

// Background.
let (r, g, b) = theme.fuzzy_bg.to_cairo();
cr.set_source_rgb(r, g, b);
cr.rectangle(px, py, popup_w, popup_h);
cr.fill().ok();

// Border.
let (r, g, b) = theme.fuzzy_border.to_cairo();
cr.set_source_rgb(r, g, b);
cr.set_line_width(1.0);
cr.rectangle(px, py, popup_w, popup_h);
cr.stroke().ok();

// Compute hovered item from mouse position (avoids engine borrow in motion callback).
let hover_idx: Option<usize> = if mouse_pos.0 >= 0.0 {
let mcol = (mouse_pos.0 / char_width) as u16;
let mrow = (mouse_pos.1 / line_height) as u16;
let tw = (editor_width / char_width) as u16;
let th = (editor_height / line_height) as u16;
match crate::core::engine::resolve_context_menu_click(
&cm.items
.iter()
.map(|i| crate::core::engine::ContextMenuItem {
label: i.label.clone(),
action: String::new(),
shortcut: i.shortcut.clone(),
separator_after: i.separator_after,
enabled: i.enabled,
})
.collect::<Vec<_>>(),
cm.screen_col,
cm.screen_row,
tw,
th,
mcol,
mrow,
) {
crate::core::engine::ContextMenuClickResult::Item(idx) => Some(idx),
_ => None,
}
} else {
None
};
// Use hover index if mouse is over an item; otherwise keep engine selection
// (preserves last-hovered or keyboard-navigated item when mouse leaves).
let selected = hover_idx.unwrap_or(cm.selected_idx);

// Items.
let mut visual_row: usize = 0;
let item_x = px + char_width;
for (i, item) in cm.items.iter().enumerate() {
let item_y = py + (visual_row + 1) as f64 * line_height;

// Selection highlight.
if i == selected && item.enabled {
let (r, g, b) = theme.fuzzy_selected_bg.to_cairo();
cr.set_source_rgb(r, g, b);
cr.rectangle(px + 1.0, item_y, popup_w - 2.0, line_height);
cr.fill().ok();
}

// Label — disabled items heavily darkened for obvious visual distinction.
let fg = if item.enabled {
theme.fuzzy_fg
} else {
theme.fuzzy_fg.darken(0.5)
};
let (r, g, b) = fg.to_cairo();
cr.set_source_rgb(r, g, b);
ui_layout.set_text(&item.label);
ui_layout.set_attributes(None);
cr.move_to(item_x, item_y);
pangocairo::show_layout(cr, &ui_layout);

// Shortcut (right-aligned).
if !item.shortcut.is_empty() {
ui_layout.set_text(&item.shortcut);
let (sw, _) = ui_layout.pixel_size();
let sc_x = px + popup_w - sw as f64 - char_width;
let (r, g, b) = theme.line_number_fg.to_cairo();
cr.set_source_rgb(r, g, b);
cr.move_to(sc_x, item_y);
pangocairo::show_layout(cr, &ui_layout);
}

visual_row += 1;

// Separator line.
if item.separator_after {
let sep_y = py + (visual_row + 1) as f64 * line_height + line_height / 2.0;
let (r, g, b) = theme.fuzzy_border.to_cairo();
cr.set_source_rgb(r, g, b);
cr.set_line_width(0.5);
cr.move_to(px + 4.0, sep_y);
cr.line_to(px + popup_w - 4.0, sep_y);
cr.stroke().ok();
visual_row += 1;
}
}
}

/// Draw the tab bar for the bottom panel (Terminal / Debug Output).
/// One row high at `(x, y)`, full width `w`.
#[allow(clippy::too_many_arguments)]
Expand Down
Loading
Loading