mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 19:47:35 -06:00
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:
Generated
+12
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user