This commit is contained in:
Claude committed 2026-10-08 23:47:24 +00:00
commit 4d2f34fb35
6 files changed
+249 -3

No files matched your search

+12
View File
@@ -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),
_ => {}
}
}
+3
View File
@@ -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};
+37
View File
@@ -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
+117
View File
@@ -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());
}
+76
View File
@@ -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
View File
@@ -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