mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 02:10:25 -05:00
Merge PR #408
This commit is contained in:
6 files changed
+249
-3
No files matched your search
@@ -115,6 +115,10 @@ pub struct Headless {
|
||||
events: Vec<egui::Event>,
|
||||
control: Sender<ControlRequest>,
|
||||
quit: bool,
|
||||
/// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host).
|
||||
pub window_maximized: bool,
|
||||
/// Window-management commands the app sent (`StartDrag`, `Maximized`, …), oldest first.
|
||||
pub window_commands: Vec<ViewportCommand>,
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
@@ -133,6 +137,8 @@ impl Headless {
|
||||
events: vec![],
|
||||
control: tx,
|
||||
quit: false,
|
||||
window_maximized: false,
|
||||
window_commands: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +154,7 @@ impl Headless {
|
||||
/// Run one frame.
|
||||
pub fn step(&mut self) {
|
||||
let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events));
|
||||
raw.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized);
|
||||
self.app.raw_input_hook(&mut raw);
|
||||
let app = &mut self.app;
|
||||
let commands = self.view.run(raw, |ui| {
|
||||
@@ -164,6 +171,11 @@ impl Headless {
|
||||
}
|
||||
ViewportCommand::InnerSize(s) if s.x >= 1.0 && s.y >= 1.0 => self.size = s,
|
||||
ViewportCommand::Close => self.quit = true,
|
||||
ViewportCommand::Maximized(on) => {
|
||||
self.window_maximized = on;
|
||||
self.window_commands.push(c);
|
||||
}
|
||||
ViewportCommand::StartDrag => self.window_commands.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod softpaint;
|
||||
pub mod state;
|
||||
pub mod tasks;
|
||||
pub mod theme;
|
||||
pub mod titlebar;
|
||||
pub mod widgets;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -58,6 +59,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,40 @@ 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 handle = egui::Id::new("titlebar-handle");
|
||||
let resp = ui.interact(bar, handle, Sense::click_and_drag());
|
||||
// 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);
|
||||
let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) {
|
||||
Some(Gesture::DoubleClicked)
|
||||
} else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget {
|
||||
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 +50,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,117 @@
|
||||
//! 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..30 {
|
||||
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: pressing a button and dragging off it does not move the window (as in a native title bar)
|
||||
#[test]
|
||||
fn dragging_from_a_button_does_not_move_the_window() {
|
||||
let mut h = integrated();
|
||||
let r = widget(&h, "icon:help");
|
||||
h.request("ui.drag", json!({"x": r.center().x, "y": r.center().y, "toX": r.center().x - 150.0, "toY": r.center().y + 30.0}), 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());
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 17 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
|
||||
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
|
||||
| **Total** | 392 | 35 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
|
||||
| **Total** | 392 | 36 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 513 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 160.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 514 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 161.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -522,6 +522,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
|
||||
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
|
||||
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
|
||||
| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) |
|
||||
|
||||
## Y. Menus
|
||||
|
||||
|
||||
Reference in new issue
Block a user