Top bar: drag to move and double-click to zoom the window (integrated title bar)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-08 19:37:05 -04:00
co-authored by Claude Sonnet 5.5
parent 54b037a90a
commit d01ebd7953
3 changed files with 134 additions and 0 deletions
+2
View File
@@ -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};
+25
View File
@@ -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
+107
View File
@@ -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());
}