4 Commits

Author SHA1 Message Date
Marcos Paulo ab3d17e067 feat(ui): Catppuccin themes, custom title bar and system title bar toggle; release 0.6.1 2026-10-12 10:51:44 -03:00
Marcos Paulo f864f15d79 build: use the masi/craft-libs fork
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 00:37:34 -03:00
Marcos Paulo 402bb38a59 build(windows): set working directory to repo root in package.ps1 2026-10-11 22:38:53 -03:00
Marcos Paulo e51ad0cd72 build(windows): package standalone pdfcraft-cli zip and auto-detect rc.exe 2026-10-11 22:37:52 -03:00
10 changed files with 592 additions and 73 deletions
Generated
+35 -35
View File
@@ -1093,7 +1093,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
dependencies = [
"egui",
"serde",
@@ -3552,7 +3552,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3583,7 +3583,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3594,7 +3594,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3605,7 +3605,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"base64 0.22.1",
"image",
@@ -3622,7 +3622,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -3637,18 +3637,18 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.6.0"
version = "0.6.1"
[[package]]
name = "pdfcraft-content"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
@@ -3660,7 +3660,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -3675,7 +3675,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"aes",
"md-5",
@@ -3687,7 +3687,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"hayro-syntax",
"pdfcraft-annot",
@@ -3701,7 +3701,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"chrono",
"icu_properties",
@@ -3738,14 +3738,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"flate2",
"proptest",
@@ -3755,7 +3755,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -3764,7 +3764,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -3776,18 +3776,18 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.6.0"
version = "0.6.1"
[[package]]
name = "pdfcraft-js"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -3799,14 +3799,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -3816,7 +3816,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"image",
"pdfcraft-annot",
@@ -3829,7 +3829,7 @@ dependencies = [
[[package]]
name = "pdfcraft-organize"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -3839,7 +3839,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"async-channel",
"futures-lite",
@@ -3848,7 +3848,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"pdfcraft-cos",
"pdfcraft-model",
@@ -3856,7 +3856,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3866,7 +3866,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -3880,7 +3880,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"bytemuck",
"flate2",
@@ -3898,7 +3898,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"aes",
"aws-lc-rs",
@@ -3932,7 +3932,7 @@ dependencies = [
[[package]]
name = "pdfcraft-ui-egui"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -3972,7 +3972,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
@@ -3984,7 +3984,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"base64 0.22.1",
"image",
@@ -3998,7 +3998,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
+2 -2
View File
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.6.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -13,7 +13,7 @@ repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", version = "0.1.0", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.6.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.6.0" }
+50 -10
View File
@@ -64,6 +64,25 @@ fn settings_dir() -> Option<std::path::PathBuf> {
eframe::storage_dir("PdfCraft")
}
/// Windows and Linux: no OS title bar; the top row is the title bar
/// (`pdfcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated top row.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ▸ Appearance ▸
/// Use System Title Bar asks for the system's. Read from the saved settings before the window
/// opens; a missing or unreadable file keeps the default.
fn custom_titlebar(settings_dir: Option<&std::path::Path>) -> bool {
let system_title_bar = settings_dir
.and_then(|d| std::fs::read_to_string(d.join("app.ron")).ok())
.is_some_and(|s| {
s.contains(r#"system_title_bar\":true"#)
|| s.contains(r#"system_title_bar\": true"#)
|| s.contains(r#""system_title_bar":true"#)
|| s.contains(r#""system_title_bar": true"#)
});
CUSTOM_TITLEBAR && !system_title_bar
}
/// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new
/// name once, so an upgrade keeps recent files, preferences and unsaved work. Best effort: a
/// folder is left alone when the new one already exists or the move fails. A portable copy leaves
@@ -188,7 +207,8 @@ fn main() -> eframe::Result {
single_instance::Claim::Alone => None,
};
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated };
let custom_titlebar = custom_titlebar(settings_dir().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated, custom_titlebar };
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
// that launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -198,7 +218,7 @@ fn main() -> eframe::Result {
let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu };
let result = eframe::run_native(
"PdfCraft",
native_options(integrated, first),
native_options(custom_titlebar, integrated, first),
app_creator(
launch.clone(),
Rc::clone(&started),
@@ -216,7 +236,7 @@ fn main() -> eframe::Result {
log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu.");
eframe::run_native(
"PdfCraft",
native_options(integrated, eframe::Renderer::Glow),
native_options(custom_titlebar, integrated, eframe::Renderer::Glow),
app_creator(
launch,
started,
@@ -256,7 +276,7 @@ fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool {
}
/// The window and renderer settings for one run.
fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
fn native_options(custom_titlebar: bool, integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
@@ -269,7 +289,9 @@ fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::Nativ
Ok(icon) => viewport = viewport.with_icon(icon),
Err(e) => log::warn!("app icon: {e}"),
}
if integrated {
if custom_titlebar {
viewport = viewport.with_decorations(false).with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
} else if integrated {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
}
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
@@ -289,6 +311,7 @@ struct Launch {
control_file: Option<String>,
create_images: bool,
integrated: bool,
custom_titlebar: bool,
}
fn app_creator<'a>(
@@ -297,7 +320,7 @@ fn app_creator<'a>(
instance: Option<&'a single_instance::Server>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated } = launch;
let Launch { files, options, control_file, create_images, integrated, custom_titlebar } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
@@ -311,6 +334,7 @@ fn app_creator<'a>(
app.restore(&json);
}
app.integrated_titlebar = integrated;
app.custom_titlebar = custom_titlebar;
app.system_text_scale = text_scale::system();
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
@@ -731,8 +755,8 @@ mod tests {
assert!(!retry_with_gl(RendererChoice::Wgpu, false));
assert!(!retry_with_gl(RendererChoice::Gl, false));
// Each run states its renderer rather than relying on eframe's default.
assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
}
#[test]
@@ -878,7 +902,7 @@ mod tests {
// and an error wgpu raises before anything reaches the driver: a texture one pixel wider
// than the device allows.
use eframe::wgpu;
let native = super::native_options(false, eframe::Renderer::Wgpu);
let native = super::native_options(false, false, eframe::Renderer::Wgpu);
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
panic!("default setup creates its own instance")
};
@@ -944,7 +968,7 @@ mod tests {
#[test]
fn pipeline_creation_error_is_returned_and_device_remains_usable() {
use eframe::{egui_wgpu, wgpu};
let mut native = super::native_options(false, eframe::Renderer::Wgpu);
let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
@@ -1219,4 +1243,20 @@ mod tests {
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
assert_eq!(class, Some(super::APP_ID));
}
#[test]
fn custom_titlebar_detects_preferences() {
let temp = std::env::temp_dir().join(format!("pdfcraft_test_titlebar_{}", std::process::id()));
let _ = std::fs::create_dir_all(&temp);
assert_eq!(super::custom_titlebar(Some(&temp)), super::CUSTOM_TITLEBAR);
std::fs::write(temp.join("app.ron"), r#"{"pdfcraft": "{\"system_title_bar\":true}"}"#).unwrap();
assert!(!super::custom_titlebar(Some(&temp)));
std::fs::write(temp.join("app.ron"), r#"{"pdfcraft": "{\"system_title_bar\":false}"}"#).unwrap();
assert_eq!(super::custom_titlebar(Some(&temp)), super::CUSTOM_TITLEBAR);
let _ = std::fs::remove_dir_all(&temp);
}
}
+53 -10
View File
@@ -7,14 +7,26 @@ use crate::PdfCraftApp;
use crate::theme::{self, ThemeKind, ThemePreference, Tokens};
/// The themes each card offers. One of each today; a new theme is one more row here.
pub const LIGHT_THEMES: &[(ThemeKind, &str)] = &[(ThemeKind::Light, "Light gray")];
pub const DARK_THEMES: &[(ThemeKind, &str)] = &[(ThemeKind::Dark, "Dark gray")];
pub const LIGHT_THEMES: &[(ThemeKind, &str)] = &[
(ThemeKind::Light, "Light gray"),
(ThemeKind::CatppuccinLatte, "Catppuccin Latte"),
];
pub const DARK_THEMES: &[(ThemeKind, &str)] = &[
(ThemeKind::Dark, "Dark gray"),
(ThemeKind::CatppuccinFrappe, "Catppuccin Frappé"),
(ThemeKind::CatppuccinMacchiato, "Catppuccin Macchiato"),
(ThemeKind::CatppuccinMocha, "Catppuccin Mocha"),
];
fn mode_label(preference: ThemePreference) -> &'static str {
match preference {
ThemePreference::System => "Use system setting",
ThemePreference::Light => "Light",
ThemePreference::Dark => "Dark",
ThemePreference::CatppuccinLatte => "Catppuccin Latte",
ThemePreference::CatppuccinFrappe => "Catppuccin Frappé",
ThemePreference::CatppuccinMacchiato => "Catppuccin Macchiato",
ThemePreference::CatppuccinMocha => "Catppuccin Mocha",
}
}
@@ -23,6 +35,10 @@ fn mode_command(preference: ThemePreference) -> &'static str {
ThemePreference::System => "view.theme.system",
ThemePreference::Light => "view.theme.light",
ThemePreference::Dark => "view.theme.dark",
ThemePreference::CatppuccinLatte => "view.theme.catppuccinLatte",
ThemePreference::CatppuccinFrappe => "view.theme.catppuccinFrappe",
ThemePreference::CatppuccinMacchiato => "view.theme.catppuccinMacchiato",
ThemePreference::CatppuccinMocha => "view.theme.catppuccinMocha",
}
}
@@ -83,7 +99,7 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
/// One theme card: its title, Active when it is what shows now, a preview of the chosen theme
/// and a radio row per theme.
fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) {
fn card(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) {
egui::Frame::new()
.fill(t.card)
.stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.border }))
@@ -98,12 +114,22 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The
}
});
ui.add_space(4.0);
let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind);
let shown = themes.iter().find(|(k, _)| *k == app.theme).map_or(themes[0].0, |(k, _)| *k);
preview(ui, shown, ui.available_width());
ui.add_space(4.0);
for (kind, label) in themes {
// One theme per card today, so it is always the chosen one.
let _ = ui.radio(*kind == shown, tl!(label));
let selected = app.theme == *kind;
if ui.radio(selected, tl!(label)).clicked() {
let cmd = match kind {
ThemeKind::Light => "view.theme.light",
ThemeKind::Dark => "view.theme.dark",
ThemeKind::CatppuccinLatte => "view.theme.catppuccinLatte",
ThemeKind::CatppuccinFrappe => "view.theme.catppuccinFrappe",
ThemeKind::CatppuccinMacchiato => "view.theme.catppuccinMacchiato",
ThemeKind::CatppuccinMocha => "view.theme.catppuccinMocha",
};
app.execute(cmd);
}
}
});
}
@@ -114,7 +140,15 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens)
ui.horizontal(|ui| {
ui.label(tl!("Appearance mode"));
egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| {
for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] {
for preference in [
ThemePreference::System,
ThemePreference::Light,
ThemePreference::Dark,
ThemePreference::CatppuccinLatte,
ThemePreference::CatppuccinFrappe,
ThemePreference::CatppuccinMacchiato,
ThemePreference::CatppuccinMocha,
] {
if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() {
app.execute(mode_command(preference));
ui.close();
@@ -123,12 +157,21 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens)
});
});
ui.add_space(4.0);
let light_active = app.theme == ThemeKind::Light;
let light_active = app.theme.is_light();
ui.columns(2, |cols| {
let [light, dark] = cols else { return };
card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES);
card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES);
card(light, app, t, tl!("Light theme"), light_active, LIGHT_THEMES);
card(dark, app, t, tl!("Dark theme"), !light_active, DARK_THEMES);
});
#[cfg(not(target_os = "macos"))]
{
ui.add_space(6.0);
let mut sys = app.system_title_bar;
let label = "Use System Title Bar on Windows and Linux";
if ui.checkbox(&mut sys, crate::i18n::t(label)).changed() {
app.system_title_bar = sys;
}
}
}
#[cfg(test)]
+31 -10
View File
@@ -31,20 +31,26 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
// macOS: the strip is the 28 pt title bar, so the traffic lights are centred in it; tabs and
// buttons shrink to keep a margin above and below.
let mac = cfg!(target_os = "macos") && app.integrated_titlebar;
let custom = app.custom_titlebar;
let left = if mac { MAC_CONTENT_LEFT as i8 } else { 8 };
let right = if custom { (crate::titlebar::WIDTH + 8.0) as i8 } else { 10 };
let (tab_h, button) = if mac { (MAC_TITLE_BAR - 2.0, 24.0) } else { (TAB_HEIGHT, STRIP_BUTTON) };
egui::Panel::top("tab_strip")
.exact_size(if mac { MAC_TITLE_BAR } else { TAB_STRIP })
.frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 }))
.frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right, top: 0, bottom: 0 }))
.show(ui, |ui| {
let full = ui.max_rect();
let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
if custom || mac {
crate::titlebar::drag_area(ui, full);
} else {
let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
}
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
@@ -175,15 +181,23 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ThemePreference::System => ("monitor", tl!("Use system setting")),
ThemePreference::Light => ("sun", tl!("Light gray")),
ThemePreference::Dark => ("moon", tl!("Dark gray")),
ThemePreference::CatppuccinLatte => ("sun", "Catppuccin Latte"),
ThemePreference::CatppuccinFrappe => ("moon", "Catppuccin Frappé"),
ThemePreference::CatppuccinMacchiato => ("moon", "Catppuccin Macchiato"),
ThemePreference::CatppuccinMocha => ("moon", "Catppuccin Mocha"),
};
let tip = format!("{}: {label}", tl!("Display theme"));
let response = icons::button(ui, icon, button, false, &tip);
// A click cycles System, Light, Dark; a right-click opens the menu.
// A click cycles System, Light, Dark, Catppuccin; a right-click opens the menu.
if response.clicked() {
app.execute(match app.theme_preference {
ThemePreference::System => "view.theme.light",
ThemePreference::Light => "view.theme.dark",
ThemePreference::Dark => "view.theme.system",
ThemePreference::Dark => "view.theme.catppuccinLatte",
ThemePreference::CatppuccinLatte => "view.theme.catppuccinFrappe",
ThemePreference::CatppuccinFrappe => "view.theme.catppuccinMacchiato",
ThemePreference::CatppuccinMacchiato => "view.theme.catppuccinMocha",
ThemePreference::CatppuccinMocha => "view.theme.system",
});
}
egui::Popup::context_menu(&response).id(response.id.with("theme_menu")).show(|ui| appearance_menu(app, ui));
@@ -192,6 +206,9 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
});
});
if custom {
crate::titlebar::caption_buttons(app, ui, full);
}
});
}
@@ -201,6 +218,10 @@ fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
(ThemePreference::System, "view.theme.system", "Use system setting"),
(ThemePreference::Light, "view.theme.light", "Light gray"),
(ThemePreference::Dark, "view.theme.dark", "Dark gray"),
(ThemePreference::CatppuccinLatte, "view.theme.catppuccinLatte", "Catppuccin Latte"),
(ThemePreference::CatppuccinFrappe, "view.theme.catppuccinFrappe", "Catppuccin Frappé"),
(ThemePreference::CatppuccinMacchiato, "view.theme.catppuccinMacchiato", "Catppuccin Macchiato"),
(ThemePreference::CatppuccinMocha, "view.theme.catppuccinMocha", "Catppuccin Mocha"),
] {
if ui.radio(app.theme_preference == preference, tl!(label)).clicked() {
app.execute(command);
+6 -2
View File
@@ -9,7 +9,7 @@ use pdfcraft_engine::commands::{self, COMMANDS, CommandSpec, Shortcut};
use crate::{
Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, SaveTarget,
theme::{ThemeKind, ThemePreference},
theme::ThemePreference,
widgets,
};
@@ -261,13 +261,17 @@ impl PdfCraftApp {
}
"view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false),
"view.theme" => {
let next = if self.theme == ThemeKind::Light { ThemePreference::Dark } else { ThemePreference::Light };
let next = if self.theme.is_light() { ThemePreference::Dark } else { ThemePreference::Light };
self.set_theme_preference(next);
}
command if command.starts_with("measure.") => crate::measure_ui::command(self, command),
"view.theme.system" => self.set_theme_preference(ThemePreference::System),
"view.theme.light" => self.set_theme_preference(ThemePreference::Light),
"view.theme.dark" => self.set_theme_preference(ThemePreference::Dark),
"view.theme.catppuccinlatte" | "view.theme.catppuccinLatte" => self.set_theme_preference(ThemePreference::CatppuccinLatte),
"view.theme.catppuccinfrappe" | "view.theme.catppuccinFrappe" => self.set_theme_preference(ThemePreference::CatppuccinFrappe),
"view.theme.catppuccinmacchiato" | "view.theme.catppuccinMacchiato" => self.set_theme_preference(ThemePreference::CatppuccinMacchiato),
"view.theme.catppuccinmocha" | "view.theme.catppuccinMocha" => self.set_theme_preference(ThemePreference::CatppuccinMocha),
"comment.list" => self.choose_right_panel(Some(RightPanel::Comments)),
tool if crate::comments::CommentTool::from_command(tool).is_some() => {
let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return false };
+17 -1
View File
@@ -119,6 +119,7 @@ pub mod scroll_speed;
#[cfg(not(target_arch = "wasm32"))]
mod system_fonts;
pub mod theme;
pub mod titlebar;
pub mod ui_scale;
pub mod updates;
mod wheel_pager;
@@ -507,6 +508,8 @@ pub struct PdfCraftApp {
pub toast: Option<(String, f64)>,
/// Whether the macOS title bar is drawn by us (traffic lights over our tab strip).
pub integrated_titlebar: bool,
pub custom_titlebar: bool,
pub system_title_bar: bool,
pub password_prompt: Option<PasswordPrompt>,
pub unlock_prompt: Option<UnlockPrompt>,
pub full_screen: bool,
@@ -809,6 +812,8 @@ impl PdfCraftApp {
pinned: Default::default(),
toast: None,
integrated_titlebar: false,
custom_titlebar: false,
system_title_bar: false,
password_prompt: None,
unlock_prompt: None,
full_screen: false,
@@ -1620,6 +1625,7 @@ impl PdfCraftApp {
"reload_changed_files": self.reload_changed_files,
"pinned_folders": self.pinned.folders,
"theme": self.theme_preference,
"system_title_bar": self.system_title_bar,
"scroll_speed": self.scroll_speed,
"default_mode": self.default_mode,
"default_layout": canvas::display_token(self.view_defaults.layout, self.view_defaults.continuous),
@@ -1703,6 +1709,9 @@ impl PdfCraftApp {
if let Ok(preference) = serde_json::from_value::<ThemePreference>(v["theme"].clone()) {
self.set_theme_preference(preference);
}
if let Some(on) = v["system_title_bar"].as_bool() {
self.system_title_bar = on;
}
if let Ok(speed) = serde_json::from_value::<scroll_speed::ScrollSpeed>(v["scroll_speed"].clone()) {
self.scroll_speed = speed;
}
@@ -1825,7 +1834,11 @@ impl PdfCraftApp {
"system" => ThemePreference::System,
"light" => ThemePreference::Light,
"dark" => ThemePreference::Dark,
_ => return Err("theme must be light, dark, or system".into()),
"catppuccinlatte" | "catppuccin_latte" | "catppuccinLatte" => ThemePreference::CatppuccinLatte,
"catppuccinfrappe" | "catppuccin_frappe" | "catppuccinFrappe" => ThemePreference::CatppuccinFrappe,
"catppuccinmacchiato" | "catppuccin_macchiato" | "catppuccinMacchiato" => ThemePreference::CatppuccinMacchiato,
"catppuccinmocha" | "catppuccin_mocha" | "catppuccinMocha" => ThemePreference::CatppuccinMocha,
_ => return Err("theme must be light, dark, catppuccinLatte, catppuccinFrappe, catppuccinMacchiato, catppuccinMocha, or system".into()),
};
self.set_theme_preference(preference);
}
@@ -2441,6 +2454,9 @@ impl eframe::App for PdfCraftApp {
dialogs::show(self, &ctx);
self.show_progress(&ctx);
widgets::toast(self, &ctx);
if self.custom_titlebar {
titlebar::resize_zones(ui);
}
self.finish_render_frame(&ctx);
}
}
+99 -2
View File
@@ -3,6 +3,7 @@
use std::sync::Arc;
use craft_ui::theme::catppuccin::Flavor;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
@@ -10,6 +11,46 @@ pub enum ThemeKind {
#[default]
Light,
Dark,
CatppuccinLatte,
CatppuccinFrappe,
CatppuccinMacchiato,
CatppuccinMocha,
}
impl ThemeKind {
pub const ALL: [ThemeKind; 6] = [
ThemeKind::Light,
ThemeKind::Dark,
ThemeKind::CatppuccinLatte,
ThemeKind::CatppuccinFrappe,
ThemeKind::CatppuccinMacchiato,
ThemeKind::CatppuccinMocha,
];
pub fn name(self) -> &'static str {
match self {
ThemeKind::Light => "light",
ThemeKind::Dark => "dark",
ThemeKind::CatppuccinLatte => "catppuccinLatte",
ThemeKind::CatppuccinFrappe => "catppuccinFrappe",
ThemeKind::CatppuccinMacchiato => "catppuccinMacchiato",
ThemeKind::CatppuccinMocha => "catppuccinMocha",
}
}
pub fn flavor(self) -> Option<Flavor> {
match self {
ThemeKind::CatppuccinLatte => Some(Flavor::Latte),
ThemeKind::CatppuccinFrappe => Some(Flavor::Frappe),
ThemeKind::CatppuccinMacchiato => Some(Flavor::Macchiato),
ThemeKind::CatppuccinMocha => Some(Flavor::Mocha),
_ => None,
}
}
pub fn is_light(self) -> bool {
matches!(self, ThemeKind::Light | ThemeKind::CatppuccinLatte)
}
}
/// The saved user choice, independent of the light/dark colours currently displayed.
@@ -19,6 +60,10 @@ pub enum ThemePreference {
#[default]
Light,
Dark,
CatppuccinLatte,
CatppuccinFrappe,
CatppuccinMacchiato,
CatppuccinMocha,
}
impl ThemePreference {
@@ -28,6 +73,10 @@ impl ThemePreference {
match self {
Self::Light => ThemeKind::Light,
Self::Dark => ThemeKind::Dark,
Self::CatppuccinLatte => ThemeKind::CatppuccinLatte,
Self::CatppuccinFrappe => ThemeKind::CatppuccinFrappe,
Self::CatppuccinMacchiato => ThemeKind::CatppuccinMacchiato,
Self::CatppuccinMocha => ThemeKind::CatppuccinMocha,
Self::System => match system {
Some(true) => ThemeKind::Dark,
Some(false) => ThemeKind::Light,
@@ -66,8 +115,40 @@ pub struct Tokens {
pub radius: u8,
}
fn catppuccin(kind: ThemeKind, f: Flavor) -> Tokens {
let p = f.palette();
let dark = f.is_dark();
Tokens {
kind,
titlebar: p.mantle,
chrome: p.base,
panel: p.base,
pasteboard: p.crust,
card: p.surface0,
border: p.surface1,
divider: p.surface0,
text: p.text,
text_muted: p.subtext0,
text_faint: p.overlay1,
icon: p.subtext1,
hover: p.surface0,
pressed: p.surface1,
selected: Color32::from_rgba_unmultiplied(p.mauve.r(), p.mauve.g(), p.mauve.b(), if dark { 55 } else { 40 }),
accent: p.mauve,
accent_text: if dark { p.lavender } else { p.mauve },
accent_soft: Color32::from_rgba_unmultiplied(p.mauve.r(), p.mauve.g(), p.mauve.b(), if dark { 40 } else { 30 }),
field: p.mantle,
badge_new: p.mauve,
page_shadow: if dark { Color32::from_black_alpha(120) } else { Color32::from_black_alpha(34) },
radius: 6,
}
}
impl Tokens {
pub fn for_kind(kind: ThemeKind) -> Self {
if let Some(f) = kind.flavor() {
return catppuccin(kind, f);
}
match kind {
ThemeKind::Light => Self {
kind,
@@ -93,7 +174,7 @@ impl Tokens {
page_shadow: Color32::from_black_alpha(34),
radius: 6,
},
ThemeKind::Dark => Self {
ThemeKind::Dark | ThemeKind::CatppuccinLatte | ThemeKind::CatppuccinFrappe | ThemeKind::CatppuccinMacchiato | ThemeKind::CatppuccinMocha => Self {
kind,
titlebar: Color32::from_rgb(0x1B, 0x1B, 0x1E),
chrome: Color32::from_rgb(0x26, 0x26, 0x2A),
@@ -125,7 +206,7 @@ impl Tokens {
}
pub fn dark(&self) -> bool {
self.kind == ThemeKind::Dark
!self.kind.is_light()
}
}
@@ -379,4 +460,20 @@ mod tests {
}
}
}
#[test]
fn catppuccin_flavors_resolve_and_set_correct_kind() {
for kind in [
ThemeKind::CatppuccinLatte,
ThemeKind::CatppuccinFrappe,
ThemeKind::CatppuccinMacchiato,
ThemeKind::CatppuccinMocha,
] {
assert!(kind.flavor().is_some());
let t = Tokens::for_kind(kind);
assert_eq!(t.kind, kind);
assert_eq!(t.dark(), !kind.is_light());
}
}
}
+272
View File
@@ -0,0 +1,272 @@
//! The window's own title bar on Windows and Linux, where the window has no OS decorations
//! ([`PdfCraftApp::custom_titlebar`]): the tab strip row is the title bar. Empty space in it
//! drags the window and a double-click there maximizes it, caption buttons at the far right
//! minimize, maximize/restore and close, and thin invisible zones along the window's edges resize
//! it. On macOS the traffic lights sit over the same row and only the drag works.
use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
use crate::PdfCraftApp;
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;
/// Height of the title row (tab strip) when it carries the caption buttons.
pub const HEIGHT: f32 = 38.0;
/// Resize zone thickness along an edge, and the side of a corner zone.
const EDGE: f32 = 6.0;
const CORNER: f32 = 12.0;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Caption {
Minimize,
Maximize,
Close,
}
const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
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)));
}
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());
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);
}
}
pub fn caption_buttons(app: &mut PdfCraftApp, ui: &mut Ui, bar: Rect) {
let t = Tokens::get(ui.ctx());
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 => egui::Color32::from_rgb(0xc4, 0x2b, 0x1c),
(_, true) if close => egui::Color32::from_rgb(0xe8, 0x11, 0x23),
(true, _) => t.pressed,
(_, true) => t.hover,
_ => egui::Color32::TRANSPARENT,
};
ui.painter().rect_filled(r, 0.0, fill);
let ink = if close && resp.hovered() { egui::Color32::WHITE } else { t.icon };
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
let tip = caption_tip(c, max);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
if resp.on_hover_text(tip).clicked() {
clicked = Some(c);
}
}
match clicked {
Some(Caption::Minimize) => ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)),
Some(Caption::Maximize) => toggle_maximize(ui.ctx()),
Some(Caption::Close) => {
let ctx = ui.ctx().clone();
app.request_quit(&ctx);
}
None => {}
}
}
fn caption_tip(c: Caption, max: bool) -> &'static str {
match c {
Caption::Minimize => crate::i18n::t("Minimize\0".trim_end_matches('\0')),
Caption::Maximize if max => crate::i18n::t("Restore\0".trim_end_matches('\0')),
Caption::Maximize => crate::i18n::t("Maximize\0".trim_end_matches('\0')),
Caption::Close => tl!("Close"),
}
}
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);
}
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);
}
}
}
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),
]
}
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};
struct Win {
ctx: egui::Context,
app: PdfCraftApp,
width: f32,
time: f64,
maximized: bool,
}
impl Win {
fn new(width: f32) -> Self {
let ctx = egui::Context::default();
let mut app = PdfCraftApp::new();
app.custom_titlebar = true;
crate::theme::apply(&ctx, app.theme);
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, 600.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 width = self.width;
let mut out = self.ctx.run_ui(input, |ui| {
let bar = Rect::from_min_size(Pos2::ZERO, vec2(width, HEIGHT));
drag_area(ui, bar);
caption_buttons(app, ui, bar);
resize_zones(ui);
});
out.textures_delta.clear();
out
}
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)
}
}
#[test]
fn caption_buttons_take_the_right_end_of_the_row() {
for width in [800.0, 1440.0] {
let mut w = Win::new(width);
w.frame(vec![]);
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!(close.height() >= 30.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
}
}
#[test]
fn minimize_maximize_and_close_follow_the_window_state() {
let mut w = Win::new(1000.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));
}
#[test]
fn empty_row_space_drags_and_double_click_maximizes() {
let mut w = Win::new(1440.0);
w.frame(vec![]);
let empty = pos2(700.0, 16.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:?}");
}
#[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, 598.0), South),
(pos2(2.0, 2.0), NorthWest),
(pos2(997.0, 3.0), NorthEast),
(pos2(3.0, 597.0), SouthWest),
(pos2(998.0, 598.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:?}");
}
}
+27 -1
View File
@@ -6,6 +6,7 @@
Produces, in $env:DIST (default: dist/release):
pdfcraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
pdfcraft-<version>-windows-<arch>-portable.zip pdfcraft.exe + pdfcraft-cli.exe + models\ + portable.txt
pdfcraft-cli-<version>-windows-<arch>.zip pdfcraft-cli.exe standalone
The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable
zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a
@@ -25,6 +26,7 @@ param(
)
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
Set-Location $Root
function Invoke-Native([string] $What, [scriptblock] $Block) {
Write-Output "==> $What"
@@ -56,6 +58,17 @@ if (-not $env:PDFCRAFT_BUILD_DATE) { $env:PDFCRAFT_BUILD_DATE = (Get-Date).ToUni
Write-Output "PdfCraft $Version for Windows $Arch ($Target)"
if (-not $SkipBuild) {
# Locate rc.exe if not in PATH
if (-not (Get-Command rc.exe -ErrorAction SilentlyContinue)) {
$kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
$foundRc = Get-ChildItem -Path $kits -Recurse -Filter rc.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\rc\.exe$' } |
Sort-Object FullName -Descending | Select-Object -First 1
if ($foundRc) {
$env:PATH = "$($foundRc.DirectoryName);$env:PATH"
}
}
# Static CRT: no VC++ redistributable needed. Scoped to the target so host build scripts and
# proc-macros are unaffected.
$flagVar = 'CARGO_TARGET_' + ($Target.ToUpper() -replace '-', '_') + '_RUSTFLAGS'
@@ -152,6 +165,19 @@ $Zip = Join-Path $Dist "pdfcraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
# ---- cli zip -----------------------------------------------------------------------------------
$CliPortable = Join-Path $TargetDir "windows-package\pdfcraft-cli-$Version-windows-$Arch"
Remove-Item -Recurse -Force $CliPortable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $CliPortable | Out-Null
Copy-Item (Join-Path $Stage 'pdfcraft-cli.exe') $CliPortable
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $CliPortable }
}
$CliZip = Join-Path $Dist "pdfcraft-cli-$Version-windows-$Arch.zip"
Remove-Item -Force $CliZip -ErrorAction SilentlyContinue
Compress-Archive -Path $CliPortable -DestinationPath $CliZip
# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run
# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead.
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
@@ -160,4 +186,4 @@ if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') {
} else {
Write-Output "skipping pdfcraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
}
Get-Item $Msi, $Zip | Format-Table Name, Length
Get-Item $Msi, $Zip, $CliZip | Format-Table Name, Length