diff --git a/src/core/engine/mod.rs b/src/core/engine/mod.rs
index ae03831c..3812d004 100644
--- a/src/core/engine/mod.rs
+++ b/src/core/engine/mod.rs
@@ -2867,6 +2867,9 @@ 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>,
+ /// Cached layout from the last paint of the menu bar strip.
+ /// Written at paint time; read by click/hover handlers.
+ pub menu_bar_layout: std::cell::RefCell >,
/// Launch arguments stored between `initialize` send and response receipt.
/// We defer `launch`/`attach` until the adapter confirms `initialize` to avoid a race
/// where codelldb processes both requests concurrently and reads arguments
@@ -3536,6 +3539,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),
+ menu_bar_layout: std::cell::RefCell::new(None),
dap_pending_launch: None,
bottom_panel_open: false,
dap_wants_sidebar: false,
diff --git a/src/gtk/draw.rs b/src/gtk/draw.rs
index 2696a624..b9ae4463 100644
--- a/src/gtk/draw.rs
+++ b/src/gtk/draw.rs
@@ -2834,8 +2834,9 @@ pub(super) fn draw_command_line(
}
}
-/// Returns `(back_x, back_end, fwd_x, fwd_end, unit_end)` — pixel hit rects for nav arrows
-/// and the right edge of the entire interactive area (arrows + search box).
+/// Returns `(MenuBarLayout, (back_x, back_end, fwd_x, fwd_end, unit_end))` — the quadraui
+/// layout for menu label hit-testing plus pixel hit rects for nav arrows / search box.
+#[allow(clippy::type_complexity)]
pub(super) fn draw_menu_bar(
cr: &Context,
data: &render::MenuBarData,
@@ -2844,63 +2845,43 @@ pub(super) fn draw_menu_bar(
y: f64,
width: f64,
height: f64,
-) -> (f64, f64, f64, f64, f64) {
- // Title bar background: use tab_bar_bg (adapts to light/dark themes).
- let (tbr, tbg, tbb) = theme.tab_bar_bg.to_cairo();
- cr.set_source_rgb(tbr, tbg, tbb);
- cr.rectangle(x, y, width, height);
- let _ = cr.fill();
-
+) -> (quadraui::MenuBarLayout, (f64, f64, f64, f64, f64)) {
let pango_ctx = pangocairo::create_context(cr);
let font_desc = pango::FontDescription::from_string(&UI_FONT());
- let layout = pango::Layout::new(&pango_ctx);
- layout.set_font_description(Some(&font_desc));
+ let pango_layout = pango::Layout::new(&pango_ctx);
+ pango_layout.set_font_description(Some(&font_desc));
- let (fr, fg, fb) = theme.foreground.to_cairo();
- cr.set_source_rgb(fr, fg, fb);
+ let q_theme = super::quadraui_gtk::q_theme(theme);
+ let bar = render::build_menu_bar_view(data.open_menu_idx);
+ let mb_layout =
+ quadraui::gtk::draw_menu_bar(cr, &pango_layout, x, y, width, height, &bar, &q_theme);
- // Menu labels
- let mut cursor_x = x + 8.0;
+ let (fr, fg, fb) = theme.foreground.to_cairo();
- for (idx, (name, _, _)) in render::MENU_STRUCTURE.iter().enumerate() {
- let is_open = data.open_menu_idx == Some(idx);
- if is_open {
- let (ar, ag, ab) = theme.keyword.to_cairo();
- cr.set_source_rgb(ar, ag, ab);
- } else {
- cr.set_source_rgb(fr, fg, fb);
- }
- layout.set_text(name);
- let (_lw, lh) = layout.pixel_size();
- cr.move_to(cursor_x, y + (height - lh as f64) / 2.0);
- pangocairo::show_layout(cr, &layout);
- // Use same metric as click/hover handlers: 7px/char + 10px padding.
- cursor_x += name.len() as f64 * 7.0 + 10.0;
- }
+ let menu_end_x = mb_layout
+ .visible_items
+ .last()
+ .map(|vi| x + (vi.bounds.x + vi.bounds.width) as f64)
+ .unwrap_or(x);
// Centered nav arrows + search box (like VSCode Command Center).
- // The entire unit is centered between the menu labels and the right edge.
- let menu_end_x = cursor_x;
-
- // Measure arrow widths.
- layout.set_text("\u{25C0}"); // ◀
- let (back_w, _) = layout.pixel_size();
- layout.set_text("\u{25B6}"); // ▶
- let (fwd_w, _) = layout.pixel_size();
+ pango_layout.set_text("\u{25C0}"); // ◀
+ let (back_w, _) = pango_layout.pixel_size();
+ pango_layout.set_text("\u{25B6}"); // ▶
+ let (fwd_w, _) = pango_layout.pixel_size();
let arrow_gap = 6.0;
let arrows_w = back_w as f64 + arrow_gap + fwd_w as f64;
- // Measure search box text.
let display = if data.title.is_empty() {
String::new()
} else {
format!("\u{1f50d} {}", data.title)
};
let box_pad = 12.0;
- let min_box_w = 280.0; // minimum search bar width to match VSCode proportions
+ let min_box_w = 280.0;
let (box_text_w, _) = if !display.is_empty() {
- layout.set_text(&display);
- layout.pixel_size()
+ pango_layout.set_text(&display);
+ pango_layout.pixel_size()
} else {
(0, 0)
};
@@ -2912,11 +2893,9 @@ pub(super) fn draw_menu_bar(
let gap_between = if box_w > 0.0 { 10.0 } else { 0.0 };
let total_unit_w = arrows_w + gap_between + box_w;
- // Center the unit between menu_end_x and right edge.
let available = x + width - menu_end_x;
let unit_x = (menu_end_x + (available - total_unit_w) / 2.0).max(menu_end_x + 8.0);
- // Draw back arrow.
let dim_fg = theme.line_number_fg;
let back_color = if data.nav_back_enabled {
theme.foreground
@@ -2925,12 +2904,12 @@ pub(super) fn draw_menu_bar(
};
let (br2, bg2, bb2) = back_color.to_cairo();
cr.set_source_rgb(br2, bg2, bb2);
- layout.set_text("\u{25C0}");
- let (_, bh) = layout.pixel_size();
+ pango_layout.set_text("\u{25C0}");
+ pango_layout.set_attributes(None);
+ let (_, bh) = pango_layout.pixel_size();
cr.move_to(unit_x, y + (height - bh as f64) / 2.0);
- pangocairo::show_layout(cr, &layout);
+ pangocairo::show_layout(cr, &pango_layout);
- // Draw forward arrow.
let fwd_color = if data.nav_forward_enabled {
theme.foreground
} else {
@@ -2938,21 +2917,19 @@ pub(super) fn draw_menu_bar(
};
let (fr2, fg2, fb2) = fwd_color.to_cairo();
cr.set_source_rgb(fr2, fg2, fb2);
- layout.set_text("\u{25B6}");
- let (_, fh) = layout.pixel_size();
+ pango_layout.set_text("\u{25B6}");
+ let (_, fh) = pango_layout.pixel_size();
cr.move_to(
unit_x + back_w as f64 + arrow_gap,
y + (height - fh as f64) / 2.0,
);
- pangocairo::show_layout(cr, &layout);
+ pangocairo::show_layout(cr, &pango_layout);
- // Draw search box.
if !display.is_empty() {
let bx = unit_x + arrows_w + gap_between;
let by = y + 3.0;
let bh_box = height - 6.0;
let radius = 4.0;
- // Border
let (sr, sg, sb) = theme.separator.to_cairo();
cr.set_source_rgb(sr, sg, sb);
cr.new_path();
@@ -2987,23 +2964,24 @@ pub(super) fn draw_menu_bar(
cr.close_path();
cr.set_line_width(1.0);
let _ = cr.stroke();
- // Text inside box — same color as menu labels (foreground)
cr.set_source_rgb(fr, fg, fb);
- layout.set_text(&display);
- let (_, th) = layout.pixel_size();
+ pango_layout.set_text(&display);
+ let (_, th) = pango_layout.pixel_size();
cr.move_to(bx + box_pad, y + (height - th as f64) / 2.0);
- pangocairo::show_layout(cr, &layout);
+ pangocairo::show_layout(cr, &pango_layout);
}
- // Return pixel hit rects for back and forward arrows + interactive area end.
- let fwd_x = unit_x + back_w as f64 + arrow_gap;
+ let fwd_x_pos = unit_x + back_w as f64 + arrow_gap;
let unit_end = unit_x + total_unit_w;
(
- unit_x,
- unit_x + back_w as f64,
- fwd_x,
- fwd_x + fwd_w as f64,
- unit_end,
+ mb_layout,
+ (
+ unit_x,
+ unit_x + back_w as f64,
+ fwd_x_pos,
+ fwd_x_pos + fwd_w as f64,
+ unit_end,
+ ),
)
}
diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs
index 332fb26c..83f0a837 100644
--- a/src/gtk/mod.rs
+++ b/src/gtk/mod.rs
@@ -2219,6 +2219,8 @@ impl SimpleComponent for App {
#[allow(clippy::type_complexity)]
let nav_arrow_rects_cell: Rc> =
Rc::new(RefCell::new((0.0, 0.0, 0.0, 0.0, 0.0)));
+ let menu_bar_layout_cell: Rc>> =
+ Rc::new(RefCell::new(None));
let sidebar_inner_sw_ref: Rc>> =
Rc::new(RefCell::new(None));
let sidebar_revealer_ref: Rc>> = Rc::new(RefCell::new(None));
@@ -3037,9 +3039,9 @@ impl SimpleComponent for App {
{
let engine = engine.clone();
let nav_rects = nav_arrow_rects_cell.clone();
+ let mb_layout_draw = menu_bar_layout_cell.clone();
widgets.menu_bar_da.set_draw_func(move |da, cr, _w, _h| {
let engine = engine.borrow();
- // Menu bar is always visible in GTK (acts as the window title bar).
let theme = Theme::from_name(&engine.settings.colorscheme);
let open_items: Vec = if let Some(midx) = engine.menu_open_idx
{
@@ -3080,8 +3082,9 @@ impl SimpleComponent for App {
};
let w = da.width() as f64;
let h = da.height() as f64;
- let rects = draw_menu_bar(cr, &data, &theme, 0.0, 0.0, w, h);
- *nav_rects.borrow_mut() = rects;
+ let (mb_layout, arrow_rects) = draw_menu_bar(cr, &data, &theme, 0.0, 0.0, w, h);
+ *nav_rects.borrow_mut() = arrow_rects;
+ *mb_layout_draw.borrow_mut() = Some(mb_layout);
});
}
// Click gesture: open/close individual menus (no hamburger zone here).
@@ -3089,29 +3092,25 @@ impl SimpleComponent for App {
let sender_menu = sender.input_sender().clone();
let engine_menu = engine.clone();
let nav_rects_click = nav_arrow_rects_cell.clone();
+ let mb_layout_click = menu_bar_layout_cell.clone();
let gesture = gtk4::GestureClick::new();
gesture.set_button(1);
gesture.connect_pressed(move |gest, _, x, _y| {
let engine = engine_menu.borrow();
- // Scan menu labels from left edge (no hamburger on this widget).
- // Use ~7px/char + 10px padding as approximation for UI font metrics.
- let mut cursor_x = 8.0_f64;
- for (idx, (name, _, _)) in render::MENU_STRUCTURE.iter().enumerate() {
- let item_w = name.len() as f64 * 7.0 + 10.0;
- if x >= cursor_x && x < cursor_x + item_w {
- if engine.menu_open_idx == Some(idx) {
- sender_menu.send(Msg::CloseMenu).ok();
- } else {
- sender_menu.send(Msg::OpenMenu(idx)).ok();
- }
- return;
+ let mb_hit = mb_layout_click
+ .borrow()
+ .as_ref()
+ .map(|l| l.hit_test(x as f32, 0.5));
+ if let Some(quadraui::MenuBarHit::Item(idx)) = mb_hit {
+ if engine.menu_open_idx == Some(idx) {
+ sender_menu.send(Msg::CloseMenu).ok();
+ } else {
+ sender_menu.send(Msg::OpenMenu(idx)).ok();
}
- cursor_x += item_w;
+ return;
}
- // Use cached arrow pixel positions from draw_menu_bar.
let (back_x, back_end, fwd_x, fwd_end, unit_end) = *nav_rects_click.borrow();
if x >= back_x && x < back_end {
- // Claim the gesture so WindowHandle doesn't maximize on double-click.
gest.set_state(gtk4::EventSequenceState::Claimed);
sender_menu.send(Msg::MruNavBack).ok();
return;
@@ -3121,18 +3120,14 @@ impl SimpleComponent for App {
sender_menu.send(Msg::MruNavForward).ok();
return;
}
- // Click on the search box area → open Command Center.
if x >= fwd_end && x < unit_end {
gest.set_state(gtk4::EventSequenceState::Claimed);
sender_menu.send(Msg::OpenCommandCenter).ok();
return;
}
- // Claim clicks within the nav+search box area to prevent
- // WindowHandle double-click-to-maximize on the search box.
if x >= back_x && x < unit_end {
gest.set_state(gtk4::EventSequenceState::Claimed);
}
- // Click in empty part of bar → close any open dropdown
if engine.menu_open_idx.is_some() {
sender_menu.send(Msg::CloseMenu).ok();
}
@@ -3143,23 +3138,21 @@ impl SimpleComponent for App {
{
let sender_hover = sender.input_sender().clone();
let engine_hover = engine.clone();
+ let mb_layout_hover = menu_bar_layout_cell.clone();
let motion = gtk4::EventControllerMotion::new();
motion.connect_motion(move |_, x, _y| {
let engine = engine_hover.borrow();
- // Only switch if a menu is already open.
let Some(current) = engine.menu_open_idx else {
return;
};
- let mut cursor_x = 8.0_f64;
- for (idx, (name, _, _)) in render::MENU_STRUCTURE.iter().enumerate() {
- let item_w = name.len() as f64 * 7.0 + 10.0;
- if x >= cursor_x && x < cursor_x + item_w {
- if idx != current {
- sender_hover.send(Msg::OpenMenu(idx)).ok();
- }
- return;
+ let hit = mb_layout_hover
+ .borrow()
+ .as_ref()
+ .map(|l| l.hit_test(x as f32, 0.5));
+ if let Some(quadraui::MenuBarHit::Item(idx)) = hit {
+ if idx != current {
+ sender_hover.send(Msg::OpenMenu(idx)).ok();
}
- cursor_x += item_w;
}
});
widgets.menu_bar_da.add_controller(motion);
diff --git a/src/render.rs b/src/render.rs
index d8b8d1d8..a3d51e78 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -3441,6 +3441,26 @@ pub fn menu_dropdown_action_index(id: &quadraui::WidgetId) -> Option {
id.as_str().strip_prefix("menu:")?.parse().ok()
}
+/// Build a `quadraui::MenuBar` descriptor from the static `MENU_STRUCTURE`
+/// and the engine's current open-menu state.
+pub fn build_menu_bar_view(open_menu_idx: Option) -> quadraui::MenuBar {
+ let items = MENU_STRUCTURE
+ .iter()
+ .enumerate()
+ .map(|(i, (name, _alt_key, _))| quadraui::MenuBarItem {
+ id: quadraui::WidgetId::new(format!("menubar:{i}")),
+ label: format!("&{name}"),
+ disabled: false,
+ })
+ .collect();
+ quadraui::MenuBar {
+ id: quadraui::WidgetId::new("menubar"),
+ items,
+ open_item: open_menu_idx,
+ focused_item: None,
+ }
+}
+
/// A modal dialog displayed over the editor.
#[derive(Debug, Clone)]
pub struct DialogPanel {
diff --git a/src/tui_main/mouse.rs b/src/tui_main/mouse.rs
index 907cb68b..cc4fbd84 100644
--- a/src/tui_main/mouse.rs
+++ b/src/tui_main/mouse.rs
@@ -1988,22 +1988,28 @@ pub(super) fn handle_mouse(
// ── Menu bar row click ─────────────────────────────────────────────────────
if engine.menu_bar_visible && row == 0 {
- let mut col_pos: u16 = 1; // 1-cell left pad
- for (idx, (name, _, _)) in render::MENU_STRUCTURE.iter().enumerate() {
- let item_w = name.chars().count() as u16 + 2; // space + name + space
- if col >= col_pos && col < col_pos + item_w {
- if engine.menu_open_idx == Some(idx) {
- engine.close_menu();
- } else {
- engine.open_menu(idx);
- }
- return sidebar_width;
+ let mb_hit = engine
+ .menu_bar_layout
+ .borrow()
+ .as_ref()
+ .map(|l| l.hit_test(col as f32, row as f32 + 0.5));
+ if let Some(quadraui::MenuBarHit::Item(idx)) = mb_hit {
+ if engine.menu_open_idx == Some(idx) {
+ engine.close_menu();
+ } else {
+ engine.open_menu(idx);
}
- col_pos += item_w;
+ return sidebar_width;
}
// Nav arrows + search box are centered between menu_end and right edge.
- let menu_end = col_pos;
- let arrows_w: u16 = 4; // "◀ ▶ "
+ let menu_end: u16 = engine
+ .menu_bar_layout
+ .borrow()
+ .as_ref()
+ .and_then(|l| l.visible_items.last())
+ .map(|vi| (vi.bounds.x + vi.bounds.width).round() as u16)
+ .unwrap_or(0);
+ let arrows_w: u16 = 4;
let title = engine
.cwd
.file_name()
@@ -2019,7 +2025,6 @@ pub(super) fn handle_mouse(
let available = term_w.saturating_sub(menu_end);
if available >= total_unit + 2 {
let unit_start = menu_end + (available - total_unit) / 2;
- // Back arrow at unit_start, forward at unit_start+2
if col == unit_start {
engine.tab_nav_back();
return sidebar_width;
@@ -2028,7 +2033,6 @@ pub(super) fn handle_mouse(
engine.tab_nav_forward();
return sidebar_width;
}
- // Search box area: from arrows_w past unit_start to end of box
let search_start = unit_start + arrows_w + gap;
let search_end = unit_start + total_unit;
if col >= search_start && col < search_end {
@@ -2036,7 +2040,7 @@ pub(super) fn handle_mouse(
return sidebar_width;
}
}
- engine.close_menu(); // click in empty area of menu bar
+ engine.close_menu();
return sidebar_width;
}
diff --git a/src/tui_main/render_impl.rs b/src/tui_main/render_impl.rs
index 6b8adf3d..8baf71da 100644
--- a/src/tui_main/render_impl.rs
+++ b/src/tui_main/render_impl.rs
@@ -201,8 +201,11 @@ pub(super) fn draw_frame(
// ── Render menu bar strip (if visible) ───────────────────────────────────
if let Some(ref menu_data) = screen.menu_bar {
- render_menu_bar(frame.buffer_mut(), menu_bar_area, menu_data, theme);
+ let mb_layout = render_menu_bar(frame.buffer_mut(), menu_bar_area, menu_data, theme);
+ engine.menu_bar_layout.replace(Some(mb_layout));
// Note: dropdown is rendered LAST (after all content) so it draws on top.
+ } else {
+ engine.menu_bar_layout.replace(None);
}
// ── Render activity bar ───────────────────────────────────────────────────
@@ -2223,56 +2226,30 @@ pub(super) fn render_menu_bar(
area: Rect,
data: &render::MenuBarData,
theme: &Theme,
-) {
- if area.height == 0 {
- return;
- }
- let bar_bg = rc(theme.status_bg);
- let bar_fg = rc(theme.status_fg);
- let y = area.y;
+) -> quadraui::MenuBarLayout {
+ let q_theme = super::quadraui_tui::q_theme(theme);
+ let bar = render::build_menu_bar_view(data.open_menu_idx);
+ let layout = quadraui::tui::draw_menu_bar(buf, area, &bar, &q_theme);
- // Fill background
- for x in area.x..area.x + area.width {
- set_cell(buf, x, y, ' ', bar_fg, bar_bg);
+ if area.height == 0 {
+ return layout;
}
- // Menu labels (no hamburger here — it lives in the activity bar below)
- let mut col = area.x + 1; // one-cell left pad
+ let bar_bg = rc(theme.tab_bar_bg);
+ let bar_fg = rc(theme.tab_inactive_fg);
+ let y = area.y;
- for (idx, (name, _, _)) in render::MENU_STRUCTURE.iter().enumerate() {
- let is_open = data.open_menu_idx == Some(idx);
- let (fg, bg) = if is_open {
- (bar_bg, bar_fg) // reversed for open
- } else {
- (bar_fg, bar_bg)
- };
- // Space before name
- if col < area.x + area.width {
- set_cell(buf, col, y, ' ', bar_fg, bar_bg);
- col += 1;
- }
- // Name chars
- for ch in name.chars() {
- if col >= area.x + area.width {
- break;
- }
- set_cell(buf, col, y, ch, fg, bg);
- col += 1;
- }
- // Space after name
- if col < area.x + area.width {
- set_cell(buf, col, y, ' ', fg, bar_bg);
- col += 1;
- }
- }
+ let menu_end: u16 = layout
+ .visible_items
+ .last()
+ .map(|vi| area.x + (vi.bounds.x + vi.bounds.width).round() as u16)
+ .unwrap_or(area.x);
// Center nav arrows + search box as one unit between menu labels and right edge.
let dim_fg = rc(theme.line_number_fg);
let active_fg = bar_fg;
- let menu_end = col;
- // Compute total unit width: "◀ ▶ [ 🔍 title ]"
- let arrows_w: u16 = 4; // "◀ ▶" = 4 cols (arrow + space + arrow + space)
+ let arrows_w: u16 = 4;
let display = if data.title.is_empty() {
String::new()
} else {
@@ -2280,7 +2257,6 @@ pub(super) fn render_menu_bar(
};
let display_chars: Vec = display.chars().collect();
let text_len = display_chars.len() as u16;
- // Box = [ space text space ] = text + 4
let box_width = if !display.is_empty() { text_len + 4 } else { 0 };
let gap = if box_width > 0 { 1u16 } else { 0 };
let total_unit = arrows_w + gap + box_width;
@@ -2290,7 +2266,6 @@ pub(super) fn render_menu_bar(
if available >= total_unit + 2 {
let unit_start = menu_end + (available - total_unit) / 2;
- // Draw arrows.
let mut ax = unit_start;
let back_fg = if data.nav_back_enabled {
active_fg
@@ -2311,12 +2286,10 @@ pub(super) fn render_menu_bar(
set_cell(buf, ax, y, ' ', bar_bg, bar_bg);
ax += 1;
- // Draw search box.
if !display.is_empty() {
ax += gap;
let box_start = ax;
let box_end = box_start + box_width;
- // Use bar_fg (same as menu text) for box border and text
if box_start < right_edge {
set_cell(buf, box_start, y, '[', dim_fg, bar_bg);
}
@@ -2337,6 +2310,8 @@ pub(super) fn render_menu_bar(
}
}
}
+
+ layout
}
// ─── Context menu popup rendering ───────────────────────────────────────────────────────