From d01ebd79538bbe53c7246d62919310ec430a9fe1 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 19:37:05 -0400 Subject: [PATCH] Top bar: drag to move and double-click to zoom the window (integrated title bar) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/topbar.rs | 25 +++++++ crates/ui-egui/src/tests_titlebar.rs | 107 +++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 crates/ui-egui/src/tests_titlebar.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 43b210f1f..b327e2454 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -55,6 +55,8 @@ mod tests_scroll; #[cfg(test)] mod tests_switch_library; #[cfg(test)] +mod tests_titlebar; +#[cfg(test)] mod tests_unsaved; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index e960909ea..7451c8acb 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -8,6 +8,28 @@ use crate::icons::{Icon, paint}; use crate::theme::Tokens; use crate::widgets::{icon_button, register}; +/// The bar doubles as the window's title bar: dragging its empty space moves the window and a +/// double-click zooms or restores it. Registered before the bar's widgets so they win the click. +fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_left: f32, margin_right: f32) { + use crate::titlebar::{Gesture, WindowState, command_for}; + let bar = Rect::from_min_max(pos2(content.left() - margin_left, content.top()), pos2(content.right() + margin_right, content.bottom())); + register(ui.ctx(), "region:titlebar", bar); + let resp = ui.interact(bar, egui::Id::new("titlebar-handle"), Sense::click_and_drag()); + let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) { + Some(Gesture::DoubleClicked) + } else if resp.drag_started_by(egui::PointerButton::Primary) { + Some(Gesture::DragStarted) + } else { + None + }; + if let Some(g) = gesture { + let window = WindowState { maximized: ui.input(|i| i.viewport().maximized).unwrap_or(false), fullscreen: app.window_is_fullscreen }; + if let Some(cmd) = command_for(g, window) { + ui.ctx().send_viewport_cmd(cmd); + } + } +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let left = if app.integrated_titlebar { 78 } else { 10 }; @@ -16,6 +38,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); + if app.integrated_titlebar { + window_handle(app, ui, full, left as f32, 12.0); + } let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs new file mode 100644 index 000000000..f1ff782ef --- /dev/null +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -0,0 +1,107 @@ +//! The integrated title bar behaves like a native one: drag the empty space to move the window, +//! double-click it to zoom and again to restore. Buttons and the search field keep their clicks. + +use std::time::Duration; + +use egui::ViewportCommand; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// Given LightCraft with the integrated title bar (macOS) in a 1200×800 window. +fn integrated() -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.integrated_titlebar = true; + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(Duration::from_secs(120)); + h +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// Empty top-bar space: where the traffic lights sit, left of the sidebar button. +fn empty_spot(h: &Headless) -> egui::Pos2 { + let bar = widget(h, "region:titlebar"); + egui::pos2(bar.left() + 20.0, bar.center().y) +} + +fn play(h: &mut Headless) { + for _ in 0..6 { + h.step(); + } +} + +// Scenario: double-clicking the empty top bar zooms the window, and again restores it +#[test] +fn double_click_on_the_empty_bar_zooms_then_restores() { + let mut h = integrated(); + let p = empty_spot(&h); + h.request("ui.click", json!({"x": p.x, "y": p.y, "count": 2}), T); + play(&mut h); + assert_eq!(h.window_commands, vec![ViewportCommand::Maximized(true)]); + // a pause longer than egui's multi-click window, or the second pair would count as a triple click + for _ in 0..120 { + h.step(); + } + h.request("ui.click", json!({"x": p.x, "y": p.y, "count": 2}), T); + play(&mut h); + assert_eq!(h.window_commands, vec![ViewportCommand::Maximized(true), ViewportCommand::Maximized(false)]); +} + +// Scenario: dragging the empty top bar starts a window move +#[test] +fn dragging_the_empty_bar_moves_the_window() { + let mut h = integrated(); + let p = empty_spot(&h); + h.request("ui.drag", json!({"x": p.x, "y": p.y, "toX": p.x + 120.0, "toY": p.y + 40.0}), T); + play(&mut h); + assert_eq!(h.window_commands, vec![ViewportCommand::StartDrag]); +} + +// Scenario: a single click on the empty bar does nothing +#[test] +fn single_click_on_the_empty_bar_does_nothing() { + let mut h = integrated(); + let p = empty_spot(&h); + h.request("ui.click", json!({"x": p.x, "y": p.y}), T); + play(&mut h); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + +// Scenario: double-clicking a button does not zoom the window +#[test] +fn double_click_on_a_button_does_not_zoom() { + let mut h = integrated(); + let r = widget(&h, "icon:help"); + h.request("ui.click", json!({"x": r.center().x, "y": r.center().y, "count": 2}), T); + play(&mut h); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + +// Scenario: double-clicking the search field selects a word, it does not zoom the window +#[test] +fn double_click_in_the_search_field_does_not_zoom() { + let mut h = integrated(); + let r = widget(&h, "field:search"); + h.request("ui.click", json!({"x": r.center().x, "y": r.center().y, "count": 2}), T); + play(&mut h); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + +// Scenario: a window with its native title bar (Windows, Linux) leaves the bar alone +#[test] +fn without_the_integrated_title_bar_the_bar_is_not_a_window_handle() { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(Duration::from_secs(120)); + assert!(!h.app.integrated_titlebar); + let bar = h.app.widgets.iter().find(|(w, _)| w == "region:titlebar"); + assert!(bar.is_none()); +}