Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ zip = { version = "8.6", default-features = false, features = ["deflate"] }
quick-xml = { version = "0.41", default-features = false }
base64 = "0.22"
flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "tiff"] }
image = { version = "0.25", default-features = false, features = ["bmp", "jpeg", "png", "tiff", "webp"] }
tiff = "0.11"
memmap2 = "0.9"
tempfile = "3"
arboard = "3.6"
libc = "0.2"
pdf-writer = "0.12"
resvg = "0.47"
Expand All @@ -95,6 +99,8 @@ windows-sys = { version = "0.61", features = [
"Win32_System_ProcessStatus",
"Win32_System_Threading",
"Win32_UI_Controls",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }

Expand Down
2 changes: 2 additions & 0 deletions crates/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ egui_extras.workspace = true
egui-phosphor.workspace = true
fontdb.workspace = true
image.workspace = true
arboard.workspace = true
log.workspace = true
num-complex.workspace = true
rfd.workspace = true
Expand Down Expand Up @@ -56,3 +57,4 @@ winresource = "0.1"

[dev-dependencies]
ab_glyph = "0.2"
tiff.workspace = true
17 changes: 7 additions & 10 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ struct Shell {
}

impl eframe::App for Shell {
#[cfg(windows)]
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
ui::clipboard_native::restore_missing_paste_shortcut(raw_input);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
observability::show_pending_crash_dialog();
Expand Down Expand Up @@ -586,26 +590,19 @@ impl Shell {
}
}

/// Undecorated windows lose the DWM frame, so Windows 11 rounded corners and
/// the drop shadow must be requested explicitly. Both calls are cosmetic:
/// failures (e.g. Windows 10 rejecting the corner attribute) are deliberately
/// ignored and the window simply stays square/shadowless.
/// Register native drag hit testing, then restore the DWM frame effects lost
/// by undecorated windows. Cosmetic DWM failures are deliberately ignored.
#[cfg(windows)]
fn apply_windows_frame_polish(cc: &eframe::CreationContext<'_>) {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows_sys::Win32::Graphics::Dwm::{
DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, DwmExtendFrameIntoClientArea,
DwmSetWindowAttribute,
};
use windows_sys::Win32::UI::Controls::MARGINS;

let Ok(handle) = cc.window_handle() else {
return;
};
let RawWindowHandle::Win32(win32) = handle.as_raw() else {
let Some(hwnd) = ui::file_drop::register_native_window(cc) else {
return;
};
let hwnd = win32.hwnd.get() as *mut core::ffi::c_void;
let corner = DWMWCP_ROUND;
// SAFETY: hwnd comes from the live winit window; both DWM calls only read
// the passed attribute structs.
Expand Down
11 changes: 7 additions & 4 deletions crates/app/src/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,13 @@ fn rotate_files(dir: &Path, prefix: &str, keep: usize) -> io::Result<()> {
})
.collect::<Vec<_>>();
files.sort_by_key(|entry| {
entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(UNIX_EPOCH)
(
entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(UNIX_EPOCH),
entry.file_name(),
)
});
let remove_count = files.len().saturating_sub(keep);
for entry in files.into_iter().take(remove_count) {
Expand Down
4 changes: 3 additions & 1 deletion crates/app/src/ui/canvas/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,9 @@ pub(crate) fn dispatch_frame_gesture(app: &mut PlotxApp, rect: egui::Rect, ui: &
});
if pressed
&& let Some(point) = hover
&& let Some((canvas, _)) = object_at_screen(app, rect, point)
&& let Some(canvas) = object_at_screen(app, rect, point)
.map(|(canvas, _)| canvas)
.or_else(|| panel_at_screen(app, rect, point).map(|(canvas, _)| canvas))
{
if app.session.active_canvas != Some(canvas) {
activate_frame(app, FrameRef::Page(canvas));
Expand Down
51 changes: 51 additions & 0 deletions crates/app/src/ui/canvas/breadcrumb.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use super::*;

pub(crate) fn canvas_breadcrumb(app: &mut PlotxApp, ci: usize, ui: &mut Ui) {
let canvas_name = app.doc.canvases[ci].name.clone();
let lead = app.session.ui.hierarchical_selection.lead();
let panel = lead.and_then(|path| path.panel).and_then(|id| {
app.doc.canvases[ci]
.panel(id)
.map(|panel| (id, panel.name.clone()))
});
let content = lead.and_then(|path| path.content).and_then(|id| {
app.doc.canvases[ci]
.object(id)
.map(|object| (id, object.name.clone()))
});
let mut clicked = None;
ui.horizontal(|ui| {
ui.add_space(2.0);
if ui.small_button(canvas_name).clicked() {
clicked = Some(BreadcrumbTarget::Canvas);
}
if let Some((id, name)) = panel {
ui.weak("›");
if ui.small_button(name).clicked() {
clicked = Some(BreadcrumbTarget::Panel(id));
}
}
if let Some((id, name)) = content {
ui.weak("›");
if ui.small_button(name).clicked() {
clicked = Some(BreadcrumbTarget::Content(id));
}
} else if lead.is_none() {
ui.weak("›");
ui.small(app.session.tool.label());
}
});
ui.add_space(2.0);
match clicked {
Some(BreadcrumbTarget::Canvas) => app.exit_panel_scope(),
Some(BreadcrumbTarget::Panel(panel)) => app.select_panel(ci, panel),
Some(BreadcrumbTarget::Content(content)) => app.select_content(ci, content),
None => {}
}
}

enum BreadcrumbTarget {
Canvas,
Panel(PanelId),
Content(ObjectId),
}
95 changes: 57 additions & 38 deletions crates/app/src/ui/canvas/geometry.rs
Original file line number Diff line number Diff line change
@@ -1,42 +1,13 @@
use super::*;

#[derive(Clone, Copy)]
pub(crate) struct ObjectHit {
pub(crate) object: ObjectId,
pub(crate) kind: ObjectDragKind,
}

pub(crate) fn hit_object(canvas: &CanvasDocument, p: Pos2, zoom: f32) -> Option<ObjectHit> {
let handle_radius = (HANDLE_SIZE_PX / zoom.max(0.01)).max(3.0);
canvas.objects.iter().rev().find_map(|object| {
if !object.visible {
return None;
}
let frame = canvas.layout_frame(object.id)?;
let r = egui::Rect::from_min_size(
Pos2::new(frame.x, frame.y),
egui::vec2(frame.width, frame.height),
);
let handles = [
(r.left_top(), ResizeHandle::TopLeft),
(r.right_top(), ResizeHandle::TopRight),
(r.left_bottom(), ResizeHandle::BottomLeft),
(r.right_bottom(), ResizeHandle::BottomRight),
];
for (pos, handle) in handles {
if pos.distance(p) <= handle_radius {
return Some(ObjectHit {
object: object.id,
kind: ObjectDragKind::Resize(handle),
});
}
}
r.contains(p).then_some(ObjectHit {
object: object.id,
kind: ObjectDragKind::Move,
})
})
}
#[path = "geometry_aspect.rs"]
mod aspect;
pub(crate) use aspect::preserve_aspect_frame;
#[path = "geometry_panels.rs"]
mod panels;
pub(crate) use panels::{
ObjectHit, PanelHit, content_screen_rect, hit_content_object, hit_content_objects, hit_object,
hit_objects, hit_panel,
};

/// Topmost canvas object under a screen point, including objects outside their
/// owning page. The active page wins ties, matching page/frame hit ordering.
Expand All @@ -59,6 +30,28 @@ pub(crate) fn object_at_screen(
})
}

/// Topmost Panel under a screen point, including an empty Panel or one placed
/// beyond its page boundary. Board-level gestures use this before considering
/// a marquee so direct manipulation always owns a semantic Panel hit.
pub(crate) fn panel_at_screen(
app: &PlotxApp,
screen: egui::Rect,
point: Pos2,
) -> Option<(usize, PanelHit)> {
let mut canvases: Vec<_> = (0..app.doc.canvases.len()).rev().collect();
if let Some(active) = app.session.active_canvas
&& canvases.contains(&active)
{
canvases.retain(|candidate| *candidate != active);
canvases.insert(0, active);
}
let transform = BoardTransform::from_board(app.session.board, screen);
canvases.into_iter().find_map(|canvas| {
let page = transform.screen_to_page(&app.doc.canvases[canvas], point);
hit_panel(&app.doc.canvases[canvas], page, app.session.board.zoom).map(|hit| (canvas, hit))
})
}

pub(crate) fn drag_frame(
frame: ObjectFrame,
kind: ObjectDragKind,
Expand Down Expand Up @@ -199,6 +192,21 @@ impl BoardTransform {
};
bounds = bounds.union(plot_rect(frame));
}
// Empty Panels have no content frame to contribute above, but remain
// directly manipulable page objects and must not disappear from the
// editor's input/culling bounds when placed beyond the paper edge.
let page = self.page_screen_rect(canvas);
for panel in &canvas.panels {
let frame = panel.frame;
let panel_rect = egui::Rect::from_min_size(
Pos2::new(
page.left() + frame.x * self.zoom,
page.top() + frame.y * self.zoom,
),
egui::vec2(frame.width * self.zoom, frame.height * self.zoom),
);
bounds = bounds.union(panel_rect);
}
bounds
}

