mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
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:
co-authored by
Claude Sonnet 5.5
parent
54b037a90a
commit
d01ebd7953
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user