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 ───────────────────────────────────────────────────────