Top bar: the title-bar drag uses craft-window's title_bar, and a panel over the bar keeps the window still on macOS

This commit is contained in:
aiongg
2026-10-25 12:35:44 +00:00
parent 31ec62cd03
commit 577b6cfced
7 changed files with 50 additions and 110 deletions
Generated
+12
View File
@@ -1095,6 +1095,17 @@ dependencies = [
"serde",
]
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=8177bad411b577f3204deb0a0e074516d76a0b93#8177bad411b577f3204deb0a0e074516d76a0b93"
dependencies = [
"egui",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"wayland-client",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -3522,6 +3533,7 @@ name = "lightcraft-ui-egui"
version = "0.13.0"
dependencies = [
"craft-ui",
"craft-window",
"egui",
"egui_kittest",
"lightcraft-catalog",
+1
View File
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "8177bad411b577f3204deb0a0e074516d76a0b93", features = ["egui"] }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
craft-ui = { workspace = true }
craft-window = { workspace = true }
lightcraft-geom = { workspace = true }
lightcraft-meta = { workspace = true }
lightcraft-color = { workspace = true }
+7 -1
View File
@@ -49,7 +49,6 @@ pub mod sync;
pub mod tasks;
pub mod text_field;
pub mod theme;
pub mod titlebar;
pub mod widgets;
pub mod window_place;
@@ -388,6 +387,8 @@ pub struct LightcraftApp {
pub perf: Perf,
/// macOS: the host draws the traffic lights over our top bar.
pub integrated_titlebar: bool,
/// The `movable` flag last given to the window (macOS, `craft_window::title_bar::sync_movable`).
pub window_movable: Option<bool>,
/// The host installed a native menu bar (no in-window menus then).
pub native_menu: bool,
/// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler
@@ -545,6 +546,7 @@ impl LightcraftApp {
perf: Perf::default(),
caches: Caches::default(),
integrated_titlebar: false,
window_movable: None,
native_menu: false,
native_shortcuts: Default::default(),
recording_shortcut: None,
@@ -1624,6 +1626,10 @@ impl LightcraftApp {
panels::keyword_list::drag_feedback(self, &ctx);
panels::toast(self, &ctx);
drop_overlay::show(self, &ctx);
// AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't
// movable: it stops being movable while the pointer is on a bar control or a panel over it
#[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable);
self.widgets = widgets::take_registry(&ctx);
self.text_focus = ctx.egui_wants_keyboard_input();
self.end_frame(&ctx, t0);
+7 -33
View File
@@ -9,39 +9,13 @@ 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, bar: Rect, search_rect: Rect) {
use crate::titlebar::{Gesture, WindowState, command_for};
/// double-click acts as the system setting says. Made before the bar's widgets so they win the click.
fn window_handle(ui: &mut egui::Ui, bar: Rect) {
register(ui.ctx(), "region:titlebar", bar);
let handle = egui::Id::new("titlebar-handle");
let resp = ui.interact(bar, handle, Sense::click_and_drag());
crate::widgets::announce(&resp, crate::i18n::tr("Title bar"));
// a press that began on a button or the search field is theirs: dragging off it must not move the window
// (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one)
let ctx = ui.ctx().clone();
let memo = egui::Id::new("titlebar-press-on-widget");
if resp.is_pointer_button_down_on() && ctx.input(|i| i.pointer.primary_pressed()) {
let hovered = ctx.interaction_snapshot(|s| s.hovered.clone());
let on_widget =
hovered.iter().any(|id| *id != handle && ctx.read_response(*id).is_some_and(|r| r.sense.senses_click() || r.sense.senses_drag()));
ctx.data_mut(|d| d.insert_temp(memo, on_widget));
}
let on_widget = ctx.data(|d| d.get_temp::<bool>(memo)).unwrap_or(false);
// The search field is drawn after this handle. Its hit-test response may not exist
// yet when the press is recorded; the press origin still identifies text selection.
let on_search = ui.input(|i| i.pointer.press_origin().is_some_and(|p| search_rect.contains(p)));
let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) && !on_search {
Some(Gesture::DoubleClicked)
} else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget && !on_search {
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);
}
craft_window::title_bar::empty_area(ui, bar);
// (the empty area is the widget `empty_area` makes under this id)
if let Some(resp) = ui.ctx().read_response(ui.id().with("title_bar_empty")) {
crate::widgets::announce(&resp, crate::i18n::tr("Title bar"));
}
}
@@ -75,7 +49,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let search_left = bar.center().x - sw / 2.0;
let sr = Rect::from_center_size(pos2(bar.center().x, full.center().y), vec2(sw, 28.0));
if app.integrated_titlebar {
window_handle(app, ui, bar, sr);
window_handle(ui, bar);
}
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
+22
View File
@@ -169,3 +169,25 @@ fn full_screen_moves_the_left_buttons_into_the_traffic_lights_space() {
assert!(!h.app.window_is_fullscreen);
assert_eq!(widget(&h, "icon:sidebar").left(), windowed, "leaving full screen restores the room for the traffic lights");
}
// Scenario: a panel floating over the bar keeps a drag on it from moving the window (macOS's
// title-bar band would otherwise take the drag); a bar control does too
#[test]
fn a_panel_floating_over_the_bar_keeps_the_window_from_moving() {
let mut h = integrated();
let bar = widget(&h, "region:titlebar");
let empty = empty_spot(&h);
let hover = |h: &mut Headless, p: egui::Pos2| {
h.events.push(egui::Event::PointerMoved(p));
play(h);
craft_window::title_bar::window_movable(&h.view.ctx)
};
assert!(hover(&mut h, empty), "the empty bar moves the window");
let help = widget(&h, "icon:help").center();
assert!(!hover(&mut h, help), "a bar control");
let params = json!({"operation": "float", "panel": "tools", "rect": [bar.left(), bar.top() + 4.0, 330.0, 500.0]});
let r = h.request("engine.execute", json!({"command": "ui.dock", "params": params}), T);
assert_eq!(r["ok"], true, "{r}");
play(&mut h);
assert!(!hover(&mut h, empty), "the floating panel covers the empty bar there");
}
-76
View File
@@ -1,76 +0,0 @@
//! The integrated title bar's window gestures (macOS: the native title bar is hidden and the top
//! bar takes its place, so dragging and double-click-to-zoom have to be done here).
//!
//! Pure policy, no egui widgets: [`command_for`] maps a gesture on the bar's empty space and the
//! window's current state to the viewport command the host should run.
use egui::ViewportCommand;
/// What the user did on the bar's empty space.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Gesture {
/// A primary-button drag began: the window follows the pointer.
DragStarted,
/// A primary-button double-click: zoom the window, or restore it when already zoomed.
DoubleClicked,
}
/// The window as the host reports it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct WindowState {
/// Zoomed (macOS) / maximized (elsewhere).
pub maximized: bool,
/// In the OS's full-screen mode.
pub fullscreen: bool,
}
/// The viewport command for `gesture`, if any. Full screen has no title bar to drag or zoom.
pub fn command_for(gesture: Gesture, window: WindowState) -> Option<ViewportCommand> {
if window.fullscreen {
return None;
}
match gesture {
Gesture::DragStarted => Some(ViewportCommand::StartDrag),
Gesture::DoubleClicked => Some(ViewportCommand::Maximized(!window.maximized)),
}
}
#[cfg(test)]
mod tests {
use super::*;
const NORMAL: WindowState = WindowState { maximized: false, fullscreen: false };
const ZOOMED: WindowState = WindowState { maximized: true, fullscreen: false };
const FULL: WindowState = WindowState { maximized: false, fullscreen: true };
// Scenario: dragging the title bar moves the window
#[test]
fn given_a_normal_window_when_dragging_then_the_window_follows_the_pointer() {
assert_eq!(command_for(Gesture::DragStarted, NORMAL), Some(ViewportCommand::StartDrag));
}
// Scenario: a zoomed window can still be dragged (macOS lets you pull it free)
#[test]
fn given_a_zoomed_window_when_dragging_then_the_window_follows_the_pointer() {
assert_eq!(command_for(Gesture::DragStarted, ZOOMED), Some(ViewportCommand::StartDrag));
}
// Scenario: double-click zooms
#[test]
fn given_a_normal_window_when_double_clicking_then_it_zooms() {
assert_eq!(command_for(Gesture::DoubleClicked, NORMAL), Some(ViewportCommand::Maximized(true)));
}
// Scenario: double-click again restores
#[test]
fn given_a_zoomed_window_when_double_clicking_then_it_is_restored() {
assert_eq!(command_for(Gesture::DoubleClicked, ZOOMED), Some(ViewportCommand::Maximized(false)));
}
// Scenario: full screen ignores both
#[test]
fn given_a_full_screen_window_then_neither_gesture_does_anything() {
assert_eq!(command_for(Gesture::DragStarted, FULL), None);
assert_eq!(command_for(Gesture::DoubleClicked, FULL), None);
}
}