Custom title bar: on Windows and Linux the application bar is the window's title bar (drag to move with OS snapping, double-click to maximize or restore, minimize/maximize/close buttons, resize from the edges and corners); macOS keeps its traffic lights over the bar; the web build has no window buttons; ui.window control method (adapted from VectorCraft)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
e5560e8035
commit
178fc47ed7
7 files changed
+451
-63
No files matched your search
@@ -32,6 +32,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| crates/mcp/src/server.rs, crates/mcp/src/backend.rs (JSON-RPC framing, MCP lifecycle, control-port bridge) | CADCraft `crates/mcp/src/{server,backend}.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
|
||||
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
|
||||
|
||||
## Vendored third-party code
|
||||
|
||||
@@ -58,6 +58,10 @@ fn services() -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the application bar is the title bar
|
||||
/// (`solvecraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
@@ -87,6 +91,10 @@ fn main() -> eframe::Result {
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id("ai.storyteller.solvecraft"),
|
||||
depth_buffer: DEPTH_BITS,
|
||||
multisampling: MSAA,
|
||||
@@ -97,6 +105,8 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = SolveApp::new(Session::default(), services());
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
|
||||
//! the open menu and its items. `ui.menuPick {item}` runs an item by id or label;
|
||||
//! `ui.rename {text?, commit?}` finishes the rename box
|
||||
//! - `ui.window {action: minimize|maximize|restore|toggle|close}`: what the title bar's buttons do
|
||||
//! - `ui.resize {width, height}`, `app.quit`
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
@@ -252,6 +253,10 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
err("no rename in progress")
|
||||
}
|
||||
}
|
||||
"ui.window" => match crate::titlebar::window_action(app, ctx, s("action").unwrap_or("")) {
|
||||
Ok(()) => ok(json!({"custom_titlebar": app.custom_titlebar, "maximized": ctx.input(|i| i.viewport().maximized)})),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"ui.resize" => {
|
||||
let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") };
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
|
||||
|
||||
@@ -27,6 +27,7 @@ mod sketch_edit_tests;
|
||||
pub mod sketch_tools;
|
||||
pub mod theme;
|
||||
pub mod timeline;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
pub mod tools;
|
||||
pub mod viewport;
|
||||
@@ -134,6 +135,10 @@ pub struct SolveApp {
|
||||
pub last_command: Option<(String, String)>,
|
||||
pub status: Option<(String, f64, bool)>,
|
||||
pub quit_requested: bool,
|
||||
/// The application bar is the window's title bar (Windows, Linux: no OS decorations).
|
||||
pub custom_titlebar: bool,
|
||||
/// macOS: the traffic lights sit over the application bar's left end.
|
||||
pub integrated_titlebar: bool,
|
||||
pub frame_ms: f64,
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
@@ -166,6 +171,8 @@ impl SolveApp {
|
||||
last_command: None,
|
||||
status: None,
|
||||
quit_requested: false,
|
||||
custom_titlebar: false,
|
||||
integrated_titlebar: false,
|
||||
frame_ms: 0.0,
|
||||
synthetic: Vec::new(),
|
||||
control_rx: None,
|
||||
@@ -569,6 +576,9 @@ impl SolveApp {
|
||||
dialogs::show(self, ui.ctx());
|
||||
params_dialog::show(self, ui.ctx());
|
||||
context_menu::show(self, ui.ctx());
|
||||
if self.custom_titlebar {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
palette::popup(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! The window's own title bar on Windows and Linux, where the window has no OS decorations
|
||||
//! ([`SolveApp::custom_titlebar`]): empty space in the application bar drags the window and a
|
||||
//! double-click there maximizes or restores it, caption buttons at the far right minimize,
|
||||
//! maximize/restore and close, and thin invisible zones along the window's edges resize it.
|
||||
//! Dragging uses the OS's own move loop, so Windows snapping (to the screen edges and corners)
|
||||
//! works as with a native title bar. macOS keeps its traffic lights over the bar
|
||||
//! ([`SolveApp::integrated_titlebar`]); the web build has neither.
|
||||
//!
|
||||
//! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0).
|
||||
|
||||
use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// One caption button: as wide as the native Windows ones.
|
||||
pub const BUTTON_WIDTH: f32 = 46.0;
|
||||
/// The three caption buttons together.
|
||||
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
|
||||
/// Room for the macOS traffic lights at the bar's left end.
|
||||
pub const TRAFFIC_LIGHTS: f32 = 76.0;
|
||||
/// Resize zone thickness along an edge, and the side of a corner zone.
|
||||
const EDGE: f32 = 5.0;
|
||||
const CORNER: f32 = 12.0;
|
||||
/// The Close button's hover colour (as on Windows).
|
||||
const CLOSE_HOVER: Color32 = Color32::from_rgb(196, 43, 28);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Caption {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Left to right.
|
||||
pub const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
|
||||
|
||||
/// Caption button ids (stable, so tests and agents can find them).
|
||||
pub fn caption_id(c: Caption) -> Id {
|
||||
Id::new(("titlebar-caption", c as u8))
|
||||
}
|
||||
|
||||
fn maximized(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
|
||||
}
|
||||
|
||||
fn toggle_maximize(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
|
||||
}
|
||||
|
||||
/// Window actions for the control channel (`ui.window`): minimize, maximize, restore, toggle
|
||||
/// (maximize or restore), close.
|
||||
pub fn window_action(app: &mut SolveApp, ctx: &egui::Context, action: &str) -> Result<(), String> {
|
||||
match action {
|
||||
"minimize" => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
|
||||
"maximize" => ctx.send_viewport_cmd(ViewportCommand::Maximized(true)),
|
||||
"restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
|
||||
"toggle" => toggle_maximize(ctx),
|
||||
"close" => {
|
||||
app.quit_requested = true;
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
}
|
||||
other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bar's background: a press-and-drag on empty space moves the window, a double-click
|
||||
/// toggles maximize. Call it before laying out the bar so its buttons, registered later, take
|
||||
/// their clicks.
|
||||
pub fn drag_area(ui: &Ui, rect: Rect) {
|
||||
let ctx = ui.ctx();
|
||||
let resp = ui.interact(rect, Id::new("titlebar-drag"), Sense::click_and_drag());
|
||||
// egui hands a drag that starts on a click-only widget to the drag-sensing background below
|
||||
// it, so remember whether the press itself landed on bare background.
|
||||
if ctx.input(|i| i.pointer.any_pressed()) {
|
||||
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
|
||||
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
toggle_maximize(ctx);
|
||||
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimize, Maximize/Restore and Close at the right end of `bar` (the application bar's
|
||||
/// rect), full bar height, flush with the window's edge so a maximized window's corner hits
|
||||
/// Close.
|
||||
pub fn caption_buttons(app: &mut SolveApp, ui: &mut Ui, bar: Rect) {
|
||||
let t = Tokens::get();
|
||||
let max = maximized(ui.ctx());
|
||||
let mut clicked = None;
|
||||
for (i, c) in CAPTIONS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height()));
|
||||
let resp = ui.interact(r, caption_id(c), Sense::click());
|
||||
let close = c == Caption::Close;
|
||||
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
|
||||
(true, _) if close => CLOSE_HOVER.gamma_multiply(0.8),
|
||||
(_, true) if close => CLOSE_HOVER,
|
||||
(true, _) => Color32::from_white_alpha(18),
|
||||
(_, true) => Color32::from_white_alpha(30),
|
||||
_ => Color32::TRANSPARENT,
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
let ink = if close && resp.hovered() { Color32::WHITE } else { t.app_bar_text };
|
||||
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
|
||||
let tip = match c {
|
||||
Caption::Minimize => "Minimize",
|
||||
Caption::Maximize if max => "Restore",
|
||||
Caption::Maximize => "Maximize",
|
||||
Caption::Close => "Close",
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
clicked = Some(c);
|
||||
}
|
||||
}
|
||||
let action = match clicked {
|
||||
Some(Caption::Minimize) => "minimize",
|
||||
Some(Caption::Maximize) => "toggle",
|
||||
Some(Caption::Close) => "close",
|
||||
None => return,
|
||||
};
|
||||
let _ = window_action(app, &ui.ctx().clone(), action);
|
||||
}
|
||||
|
||||
/// The 10 pt line glyphs of the caption buttons.
|
||||
fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) {
|
||||
let p = ui.painter();
|
||||
let g = Rect::from_center_size(at, vec2(10.0, 10.0));
|
||||
match c {
|
||||
Caption::Minimize => {
|
||||
p.line_segment([g.left_center(), g.right_center()], s);
|
||||
}
|
||||
// Restore: a front square with the corner of the one behind it.
|
||||
Caption::Maximize if maximized => {
|
||||
let front = Rect::from_min_max(g.min + vec2(0.0, 2.0), g.max - vec2(2.0, 0.0));
|
||||
p.rect_stroke(front, 0.0, s, egui::StrokeKind::Middle);
|
||||
p.line(vec![g.min + vec2(2.0, 2.0), g.min + vec2(2.0, 0.0), g.right_top(), g.max - vec2(0.0, 2.0), g.max - vec2(2.0, 2.0)], s);
|
||||
}
|
||||
Caption::Maximize => {
|
||||
p.rect_stroke(g, 0.0, s, egui::StrokeKind::Middle);
|
||||
}
|
||||
Caption::Close => {
|
||||
p.line_segment([g.left_top(), g.right_bottom()], s);
|
||||
p.line_segment([g.right_top(), g.left_bottom()], s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor).
|
||||
/// Corners come last so they win where they overlap the edges.
|
||||
fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] {
|
||||
use ResizeDirection::*;
|
||||
let (e, c) = (EDGE, CORNER);
|
||||
[
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
|
||||
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
|
||||
]
|
||||
}
|
||||
|
||||
/// Invisible resize zones along the window's edges, above everything else; a press in one
|
||||
/// starts an OS resize in that direction. None while the window is maximized or fullscreen.
|
||||
pub fn resize_zones(ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
let w = ctx.content_rect();
|
||||
let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize"));
|
||||
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
|
||||
for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() {
|
||||
let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
|
||||
if resp.drag_started_by(PointerButton::Primary) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Event, Pos2, RawInput, ViewportId};
|
||||
use solvecraft_engine::Session;
|
||||
|
||||
/// A headless window `width` pt wide showing the application bar (as the title bar) and the
|
||||
/// resize zones.
|
||||
struct Win {
|
||||
ctx: egui::Context,
|
||||
app: SolveApp,
|
||||
width: f32,
|
||||
time: f64,
|
||||
maximized: bool,
|
||||
}
|
||||
|
||||
impl Win {
|
||||
fn new(width: f32) -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SolveApp::new(Session::default(), Default::default());
|
||||
app.custom_titlebar = true;
|
||||
Self { ctx, app, width, time: 0.0, maximized: false }
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
|
||||
self.time += 0.05;
|
||||
let mut input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 700.0))),
|
||||
time: Some(self.time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) {
|
||||
v.maximized = Some(self.maximized);
|
||||
}
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
crate::toolbar::app_bar(app, ui);
|
||||
if app.custom_titlebar {
|
||||
resize_zones(ui);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
/// Frames for a pointer gesture: move to `path[0]`, press, move along `path`, release.
|
||||
/// The viewport commands the frames sent.
|
||||
fn gesture(&mut self, path: &[Pos2]) -> Vec<ViewportCommand> {
|
||||
let button = |p: Pos2, pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
|
||||
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
|
||||
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
|
||||
events.into_iter().flat_map(|e| self.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
fn click(&mut self, p: Pos2) -> Vec<ViewportCommand> {
|
||||
self.gesture(&[p])
|
||||
}
|
||||
|
||||
fn caption(&self, c: Caption) -> Rect {
|
||||
self.ctx.read_response(caption_id(c)).map(|r| r.rect).unwrap_or(Rect::NOTHING)
|
||||
}
|
||||
}
|
||||
|
||||
fn texts(out: &egui::FullOutput) -> Vec<(String, Rect)> {
|
||||
fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_buttons_sit_in_the_corner_and_nothing_runs_under_them() {
|
||||
for width in [960.0, 1600.0] {
|
||||
let mut w = Win::new(width);
|
||||
w.frame(vec![]);
|
||||
let out = w.frame(vec![]);
|
||||
let [min, max, close] = CAPTIONS.map(|c| w.caption(c));
|
||||
assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}");
|
||||
assert!([min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
|
||||
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
|
||||
let texts = texts(&out);
|
||||
let version = texts.iter().find(|(t, _)| t.starts_with('v')).map(|(_, r)| *r);
|
||||
assert!(version.is_some_and(|r| r.right() <= min.left() - 4.0), "the version stays left of the buttons: {version:?}");
|
||||
for (t, r) in &texts {
|
||||
assert!(r.right() <= min.left() || r.top() >= min.bottom(), "`{t}` runs under the caption buttons at {width}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_caption_buttons_without_a_custom_title_bar() {
|
||||
let mut w = Win::new(1200.0);
|
||||
w.app.custom_titlebar = false;
|
||||
w.frame(vec![]);
|
||||
w.frame(vec![]);
|
||||
assert_eq!(w.caption(Caption::Close), Rect::NOTHING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimize_maximize_and_close() {
|
||||
let mut w = Win::new(1200.0);
|
||||
w.frame(vec![]);
|
||||
let (min, max, close) = (w.caption(Caption::Minimize).center(), w.caption(Caption::Maximize).center(), w.caption(Caption::Close).center());
|
||||
assert!(w.click(min).contains(&ViewportCommand::Minimized(true)));
|
||||
assert!(w.click(max).contains(&ViewportCommand::Maximized(true)));
|
||||
w.maximized = true;
|
||||
assert!(w.click(max).contains(&ViewportCommand::Maximized(false)), "Restore");
|
||||
assert!(w.click(close).contains(&ViewportCommand::Close));
|
||||
assert!(w.app.quit_requested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bar_space_drags_and_double_click_maximizes() {
|
||||
let mut w = Win::new(1600.0);
|
||||
w.frame(vec![]);
|
||||
let empty = pos2(500.0, 17.0);
|
||||
let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
|
||||
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
|
||||
let mut cmds = w.click(empty);
|
||||
cmds.extend(w.click(empty));
|
||||
assert!(cmds.contains(&ViewportCommand::Maximized(true)), "{cmds:?}");
|
||||
w.maximized = true;
|
||||
w.time += 1.0;
|
||||
let mut cmds = w.click(empty);
|
||||
cmds.extend(w.click(empty));
|
||||
assert!(cmds.contains(&ViewportCommand::Maximized(false)), "{cmds:?}");
|
||||
// The bar's buttons keep their clicks: dragging from Undo doesn't move the window.
|
||||
w.time += 1.0;
|
||||
let undo = pos2(128.0 + 2.0 * 30.0, 17.0);
|
||||
let cmds = w.gesture(&[undo, undo + vec2(12.0, 4.0), undo + vec2(30.0, 8.0)]);
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_zones_resize_unless_maximized() {
|
||||
use ResizeDirection::*;
|
||||
let mut w = Win::new(1000.0);
|
||||
w.frame(vec![]);
|
||||
for (p, dir) in [
|
||||
(pos2(1.0, 300.0), West),
|
||||
(pos2(998.0, 300.0), East),
|
||||
(pos2(500.0, 1.0), North),
|
||||
(pos2(500.0, 698.0), South),
|
||||
(pos2(2.0, 2.0), NorthWest),
|
||||
(pos2(997.0, 3.0), NorthEast),
|
||||
(pos2(3.0, 697.0), SouthWest),
|
||||
(pos2(998.0, 698.0), SouthEast),
|
||||
] {
|
||||
let cmds = w.gesture(&[p, p + vec2(6.0, 6.0)]);
|
||||
assert_eq!(cmds, vec![ViewportCommand::BeginResize(dir)], "at {p:?}");
|
||||
}
|
||||
w.maximized = true;
|
||||
let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]);
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}");
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,16 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
egui::Panel::top("sc_appbar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
// The bar is the window's title bar: its empty space moves the window.
|
||||
if app.custom_titlebar || app.integrated_titlebar {
|
||||
crate::titlebar::drag_area(ui, r);
|
||||
}
|
||||
let left = r.left() + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
|
||||
// Brand mark: a small solid block drawn in code.
|
||||
let logo = Rect::from_center_size(pos2(r.left() + 20.0, r.center().y), vec2(20.0, 20.0));
|
||||
let logo = Rect::from_center_size(pos2(left + 20.0, r.center().y), vec2(20.0, 20.0));
|
||||
icons::paint(ui.painter(), logo, "box", Color32::from_rgb(220, 226, 236), Color32::from_rgb(90, 160, 240), Color32::WHITE);
|
||||
ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, "SolveCraft", FontId::proportional(14.0), t.app_bar_text);
|
||||
let mut x = r.left() + 128.0;
|
||||
ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolveCraft", FontId::proportional(14.0), t.app_bar_text);
|
||||
let mut x = left + 128.0;
|
||||
let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str| -> bool {
|
||||
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0));
|
||||
x += 30.0;
|
||||
@@ -91,13 +96,17 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let tab = Rect::from_min_size(pos2(r.center().x - 110.0, r.top() + 5.0), vec2(220.0, r.height() - 5.0));
|
||||
ui.painter().rect_filled(tab, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, t.toolbar);
|
||||
ui.painter().text(tab.center(), Align2::CENTER_CENTER, name, FontId::proportional(13.0), t.text);
|
||||
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
|
||||
ui.painter().text(
|
||||
pos2(r.right() - 12.0, r.center().y),
|
||||
pos2(r.right() - 12.0 - captions, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("v{}", env!("CARGO_PKG_VERSION")),
|
||||
FontId::proportional(11.0),
|
||||
Color32::from_rgb(160, 168, 182),
|
||||
);
|
||||
if app.custom_titlebar {
|
||||
crate::titlebar::caption_buttons(app, ui, r);
|
||||
}
|
||||
});
|
||||
file_menu(app, ui.ctx());
|
||||
}
|
||||
@@ -108,67 +117,70 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let mut close = false;
|
||||
let resp = egui::Area::new(egui::Id::new("sc_file_area")).fixed_pos(pos2(100.0, 34.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
|
||||
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
|
||||
};
|
||||
if item(ui, "New Design", "Ctrl+N") {
|
||||
let _ = app.run("NewDocumentCommand", json!({}));
|
||||
app.fit_view();
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Open…", "Ctrl+O") {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path(&p);
|
||||
let resp = egui::Area::new(egui::Id::new("sc_file_area"))
|
||||
.fixed_pos(pos2(100.0 + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }, 34.0))
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
|
||||
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
|
||||
};
|
||||
if item(ui, "New Design", "Ctrl+N") {
|
||||
let _ = app.run("NewDocumentCommand", json!({}));
|
||||
app.fit_view();
|
||||
close = true;
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Insert STEP…", "") {
|
||||
app.start("FusionImportCommandFromToolbar");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Insert Mesh…", "") {
|
||||
app.start("ParaMeshInsertAlignCommand");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save", "Ctrl+S") {
|
||||
save(app);
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save As…", "") {
|
||||
save_as(app);
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, "Export STL…", "") {
|
||||
export(app, "stl");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Export STEP…", "") {
|
||||
export(app, "step");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Export 3MF…", "") {
|
||||
export(app, "3mf");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Export OBJ…", "") {
|
||||
export(app, "obj");
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, "Preferences…", "") {
|
||||
app.dialog = Some(crate::dialogs::Dialog::preferences());
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Quit", "") {
|
||||
app.quit_requested = true;
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Open…", "Ctrl+O") {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path(&p);
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Insert STEP…", "") {
|
||||
app.start("FusionImportCommandFromToolbar");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Insert Mesh…", "") {
|
||||
app.start("ParaMeshInsertAlignCommand");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save", "Ctrl+S") {
|
||||
save(app);
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save As…", "") {
|
||||
save_as(app);
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, "Export STL…", "") {
|
||||
export(app, "stl");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Export STEP…", "") {
|
||||
export(app, "step");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Export 3MF…", "") {
|
||||
export(app, "3mf");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Export OBJ…", "") {
|
||||
export(app, "obj");
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, "Preferences…", "") {
|
||||
app.dialog = Some(crate::dialogs::Dialog::preferences());
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Quit", "") {
|
||||
app.quit_requested = true;
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let just_opened = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("sc_file_menu_opened"))) == Some(ctx.cumulative_pass_nr());
|
||||
if close || (!just_opened && resp.response.clicked_elsewhere()) {
|
||||
ctx.data_mut(|d| d.insert_temp(id, false));
|
||||
|
||||
@@ -38,6 +38,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
|
||||
| `ui.text` | `text` | typed text |
|
||||
| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) |
|
||||
| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) |
|
||||
| `ui.resize` | `width`, `height` | |
|
||||
| `app.quit` | — | |
|
||||
|
||||
|
||||
Reference in new issue
Block a user