Expand Down Expand Up @@ -435,6 +443,10 @@ pub(crate) fn pos(p: [f32; 2]) -> Pos2 {
Pos2::new(p[0], p[1])
}

#[cfg(test)]
#[path = "geometry_pr2_tests.rs"]
mod pr2_tests;

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -683,6 +695,13 @@ mod tests {
.push(text_object(2, ObjectFrame::new(20.0, 20.0, 50.0, 50.0)));
let hit = hit_object(&canvas, Pos2::new(35.0, 35.0), 1.0).unwrap();
assert_eq!(hit.object, ObjectId::new(2));
assert_eq!(
hit_objects(&canvas, Pos2::new(35.0, 35.0), 1.0)
.iter()
.map(|hit| hit.object)
.collect::<Vec<_>>(),
[ObjectId::new(2), ObjectId::new(1)]
);
}

#[test]
Expand Down
30 changes: 30 additions & 0 deletions crates/app/src/ui/canvas/geometry_aspect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
use super::*;

pub(crate) fn preserve_aspect_frame(
before: ObjectFrame,
candidate: ObjectFrame,
kind: ObjectDragKind,
) -> ObjectFrame {
let ObjectDragKind::Resize(handle) = kind else {
return candidate;
};
let width_scale = candidate.width / before.width;
let height_scale = candidate.height / before.height;
let scale = if (width_scale - 1.0).abs() >= (height_scale - 1.0).abs() {
width_scale
} else {
height_scale
}
.max(MIN_OBJECT_SIZE_PT / before.width)
.max(MIN_OBJECT_SIZE_PT / before.height);
let width = before.width * scale;
let height = before.height * scale;
let right = before.x + before.width;
let bottom = before.y + before.height;
match handle {
ResizeHandle::TopLeft => ObjectFrame::new(right - width, bottom - height, width, height),
ResizeHandle::TopRight => ObjectFrame::new(before.x, bottom - height, width, height),
ResizeHandle::BottomLeft => ObjectFrame::new(right - width, before.y, width, height),
ResizeHandle::BottomRight => ObjectFrame::new(before.x, before.y, width, height),
}
}
Loading
Loading