Merge #253: Integrate Windows window controls into the menu bar

# Conflicts:
#	apps/designcraft/src/main.rs
#	crates/ui-egui/src/chrome.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/theme.rs
This commit is contained in:
aiongg
2026-10-13 19:27:09 +00:00
6 changed files with 479 additions and 97 deletions
+2
View File
@@ -382,6 +382,7 @@ fn main() -> std::process::ExitCode {
.with_title("DesignCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([900.0, 560.0])
.with_decorations(!cfg!(target_os = "windows"))
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
@@ -495,6 +496,7 @@ fn main() -> std::process::ExitCode {
app.status(note);
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = cfg!(target_os = "windows");
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
+2 -1
View File
@@ -23,7 +23,8 @@ fn main() {
};
let (tx, rx) = std::sync::mpsc::channel::<ControlRequest>();
let mut app = DesignApp::new(Session::new(), services).with_control(rx);
app.integrated_titlebar = true;
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = cfg!(target_os = "windows");
let lines: Vec<serde_json::Value> =
script.lines().filter(|l| !l.trim().is_empty()).map(|l| serde_json::from_str(l).expect("json line")).collect();
if lines.first().is_none_or(|l| l.get("empty").is_none()) {
+405 -96
View File
@@ -189,7 +189,12 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
let home = if mac { 22.0 } else { 24.0 };
let resp = egui::Panel::top("app_bar")
.exact_size(bar + APP_BAR_BAND)
.frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: (APP_BAR_BAND - SEPARATOR) as i8 }))
.frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin {
left,
right: if app.custom_titlebar { 0 } else { 10 },
top: 0,
bottom: (APP_BAR_BAND - SEPARATOR) as i8,
}))
.show(ui, |ui| {
let full = ui.max_rect();
if mac {
@@ -203,109 +208,149 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
}
let mut menus_end = full.min.x;
let mut tools_start = full.max.x;
// The right-hand controls' width, measured last frame: in a window too narrow for them
// and the menus they follow the menus, and the bar scrolls sideways.
let tools_w_id = ui.id().with("tools_width");
let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0);
crate::widgets::overflow_scrolling(ui);
egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_centered(|ui| {
if icons::button(ui, "home", home, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() {
app.session_home();
}
ui.add_space(6.0);
if !app.native_menu {
ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
ui.style_mut().spacing.button_padding = vec2(7.0, 3.0);
crate::menus::menu_bar(app, ui);
}
menus_end = ui.min_rect().max.x;
let size = vec2(tools_slot_width(ui.available_width(), tools_w), ui.available_height());
let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(4.0);
search_field(app, ui);
ui.add_space(8.0);
let current = app.ui.workspace.clone();
let shown = crate::i18n::workspace_name(&app.ui.language, &current);
crate::menus::menu_button(
ui,
crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)),
|ui| {
let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
for w in [
"Essentials",
"Advanced",
"Book",
"Digital Publishing",
"Interactive for PDF",
"Printing and Proofing",
"Typography",
]
.into_iter()
.map(str::to_string)
.chain(customs.iter().cloned())
{
// Windows caption buttons stay at the right edge; the rest of the bar scrolls.
let caption_w = if app.custom_titlebar { CAPTION_W } else { 0.0 };
// Undecorated window: the bar moves it. The drag area is the whole bar but the caption
// buttons, lower band included, registered before the menus and tools so they stay on
// top of it: any gap drags, and the lower band is always there, however narrow the
// window (the menus and tools scroll).
if app.custom_titlebar {
let drag = egui::Rect::from_min_max(
egui::pos2(full.min.x - left as f32, full.min.y),
egui::pos2(full.max.x - caption_w, full.min.y + bar + APP_BAR_BAND),
);
let response = ui.interact(drag, egui::Id::new("window_title_drag"), Sense::click_and_drag());
// Only from an empty part of the bar: the lower band, or the gap between the menus
// and the tools (as they were last frame). A menu title pressed and dragged doesn't
// move the window, as with native title bars.
let (gap0, gap1) =
ui.data(|d| (d.get_temp::<f32>(egui::Id::new("app_bar_menus_end")), d.get_temp::<f32>(egui::Id::new("app_bar_tools_start"))));
let empty = |p: egui::Pos2| p.y >= full.min.y + bar || matches!((gap0, gap1), (Some(a), Some(b)) if p.x >= a && p.x <= b);
let from_empty = ui.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())).is_some_and(empty);
if response.double_clicked() && from_empty {
let on = !ui.input(|i| i.viewport().maximized.unwrap_or(false));
let _ = app.run("window.maximize", json!({"on": on}));
} else if response.drag_started_by(egui::PointerButton::Primary) && from_empty {
let _ = app.run("window.drag", json!({}));
}
app.flush_window_commands(ui.ctx());
}
if app.custom_titlebar {
let caption = egui::Rect::from_min_max(egui::pos2(full.max.x - caption_w, full.min.y), full.max);
ui.scope_builder(egui::UiBuilder::new().max_rect(caption).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| {
caption_buttons(app, ui);
});
}
let scrolled = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x - caption_w, full.max.y));
ui.scope_builder(egui::UiBuilder::new().max_rect(scrolled), |ui| {
// The right-hand controls' width, measured last frame: in a window too narrow for them
// and the menus they follow the menus, and the bar scrolls sideways.
let tools_w_id = ui.id().with("tools_width");
let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0);
crate::widgets::overflow_scrolling(ui);
// (Drag-scrolling is touch-only by default, so a mouse drag on the bar moves the window.)
egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_centered(|ui| {
if icons::button(ui, "home", home, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() {
app.session_home();
}
ui.add_space(6.0);
if !app.native_menu {
ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
ui.style_mut().spacing.button_padding = vec2(7.0, 3.0);
crate::menus::menu_bar(app, ui);
}
menus_end = ui.min_rect().max.x;
// Where the menus end on screen (moves as a narrow bar scrolls).
ui.data_mut(|d| d.insert_temp(egui::Id::new("app_bar_menus_end"), menus_end));
let size = vec2(tools_slot_width(ui.available_width(), tools_w), ui.available_height());
let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(4.0);
search_field(app, ui);
ui.add_space(8.0);
let current = app.ui.workspace.clone();
let shown = crate::i18n::workspace_name(&app.ui.language, &current);
crate::menus::menu_button(
ui,
crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)),
|ui| {
let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
for w in [
"Essentials",
"Advanced",
"Book",
"Digital Publishing",
"Interactive for PDF",
"Printing and Proofing",
"Typography",
]
.into_iter()
.map(str::to_string)
.chain(customs.iter().cloned())
{
if ui
.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w)))
.clicked()
{
let _ = app.run("window.workspace", json!({"name": w}));
ui.close();
}
}
ui.separator();
if ui
.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w)))
.button(crate::rtl::widget(
ui,
format!(
"{} {}",
crate::i18n::tr(&app.ui.language, "Reset"),
crate::i18n::workspace_name(&app.ui.language, &current)
),
))
.clicked()
{
let _ = app.run("window.workspace", json!({"name": w}));
let _ = app.run("window.resetWorkspace", json!({}));
ui.close();
}
}
ui.separator();
if ui
.button(crate::rtl::widget(
ui,
format!(
"{} {}",
crate::i18n::tr(&app.ui.language, "Reset"),
crate::i18n::workspace_name(&app.ui.language, &current)
),
))
.clicked()
{
let _ = app.run("window.resetWorkspace", json!({}));
ui.close();
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
let _ = app.run("window.newWorkspace", json!({}));
ui.close();
}
if !customs.is_empty() {
crate::menus::menu_button(
ui,
crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")),
|ui| {
for w in &customs {
if ui.button(w).clicked() {
let _ = app.run("window.deleteWorkspace", json!({"name": w}));
ui.close();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
let _ = app.run("window.newWorkspace", json!({}));
ui.close();
}
if !customs.is_empty() {
crate::menus::menu_button(
ui,
crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")),
|ui| {
for w in &customs {
if ui.button(w).clicked() {
let _ = app.run("window.deleteWorkspace", json!({"name": w}));
ui.close();
}
}
}
},
);
}
},
);
ui.add_space(6.0);
if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() {
app.status(crate::i18n::tr(&app.ui.language, "Export a PDF, IDML or package to share — no cloud account needed."));
},
);
}
},
);
ui.add_space(6.0);
if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() {
app.status(crate::i18n::tr(&app.ui.language, "Export a PDF, IDML or package to share — no cloud account needed."));
}
ui.add_space(8.0);
// Always one click away: the ArtCraft community Discord.
if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) {
let _ = app.run("help.discord", json!({}));
}
tools_start = ui.min_rect().min.x;
ui.data_mut(|d| d.insert_temp(egui::Id::new("app_bar_tools_start"), tools_start));
ui.min_rect().width()
});
if (tools.inner - tools_w).abs() > 0.5 {
ui.data_mut(|d| d.insert_temp(tools_w_id, tools.inner));
ui.ctx().request_discard("app bar: the right-hand controls changed width");
}
ui.add_space(8.0);
// Always one click away: the ArtCraft community Discord.
if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) {
let _ = app.run("help.discord", json!({}));
}
tools_start = ui.min_rect().min.x;
ui.min_rect().width()
});
if (tools.inner - tools_w).abs() > 0.5 {
ui.data_mut(|d| d.insert_temp(tools_w_id, tools.inner));
ui.ctx().request_discard("app bar: the right-hand controls changed width");
}
});
});
// Centred title.
@@ -326,6 +371,85 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.painter().line_segment([egui::pos2(r.min.x, r.max.y), egui::pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border));
}
const CAPTION_BUTTON_W: f32 = 46.0;
/// Minimize, maximize and close, and the space after them.
const CAPTION_W: f32 = 3.0 * CAPTION_BUTTON_W + 8.0;
/// Windows caption buttons. The macOS traffic lights remain native.
fn caption_buttons(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
let spacing = ui.spacing().item_spacing.x;
ui.spacing_mut().item_spacing.x = 0.0;
for (command, label) in
[("window.close", "Close"), ("window.maximize", if maximized { "Restore" } else { "Maximize" }), ("window.minimize", "Minimize")]
{
let (rect, _) = ui.allocate_exact_size(vec2(CAPTION_BUTTON_W, APP_BAR), Sense::hover());
let response = ui.interact(rect, egui::Id::new(command), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
let hovered = response.hovered();
if hovered {
ui.painter().rect_filled(rect, 0.0, if command == "window.close" { t.caption_close } else { t.hover });
}
let color = if hovered && command == "window.close" { t.caption_close_text } else { t.text };
let stroke = Stroke::new(1.0, color);
let icon = egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0));
match command {
"window.close" => {
ui.painter().line_segment([icon.left_top(), icon.right_bottom()], stroke);
ui.painter().line_segment([icon.right_top(), icon.left_bottom()], stroke);
}
"window.maximize" if maximized => {
let back = icon.translate(vec2(2.0, -2.0));
ui.painter().line_segment([back.left_top(), back.right_top()], stroke);
ui.painter().line_segment([back.right_top(), back.right_bottom()], stroke);
ui.painter().rect_stroke(icon, 0.0, stroke, egui::StrokeKind::Inside);
}
"window.maximize" => {
ui.painter().rect_stroke(icon, 0.0, stroke, egui::StrokeKind::Inside);
}
_ => {
ui.painter().line_segment([icon.left_center(), icon.right_center()], stroke);
}
}
if response.clicked() {
let params = if command == "window.maximize" { json!({"on": !maximized}) } else { json!({}) };
let _ = app.run(command, params);
app.flush_window_commands(ui.ctx());
}
response.on_hover_text(crate::i18n::tr(&app.ui.language, label));
}
ui.spacing_mut().item_spacing.x = spacing;
ui.add_space(8.0);
}
/// Undecorated Windows windows need resize grips as well as caption controls.
pub(crate) fn resize_borders(app: &mut DesignApp, ui: &mut egui::Ui) {
if ui.input(|i| i.viewport().maximized.unwrap_or(false) || i.viewport().fullscreen.unwrap_or(false)) {
return;
}
let r = ui.ctx().content_rect();
let edge = 4.0;
let corner = 10.0;
let rect = |x0, y0, x1, y1| egui::Rect::from_min_max(egui::pos2(x0, y0), egui::pos2(x1, y1));
for (name, region, cursor) in [
("northWest", rect(r.left(), r.top(), r.left() + corner, r.top() + corner), egui::CursorIcon::ResizeNwSe),
("northEast", rect(r.right() - corner, r.top(), r.right(), r.top() + corner), egui::CursorIcon::ResizeNeSw),
("southWest", rect(r.left(), r.bottom() - corner, r.left() + corner, r.bottom()), egui::CursorIcon::ResizeNeSw),
("southEast", rect(r.right() - corner, r.bottom() - corner, r.right(), r.bottom()), egui::CursorIcon::ResizeNwSe),
("north", rect(r.left() + corner, r.top(), r.right() - corner, r.top() + edge), egui::CursorIcon::ResizeVertical),
("south", rect(r.left() + corner, r.bottom() - edge, r.right() - corner, r.bottom()), egui::CursorIcon::ResizeVertical),
("west", rect(r.left(), r.top() + corner, r.left() + edge, r.bottom() - corner), egui::CursorIcon::ResizeHorizontal),
("east", rect(r.right() - edge, r.top() + corner, r.right(), r.bottom() - corner), egui::CursorIcon::ResizeHorizontal),
] {
let response = ui.interact(region, egui::Id::new(("window_resize", name)), Sense::drag()).on_hover_cursor(cursor);
if response.drag_started_by(egui::PointerButton::Primary) {
let _ = app.run("window.resize", json!({"edge": name}));
app.flush_window_commands(ui.ctx());
}
}
}
impl DesignApp {
fn session_home(&mut self) {
self.ui.status = crate::i18n::tr(&self.ui.language, "Home").into();
@@ -772,6 +896,7 @@ mod tests {
use egui::{pos2, vec2};
use egui_kittest::kittest::{NodeT, Queryable};
use super::*;
use crate::test_window::{self, wheel};
/// The Control panel's last control with no text frame selected.
@@ -1114,4 +1239,188 @@ mod tests {
let columns = group(&h, "columns");
assert!(columns.width() >= 48.0 && columns.max.x <= 700.0, "Columns group squeezed or off screen: {columns:?}");
}
struct WindowHarness {
ctx: egui::Context,
app: DesignApp,
time: f64,
maximized: bool,
size: egui::Vec2,
}
impl WindowHarness {
fn new() -> Self {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.custom_titlebar = true;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, "");
let mut harness = Self { ctx, app, time: 0.0, maximized: false, size: vec2(1440.0, 900.0) };
harness.frame(vec![]);
harness.frame(vec![]);
harness
}
fn frame(&mut self, events: Vec<egui::Event>) -> Vec<egui::ViewportCommand> {
self.time += 0.1;
let mut input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, self.size)),
time: Some(self.time),
events,
..Default::default()
};
input.viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(self.maximized);
let mut output = self.ctx.run_ui(input, |ui| {
app_bar(&mut self.app, ui);
resize_borders(&mut self.app, ui);
});
output.textures_delta.clear();
output.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
}
fn press(&mut self, pos: egui::Pos2) -> Vec<egui::ViewportCommand> {
self.frame(vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() },
])
}
fn click(&mut self, pos: egui::Pos2) -> Vec<egui::ViewportCommand> {
let mut commands = self.press(pos);
commands.extend(self.frame(vec![egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
}]));
commands
}
fn caption_center(&self, id: &str) -> egui::Pos2 {
self.ctx.read_response(egui::Id::new(id)).unwrap().rect.center()
}
}
#[test]
fn caption_buttons_minimize_maximize_restore_and_close() {
let mut h = WindowHarness::new();
for (id, command) in
[("window.minimize", egui::ViewportCommand::Minimized(true)), ("window.maximize", egui::ViewportCommand::Maximized(true))]
{
let p = h.caption_center(id);
assert!(h.click(p).contains(&command), "{id}");
}
h.maximized = true;
h.frame(vec![]);
let p = h.caption_center("window.maximize");
assert!(h.click(p).contains(&egui::ViewportCommand::Maximized(false)));
let p = h.caption_center("window.close");
assert!(h.click(p).contains(&egui::ViewportCommand::Close));
}
#[test]
fn title_area_drags_and_double_clicks_but_menus_do_not_drag() {
let mut h = WindowHarness::new();
let title = h.ctx.read_response(egui::Id::new("window_title_drag")).unwrap().rect;
assert!(title.width() > 0.0, "there is a drag region between menus and tools");
let p = title.center();
let mut commands = h.press(p);
commands.extend(h.frame(vec![egui::Event::PointerMoved(p + vec2(20.0, 0.0))]));
assert!(commands.contains(&egui::ViewportCommand::StartDrag));
let mut h = WindowHarness::new();
h.click(p);
assert!(h.click(p).contains(&egui::ViewportCommand::Maximized(true)));
let mut h = WindowHarness::new();
let p = egui::pos2(70.0, 18.0); // File menu, outside the title's drag region.
let mut commands = h.press(p);
commands.extend(h.frame(vec![egui::Event::PointerMoved(title.center())]));
assert!(!commands.contains(&egui::ViewportCommand::StartDrag));
}
#[test]
fn edges_resize_only_when_the_window_is_restored() {
let mut h = WindowHarness::new();
let p = egui::pos2(1438.0, 400.0);
let mut commands = h.press(p);
commands.extend(h.frame(vec![egui::Event::PointerMoved(p + vec2(-20.0, 0.0))]));
assert!(commands.contains(&egui::ViewportCommand::BeginResize(egui::ResizeDirection::East)));
let mut h = WindowHarness::new();
h.maximized = true;
h.frame(vec![]);
let mut commands = h.press(p);
commands.extend(h.frame(vec![egui::Event::PointerMoved(p + vec2(-20.0, 0.0))]));
assert!(!commands.iter().any(|c| matches!(c, egui::ViewportCommand::BeginResize(_))));
}
#[test]
fn native_window_actions_are_unavailable_without_custom_chrome() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
assert!(!crate::menus::enabled(&app, "window.close"));
assert!(app.run("window.close", json!({})).is_err());
assert!(app.pending_window_commands.is_empty());
app.custom_titlebar = true;
assert!(app.run("window.resize", json!({"edge": "invalid"})).is_err());
assert!(app.pending_window_commands.is_empty());
}
#[test]
fn caption_buttons_stay_put_while_a_narrow_app_bar_scrolls() {
let mut h = WindowHarness::new();
h.size = vec2(640.0, 560.0);
h.frame(vec![]);
h.frame(vec![]);
let rect = |h: &WindowHarness, id: &str| h.ctx.read_response(egui::Id::new(id)).unwrap().rect;
let menus_end = |h: &WindowHarness| h.ctx.data(|d| d.get_temp::<f32>(egui::Id::new("app_bar_menus_end"))).unwrap();
let (close, menus0) = (rect(&h, "window.close"), menus_end(&h));
assert!(close.left() >= 0.0 && close.right() <= h.size.x, "{close:?}");
let p = egui::pos2(200.0, 18.0);
h.frame(vec![
egui::Event::PointerMoved(p),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -3000.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
},
]);
for _ in 0..10 {
h.frame(vec![]);
}
assert!(menus_end(&h) < menus0, "the menus scrolled");
assert_eq!(rect(&h, "window.close"), close, "the caption buttons did not");
assert!(h.click(close.center()).contains(&egui::ViewportCommand::Close));
}
#[test]
fn the_window_drags_from_its_lower_band_at_the_minimum_width() {
// At 900 px the menus and tools fill the bar's row; its lower band still moves the window,
// and it isn't drawn over the caption buttons.
let mut h = WindowHarness::new();
h.size = vec2(900.0, 560.0);
h.frame(vec![]);
h.frame(vec![]);
let drag = h.ctx.read_response(egui::Id::new("window_title_drag")).unwrap().rect;
let close = h.ctx.read_response(egui::Id::new("window.close")).unwrap().rect;
assert!(drag.right() <= close.left() + 0.5, "{drag:?} {close:?}");
let p = egui::pos2(450.0, APP_BAR + APP_BAR_BAND / 2.0);
assert!(drag.contains(p), "{drag:?}");
let mut commands = h.press(p);
commands.extend(h.frame(vec![egui::Event::PointerMoved(p + vec2(20.0, 0.0))]));
assert!(commands.contains(&egui::ViewportCommand::StartDrag));
}
#[test]
fn caption_buttons_fit_the_minimum_window_width() {
let mut h = WindowHarness::new();
h.size = vec2(900.0, 560.0);
h.frame(vec![]);
h.frame(vec![]);
for id in ["window.minimize", "window.maximize", "window.close"] {
let rect = h.ctx.read_response(egui::Id::new(id)).unwrap().rect;
assert!(rect.left() >= 0.0 && rect.right() <= h.size.x, "{id}: {rect:?}");
let p = rect.center();
assert!(!h.click(p).is_empty(), "{id} remains clickable");
}
}
}
+19
View File
@@ -552,6 +552,10 @@ pub struct DesignApp {
/// window is ready; see [`DesignApp::open_documents_later`].
os_documents: Vec<String>,
pub integrated_titlebar: bool,
/// The desktop host uses our caption controls and window drag/resize regions (Windows).
pub custom_titlebar: bool,
pub(crate) window_maximized: bool,
pub(crate) pending_window_commands: Vec<egui::ViewportCommand>,
/// The host installed a native menu bar (macOS): don't draw menus in the window.
pub native_menu: bool,
/// Shortcuts the native menu handles (skip them in the egui shortcut handler).
@@ -625,6 +629,9 @@ impl DesignApp {
fonts_ready: false,
os_documents: Vec::new(),
integrated_titlebar: false,
custom_titlebar: false,
window_maximized: false,
pending_window_commands: vec![],
native_menu: false,
native_shortcuts: Default::default(),
last_time: 0.0,
@@ -1023,6 +1030,7 @@ impl DesignApp {
if self.egui_ctx.is_none() {
self.egui_ctx = Some(ctx.clone());
}
self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
if !self.color_applied {
self.color_applied = true;
if let Some(cs) = self.ui.color_settings.clone() {
@@ -1083,6 +1091,7 @@ impl DesignApp {
if self.fonts_ready {
menus::shortcuts(self, ctx);
}
self.flush_window_commands(ctx);
#[cfg(not(target_arch = "wasm32"))]
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let p = f.path().to_string_lossy().to_string();
@@ -1304,6 +1313,13 @@ impl DesignApp {
r
}
/// Deliver window actions to the host without persisting them in UI preferences.
pub(crate) fn flush_window_commands(&mut self, ctx: &egui::Context) {
for command in std::mem::take(&mut self.pending_window_commands) {
ctx.send_viewport_cmd(command);
}
}
/// Inject synthetic events (one pointer event per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
@@ -1451,6 +1467,9 @@ impl DesignApp {
dialogs::show(self, &ctx);
about::show(self, &ctx);
menus::palette(self, &ctx);
if self.custom_titlebar {
chrome::resize_borders(self, ui);
}
for url in std::mem::take(&mut self.ui.pending_urls) {
ctx.open_url(egui::OpenUrl::new_tab(url));
}
+46
View File
@@ -236,6 +236,11 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"),
("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"),
("window.newWindow", "New Window", None, "{on?: bool} — another view of the active document in its own window"),
("window.minimize", "Minimize Window", None, "{} — minimize the desktop window"),
("window.maximize", "Maximize/Restore Window", None, "{on?: bool} — toggle maximization when omitted"),
("window.close", "Close Window", None, "{} — request that the desktop window close"),
("window.drag", "Move Window", None, "{} — begin a native window drag while the primary mouse button is held"),
("window.resize", "Resize Window", None, "{edge: north|south|east|west|northEast|southEast|northWest|southWest} — begin a native edge drag"),
("window.taskBar", "Contextual Task Bar", None, "{}"),
(
"window.taskBarPin",
@@ -834,6 +839,9 @@ fn toggle_tabs_panel(app: &mut DesignApp) {
/// Run a UI command; `None` if `id` isn't one.
pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if matches!(id, "window.minimize" | "window.maximize" | "window.close" | "window.drag" | "window.resize") && !app.custom_titlebar {
return Some(Err("custom window controls are not available in this host".into()));
}
if let Some(result) = crate::panel_docking::command(app, id, p) {
return Some(result);
}
@@ -843,6 +851,38 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
Ok(json!(*b))
};
Some(match id {
"window.minimize" => {
app.pending_window_commands.push(egui::ViewportCommand::Minimized(true));
Ok(Value::Null)
}
"window.maximize" => {
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!app.window_maximized);
app.pending_window_commands.push(egui::ViewportCommand::Maximized(on));
Ok(json!(on))
}
"window.close" => {
app.pending_window_commands.push(egui::ViewportCommand::Close);
Ok(Value::Null)
}
"window.drag" => {
app.pending_window_commands.push(egui::ViewportCommand::StartDrag);
Ok(Value::Null)
}
"window.resize" => {
let edge = match p.get("edge").and_then(Value::as_str) {
Some("north") => egui::ResizeDirection::North,
Some("south") => egui::ResizeDirection::South,
Some("east") => egui::ResizeDirection::East,
Some("west") => egui::ResizeDirection::West,
Some("northEast") => egui::ResizeDirection::NorthEast,
Some("southEast") => egui::ResizeDirection::SouthEast,
Some("northWest") => egui::ResizeDirection::NorthWest,
Some("southWest") => egui::ResizeDirection::SouthWest,
_ => return Some(Err("invalid window resize edge".into())),
};
app.pending_window_commands.push(egui::ViewportCommand::BeginResize(edge));
Ok(Value::Null)
}
"app.newDocumentDialog" => {
app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({})));
Ok(Value::Null)
@@ -1833,6 +1873,9 @@ pub fn enabled(app: &DesignApp, id: &str) -> bool {
if waits_for_ime(app, id) {
return false;
}
if matches!(id, "window.minimize" | "window.maximize" | "window.close" | "window.drag" | "window.resize") {
return app.custom_titlebar;
}
match designcraft_engine::find_command(id) {
Some(c) => (c.enabled)(&app.session).is_ok(),
None => true,
@@ -2129,6 +2172,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
if waits_for_ime(app, id) {
return false;
}
if matches!(id, "window.minimize" | "window.maximize" | "window.close" | "window.drag" | "window.resize") {
return app.custom_titlebar;
}
if id == "app.justificationDialog" {
return enabled(app, "type.para");
}
+5
View File
@@ -85,6 +85,9 @@ pub struct Tokens {
pub measure_text: Color32,
/// Live-corner widget and corner diamonds.
pub live_corner: Color32,
/// The Windows caption Close button when hovered, and its glyph.
pub caption_close: Color32,
pub caption_close_text: Color32,
}
fn hex(s: u32) -> Color32 {
@@ -128,6 +131,8 @@ impl Tokens {
measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230),
measure_text: Color32::WHITE,
live_corner: hex(0xffe500),
caption_close: hex(0xc42b1c),
caption_close_text: Color32::WHITE,
};
match b {
Brightness::Dark => dark,