A print dialog with a preview, CUPS and the print portal, a Windows backend, and ltk::dirs for the user's folders
Printing. An app that wanted to print had nothing in ltk to ask the user where and how, nor to reach a printer. `ltk::print` (`src/print/`) is a dialog built as `FileChooser` is: the app holds a `PrintDialog`, puts its `view()` in its own and hands its `PrintDialogMsg`s to `update()`, which returns a `PrintEvent`; `wrap` makes the app's message of the dialog's, so the printer list reaches it through the app's `ChannelSender`. It offers the destination, the printers CUPS knows with their state and "Save as PDF", the number of copies, all pages or a range such as `1-3, 5, 8-`, the paper (A4, Letter, Legal, A3, A5), the orientation, the margins, the scale, two-sided printing, colour and background graphics, the copies, sides and colour only for a printer. `PrintOptions` holds what was chosen and survives between dialogs, `parse_pages` and `format_pages` read and write the ranges. On a desktop the dialog is a card with ltk's `dialog()`, the preview on the left and the settings on the right; `compact()` makes it a whole page for a phone. `.printer( name )` and `.save_as_pdf()` choose where it starts. The preview. With `.preview( true )` the dialog asks the app for one with `PrintEvent::Preview( options )` whenever a setting that moves the pages changes, and shows what the app hands back to `PrintDialogMsg::previewed`, a `Preview` of some pages as RGBA with the document's total, one page at a time with buttons to move between them, greyed when colour is off. While one is being rendered a new request waits and is sent only if the layout changed meanwhile, so turning the scale quickly does not stack renders; until the first arrives the sheet's box shows "Preparing the preview…", kept to the sheet's width so it does not push the settings aside. CUPS. `print/cups.rs` loads `libcups.so.2` with `dlopen` at first use, so ltk neither links against it nor needs its headers, and a system without CUPS only loses its printers. It uses the destinations API, which also lists the network's driverless printers that have no queue: `printers()` with the default first, `print( printer, title, options, pdf )` sending the PDF as one job with `copies`, `media` as a PWG name, `sides` and `print-color-mode`, `jobs()`, `cancel()` and `set_default()`, which writes the user's `lpoptions`. `ltk::print::send`, `list_printers`, `jobs`, `cancel_job` and `set_default` run them on a thread and answer through the app's sender; `ltk::print::blocking` exposes them as they are, for code that has its own thread, such as a portal backend. `print/none.rs` stands in on Windows, Android and the browser, where only saving as PDF is offered. The portal. An app in a Flatpak cannot reach the CUPS socket. When `/.flatpak-info` exists, the dialog shows nothing of its own: it calls `org.freedesktop.portal.Print.PreparePrint` with the options as GTK's print settings and page setup, and the portal's own dialog answers; `Destination::Portal` then carries its token, and `send` passes the PDF to `Print` through a memfd. The Response signal is subscribed to on the request's path before the call, so a quick answer is not missed. `print/gtk.rs` translates `PrintOptions` to and from GTK's keys (`n-copies`, `page-ranges` counted from zero, `duplex`, `use-color`, `scale` in percent, the paper by its PWG name and size in millimetres), which a portal backend uses for the other direction. zbus 4, the one accesskit_unix already brings, joins the `wayland` feature for it. The dialog's strings are in ltk's seven locales. `file_chooser::view::icon` is shared with the dialog. Windows. The `windows` feature runs `ltk::run` in a Win32 window on Windows 10 1703 or later, with Windows's own frame, drawn through ANGLE's EGL and OpenGL ES when `libEGL.dll` and `libGLESv2.dll` sit beside the executable and in software through GDI otherwise (`src/platform/windows/`). It sizes the window by `App::window_size_hint` at the display's DPI and follows per-monitor DPI changes at fractional scales and the accessibility text size; it takes the mouse, the wheel, touch through `WM_POINTER`, and keys with Windows's repeat, dead keys and AltGr; it has the system clipboard, the cursor's shape, full screen, and moves and resizes begun from the app's own chrome, and keeps drawing while the window is moved or resized. Themes, fonts, keyboard layouts and MIME globs come from `ltk\` beside the executable, which `ltk::resource_dir` returns there by default; the font fallback chain and the monospace face are Windows's own, the system face stays the embedded Sora, and the file chooser offers each drive's root. A Win32 message loop cannot wait on calloop, so the runtime's timers and channels go through the queue the web backend already used: `build.rs` sets `cfg( queued_sources )` for both, and `AppData::dispatch_sources` moves from the web backend to `event_loop/sources.rs`. `Canvas::present_to` presents a GPU frame stretched over a window that a fractional scale left a pixel off it, and `EglContext::new_windows` and `create_native_surface` give ANGLE's display and an `HWND`'s surface. `App::save_state` is saved into `%LOCALAPPDATA%\<app_id>\` every 30 seconds, on exit and when the user's session ends, and restored as on Wayland: the part of the Wayland session runtime that does not speak the protocol, deciding why the app starts and handing it its bytes, is now `session_state::bootstrap`, which both backends call, with the file modes and the check for a running instance done each platform's way. `RunError::Window` reports a window Windows refused. `make windows-deps` and `make windows-check` typecheck the backend from Debian, compiling std from `rust-src`. `docs/windows.md` describes building, what ships beside the executable and what the backend does. ltk::dirs. `home`, `data_home`, `config_home`, `cache_home` and `state_home`: XDG's on Linux, with their usual fallbacks under the home folder, and on Windows `USERPROFILE`, `APPDATA` for data and settings and `LOCALAPPDATA` for the cache and state, an `XDG_*` variable that is set winning everywhere. An app finds its files there instead of reading `HOME` and the XDG variables itself, which on Windows are unset; ltk's own lookups of themes, keyboard layouts, MIME globs, the file chooser's folders and the session state now go through it, and `state_dir` takes the base it is given, its tests with it. Docs. The links to `WidgetId` in the crate's, `types`' and `App`'s documentation name the struct, since the `const fn` of the same name made them ambiguous to rustdoc. `README.md` names the other backends and `CHANGELOG.md` records the Windows backend and `ltk::dirs`. Not done. The print dialog has no CHANGELOG entry nor a page in `docs/widgets.md`. Under the portal there is no preview, since the portal's dialog gets the document only at the end. The desktop card's settings column does not scroll, so with a list open in a low window it may not fit. Tests cover the page ranges and the translation to GTK's keys, not the dialog, CUPS or the portal. On Windows there are no overlays, secondary surfaces or subsurfaces, no preedit for the input method and no accessibility.
This commit is contained in:
@@ -6,6 +6,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **A Windows backend: the `windows` feature** — `ltk::run` in a Win32 window on Windows 10 1703 or later, with Windows's own frame, drawn through ANGLE's EGL and OpenGL ES when `libEGL.dll` and `libGLESv2.dll` sit beside the executable and in software through GDI otherwise. It sizes the window by `App::window_size_hint` at the display's DPI and follows per-monitor DPI changes at fractional scales, the accessibility text size, the mouse, wheel, touch through `WM_POINTER`, keys with Windows's repeat, dead keys and AltGr, the system clipboard, the cursor's shape, full screen, and moves and resizes begun from the app's own chrome, and keeps drawing while the window is moved or resized. Themes, fonts, keyboard layouts and MIME globs come from `ltk\` beside the executable, the default resource bundle on Windows (`ltk::resource_dir`), and the user's home and data folders from `USERPROFILE` and `APPDATA` (`dirs.rs`); the font fallback chain is Windows's own, and the file chooser offers each drive's root. The Windows backend runs the runtime's timers and channels on the queue the web backend already used, since a Win32 message loop cannot wait on calloop; both now share it as `cfg( queued_sources )` and `AppData::dispatch_sources`. `Canvas::present_to` presents a GPU frame stretched over a window a fractional scale left a pixel off it. `App::save_state` is saved into `%LOCALAPPDATA%\<app_id>\` every 30 seconds, on exit and when the user's session ends, and restored as on Wayland: the part of the Wayland session runtime that does not speak the protocol, deciding why the app starts and handing it its bytes, is now `session_state::bootstrap`, which both backends call. `RunError::Window` reports a window Windows refused. `make windows-deps` and `make windows-check` typecheck the backend from Debian, compiling std from `rust-src`. `docs/windows.md` describes building, what ships beside the executable and what is not done yet: overlays and subsurfaces, the input method's preedit and accessibility.
|
||||||
|
- **`ltk::dirs`** — the user's folders, `home`, `data_home`, `config_home`, `cache_home` and `state_home`: XDG's on Linux, with their usual fallbacks under the home folder, and on Windows `USERPROFILE`, `APPDATA` for data and settings and `LOCALAPPDATA` for the cache and state, an `XDG_*` variable that is set winning everywhere. An app finds its files there instead of reading `HOME` and the XDG variables itself, which on Windows are unset; ltk's own lookups of themes, keyboard layouts, MIME globs, the file chooser's folders and the session state go through it.
|
||||||
- **`App::window_title`** — the main window's title, read when the window is made, which the title bar, the task switcher and the dock show; by default the `app_id`. A `ShellMode::Window` app was titled "ltk" on Wayland, whatever the app, with no way to name it but the deprecated `window_config`; in a browser its title was its `app_id`, and is now this.
|
- **`App::window_title`** — the main window's title, read when the window is made, which the title bar, the task switcher and the dock show; by default the `app_id`. A `ShellMode::Window` app was titled "ltk" on Wayland, whatever the app, with no way to name it but the deprecated `window_config`; in a browser its title was its `app_id`, and is now this.
|
||||||
- **`Text::fit` and `Text::valign`** — `fit()` draws a text at the largest size up to its own at which it fits the rect the layout gives it: on one line within the rect's width and height, or, with `wrap`, all its lines within its height. A text whose length varies, such as a number, a total or a title, in a box whose size the window sets, so neither overflows the box nor needs its size worked out from the window and the length of its content. Its preferred size is still the one at its own size. `valign( VAlign )` places the lines at the top (the default), the centre or the bottom of a rect taller than they are, as a fitted text's may be.
|
- **`Text::fit` and `Text::valign`** — `fit()` draws a text at the largest size up to its own at which it fits the rect the layout gives it: on one line within the rect's width and height, or, with `wrap`, all its lines within its height. A text whose length varies, such as a number, a total or a title, in a box whose size the window sets, so neither overflows the box nor needs its size worked out from the window and the length of its content. Its preferred size is still the one at its own size. `valign( VAlign )` places the lines at the top (the default), the centre or the bottom of a rect taller than they are, as a fitted text's may be.
|
||||||
- **A text field as an editor: `TextEdit::highlights`, `TextEdit::tab_text` and `App::take_text_command`** — `highlights( Arc<Vec<TextHighlight>> )` draws runs of a multiline value, byte ranges in order, in colours of their own, as a syntax highlighter marks keywords, strings and comments; what no run covers keeps the field's colour, and a run that does not fall on character boundaries is left out. `tab_text( text )` has Tab type `text`, such as four spaces, while the field has the focus, instead of moving the focus on; Shift+Tab still moves it back, and Ctrl+Tab still reaches the app. `App::take_text_command` returns once a `WidgetId` and a `TextCommand` for that field, `Copy`, `Cut`, `Paste`, `SelectAll` or `Select { start, end }`, which the runtime carries out after the next frame, focusing the field first: a field's cursor and selection are the runtime's, so an app's Edit menu or its search had no way to copy what was selected or to select what it found. `Select` takes its offsets to character boundaries within the value and scrolls a multiline field to them. `borderless` now also leaves out a multiline field's background and border, as an editor filling its window wants. Tests cover Tab with and without `tab_text`, selecting and copying, offsets inside a character, cutting, selecting all, and a run inside a character.
|
- **A text field as an editor: `TextEdit::highlights`, `TextEdit::tab_text` and `App::take_text_command`** — `highlights( Arc<Vec<TextHighlight>> )` draws runs of a multiline value, byte ranges in order, in colours of their own, as a syntax highlighter marks keywords, strings and comments; what no run covers keeps the field's colour, and a run that does not fall on character boundaries is left out. `tab_text( text )` has Tab type `text`, such as four spaces, while the field has the focus, instead of moving the focus on; Shift+Tab still moves it back, and Ctrl+Tab still reaches the app. `App::take_text_command` returns once a `WidgetId` and a `TextCommand` for that field, `Copy`, `Cut`, `Paste`, `SelectAll` or `Select { start, end }`, which the runtime carries out after the next frame, focusing the field first: a field's cursor and selection are the runtime's, so an app's Edit menu or its search had no way to copy what was selected or to select what it found. `Select` takes its offsets to character boundaries within the value and scrolls a multiline field to them. `borderless` now also leaves out a multiline field's background and border, as an editor filling its window wants. Tests cover Tab with and without `tab_text`, selecting and copying, offsets inside a character, cutting, selecting all, and a run inside a character.
|
||||||
|
|||||||
+31
@@ -34,6 +34,7 @@ wayland = [
|
|||||||
"dep:wayland-egl",
|
"dep:wayland-egl",
|
||||||
"dep:wayland-scanner",
|
"dep:wayland-scanner",
|
||||||
"dep:accesskit_unix",
|
"dep:accesskit_unix",
|
||||||
|
"dep:zbus",
|
||||||
]
|
]
|
||||||
# The Android backend: `ltk::run` in a NativeActivity, after `android_main`
|
# The Android backend: `ltk::run` in a NativeActivity, after `android_main`
|
||||||
# hands its `AndroidApp` to `ltk::set_android_app`. Builds only for Android
|
# hands its `AndroidApp` to `ltk::set_android_app`. Builds only for Android
|
||||||
@@ -53,6 +54,14 @@ web = [
|
|||||||
"dep:js-sys",
|
"dep:js-sys",
|
||||||
"dep:web-sys",
|
"dep:web-sys",
|
||||||
]
|
]
|
||||||
|
# The Windows backend: `ltk::run` in a Win32 window, drawn through ANGLE's
|
||||||
|
# EGL and GLES when `libEGL.dll` and `libGLESv2.dll` sit beside the
|
||||||
|
# executable, through GDI otherwise. Builds only for Windows targets and
|
||||||
|
# excludes the other backends, so a Windows build disables the default
|
||||||
|
# features.
|
||||||
|
windows = [
|
||||||
|
"dep:windows-sys",
|
||||||
|
]
|
||||||
# Exposes `ltk::test_support` — internal helpers (widget handlers, laid-out
|
# Exposes `ltk::test_support` — internal helpers (widget handlers, laid-out
|
||||||
# widget lookup, slider math) used only by ltk's own integration tests. Not
|
# widget lookup, slider math) used only by ltk's own integration tests. Not
|
||||||
# part of the stable public API; off by default so third-party builds never
|
# part of the stable public API; off by default so third-party builds never
|
||||||
@@ -92,6 +101,8 @@ glow = "0.17"
|
|||||||
# from `widget_rects` once per frame.
|
# from `widget_rects` once per frame.
|
||||||
accesskit = "0.17"
|
accesskit = "0.17"
|
||||||
accesskit_unix = { version = "0.13", optional = true }
|
accesskit_unix = { version = "0.13", optional = true }
|
||||||
|
# The print portal, for an app in a sandbox; the same zbus accesskit_unix brings.
|
||||||
|
zbus = { version = "4", default-features = false, features = ["async-io"], optional = true }
|
||||||
android-activity = { version = "0.6", features = ["native-activity"], optional = true }
|
android-activity = { version = "0.6", features = ["native-activity"], optional = true }
|
||||||
ndk = { version = "0.9", default-features = false, optional = true }
|
ndk = { version = "0.9", default-features = false, optional = true }
|
||||||
jni = { version = "0.21", optional = true }
|
jni = { version = "0.21", optional = true }
|
||||||
@@ -101,6 +112,26 @@ calloop = "0.14"
|
|||||||
khronos-egl = { version = "6", features = ["dynamic"] }
|
khronos-egl = { version = "6", features = ["dynamic"] }
|
||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { version = "0.61", optional = true, features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Globalization",
|
||||||
|
"Win32_Graphics_Dwm",
|
||||||
|
"Win32_Graphics_Gdi",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
|
"Win32_System_DataExchange",
|
||||||
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
"Win32_System_Ole",
|
||||||
|
"Win32_System_Registry",
|
||||||
|
"Win32_System_SystemServices",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_HiDpi",
|
||||||
|
"Win32_UI_Input_KeyboardAndMouse",
|
||||||
|
"Win32_UI_Input_Pointer",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
chrono = { version = "0.4", features = ["clock", "wasmbind"] }
|
chrono = { version = "0.4", features = ["clock", "wasmbind"] }
|
||||||
web-time = "1"
|
web-time = "1"
|
||||||
|
|||||||
@@ -38,11 +38,21 @@ WEB_FONTS ?= $(wildcard /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf
|
|||||||
WEB_TOOLS = target/tools
|
WEB_TOOLS = target/tools
|
||||||
WASM_BINDGEN ?= $(or $(wildcard $(WEB_TOOLS)/bin/wasm-bindgen),$(shell command -v wasm-bindgen 2>/dev/null))
|
WASM_BINDGEN ?= $(or $(wildcard $(WEB_TOOLS)/bin/wasm-bindgen),$(shell command -v wasm-bindgen 2>/dev/null))
|
||||||
|
|
||||||
|
# The Windows build, like the Android one, compiles std from Debian's rust-src
|
||||||
|
# with `-Zbuild-std`, since Debian packages no std for the target, and links
|
||||||
|
# with MinGW (gcc-mingw-w64-x86-64).
|
||||||
|
WINDOWS_TARGET ?= x86_64-pc-windows-gnu
|
||||||
|
WINDOWS_ENV = RUSTC_BOOTSTRAP=1 \
|
||||||
|
CC_$(subst -,_,$(WINDOWS_TARGET))=x86_64-w64-mingw32-gcc \
|
||||||
|
AR_$(subst -,_,$(WINDOWS_TARGET))=x86_64-w64-mingw32-ar \
|
||||||
|
CARGO_TARGET_$(shell echo $(WINDOWS_TARGET) | tr a-z- A-Z_)_LINKER=x86_64-w64-mingw32-gcc
|
||||||
|
WINDOWS_CARGO = -Zbuild-std=std,panic_abort --target $(WINDOWS_TARGET) --no-default-features --features windows
|
||||||
|
|
||||||
# Targets are recipes, not files. Listing them as PHONY tells make to
|
# Targets are recipes, not files. Listing them as PHONY tells make to
|
||||||
# ignore filesystem entries with the same name — without this `examples`
|
# ignore filesystem entries with the same name — without this `examples`
|
||||||
# silently no-ops because the `examples/` directory exists, and `doc`
|
# silently no-ops because the `examples/` directory exists, and `doc`
|
||||||
# would do the same once `target/doc` is around.
|
# would do the same once `target/doc` is around.
|
||||||
.PHONY: all deps android-deps test doctest-md stylecheck clippy audit doc install examples clean distclean android-check android-assets android-apk android-install web-deps web-check web-assets web-example web-serve
|
.PHONY: all deps android-deps test doctest-md stylecheck clippy audit doc install examples clean distclean android-check android-assets android-apk android-install windows-deps windows-check web-deps web-check web-assets web-example web-serve
|
||||||
|
|
||||||
all:
|
all:
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@@ -96,6 +106,14 @@ android-apk: android-assets
|
|||||||
android-install: android-apk
|
android-install: android-apk
|
||||||
adb install -r $(ANDROID_APK)
|
adb install -r $(ANDROID_APK)
|
||||||
|
|
||||||
|
# What the windows-* targets need, from Debian: std's sources for -Zbuild-std and MinGW.
|
||||||
|
windows-deps:
|
||||||
|
sudo apt-get install -y rust-src gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
|
# Typecheck the Windows backend.
|
||||||
|
windows-check:
|
||||||
|
$(WINDOWS_ENV) cargo check $(WINDOWS_CARGO)
|
||||||
|
|
||||||
# What the web-* targets need: Debian's std for wasm32, and wasm-bindgen-cli at the version ltk pins, which scripts/wasm-bindgen-cli.sh builds.
|
# What the web-* targets need: Debian's std for wasm32, and wasm-bindgen-cli at the version ltk pins, which scripts/wasm-bindgen-cli.sh builds.
|
||||||
web-deps:
|
web-deps:
|
||||||
sudo apt-get install -y libstd-rust-dev-wasm32 curl
|
sudo apt-get install -y libstd-rust-dev-wasm32 curl
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ to keep.
|
|||||||
|
|
||||||
- **Rust 1.85** or newer (the toolchain shipped with Debian stable; declared
|
- **Rust 1.85** or newer (the toolchain shipped with Debian stable; declared
|
||||||
as `rust-version` in `Cargo.toml`).
|
as `rust-version` in `Cargo.toml`).
|
||||||
- A running **Wayland** session — there is no X11 backend.
|
- A running **Wayland** session — there is no X11 backend. Android, the browser and Windows have backends of their own behind the `android`, `web` and `windows` features; `docs/windows.md` describes building for Windows.
|
||||||
- System headers for `libwayland`, `libegl` and `libxkbcommon` at compile
|
- System headers for `libwayland`, `libegl` and `libxkbcommon` at compile
|
||||||
time, and `rust-clippy` for `make clippy`. On Debian, `make deps`
|
time, and `rust-clippy` for `make clippy`. On Debian, `make deps`
|
||||||
installs them:
|
installs them:
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
// `cfg( backend )` marks the runtime behind `ltk::run`, which builds when some platform backend does. Each backend defines `ltk::run` and the native platform types, so only one may be enabled.
|
// `cfg( backend )` marks the runtime behind `ltk::run`, which builds when some platform backend does. Each backend defines `ltk::run` and the native platform types, so only one may be enabled. `cfg( queued_sources )` marks the targets whose run loop cannot wait on calloop, so the runtime's timers and channels go through its own queue.
|
||||||
fn main()
|
fn main()
|
||||||
{
|
{
|
||||||
println!( "cargo::rustc-check-cfg=cfg(backend)" );
|
println!( "cargo::rustc-check-cfg=cfg(backend)" );
|
||||||
|
println!( "cargo::rustc-check-cfg=cfg(queued_sources)" );
|
||||||
let wayland = std::env::var_os( "CARGO_FEATURE_WAYLAND" ).is_some();
|
let wayland = std::env::var_os( "CARGO_FEATURE_WAYLAND" ).is_some();
|
||||||
let android = std::env::var_os( "CARGO_FEATURE_ANDROID" ).is_some();
|
let android = std::env::var_os( "CARGO_FEATURE_ANDROID" ).is_some();
|
||||||
let web = std::env::var_os( "CARGO_FEATURE_WEB" ).is_some();
|
let web = std::env::var_os( "CARGO_FEATURE_WEB" ).is_some();
|
||||||
if [ wayland, android, web ].iter().filter( |on| **on ).count() > 1
|
let windows = std::env::var_os( "CARGO_FEATURE_WINDOWS" ).is_some();
|
||||||
|
if [ wayland, android, web, windows ].iter().filter( |on| **on ).count() > 1
|
||||||
{
|
{
|
||||||
panic!( "ltk: the `wayland`, `android` and `web` features are exclusive; build another backend with `default-features = false, features = [ \"android\" ]` or `[ \"web\" ]`" );
|
panic!( "ltk: the `wayland`, `android`, `web` and `windows` features are exclusive; build another backend with `default-features = false, features = [ \"android\" ]`, `[ \"web\" ]` or `[ \"windows\" ]`" );
|
||||||
}
|
}
|
||||||
if web && std::env::var( "CARGO_CFG_TARGET_ARCH" ).as_deref() != Ok( "wasm32" )
|
if web && std::env::var( "CARGO_CFG_TARGET_ARCH" ).as_deref() != Ok( "wasm32" )
|
||||||
{
|
{
|
||||||
panic!( "ltk: the `web` feature builds only for `wasm32-unknown-unknown`" );
|
panic!( "ltk: the `web` feature builds only for `wasm32-unknown-unknown`" );
|
||||||
}
|
}
|
||||||
if wayland || android || web
|
if windows && std::env::var( "CARGO_CFG_TARGET_OS" ).as_deref() != Ok( "windows" )
|
||||||
|
{
|
||||||
|
panic!( "ltk: the `windows` feature builds only for Windows targets, such as `x86_64-pc-windows-gnu` or `x86_64-pc-windows-msvc`" );
|
||||||
|
}
|
||||||
|
if wayland || android || web || windows
|
||||||
{
|
{
|
||||||
println!( "cargo::rustc-cfg=backend" );
|
println!( "cargo::rustc-cfg=backend" );
|
||||||
}
|
}
|
||||||
|
if std::env::var( "CARGO_CFG_TARGET_ARCH" ).as_deref() == Ok( "wasm32" ) || windows
|
||||||
|
{
|
||||||
|
println!( "cargo::rustc-cfg=queued_sources" );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Windows
|
||||||
|
|
||||||
|
The `windows` feature builds `ltk::run` for Windows 10 1703 or later: the app runs in a Win32 window with Windows's own frame, drawn through ANGLE's EGL and OpenGL ES when ANGLE is beside the executable, and in software through GDI otherwise. An app's code is the same as on Linux; only the build and what ships with the executable differ.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
The backends are exclusive, so a Windows build turns the default `wayland` feature off:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
ltk = { version = "0.3", default-features = false, features = [ "windows" ] }
|
||||||
|
|
||||||
|
[target.'cfg(not(windows))'.dependencies]
|
||||||
|
ltk = { version = "0.3" }
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, with the MSVC toolchain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --target x86_64-pc-windows-msvc
|
||||||
|
```
|
||||||
|
|
||||||
|
From Debian, which packages no std for Windows, std is compiled from `rust-src` with `-Zbuild-std`, as for Android, and MinGW links. `make windows-deps` installs both, and `make windows-check` typechecks ltk; a build of the same shape is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUSTC_BOOTSTRAP=1 \
|
||||||
|
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc \
|
||||||
|
cargo build --release -Zbuild-std=std,panic_abort --target x86_64-pc-windows-gnu --no-default-features --features windows
|
||||||
|
```
|
||||||
|
|
||||||
|
With rustup instead, `rustup target add x86_64-pc-windows-gnu` installs the target's std and the plain `cargo build --target x86_64-pc-windows-gnu` works.
|
||||||
|
|
||||||
|
A GUI app should not open a console window beside its own; `#![windows_subsystem = "windows"]` at the top of its `main.rs` does that, at the cost of losing ltk's `[ltk]` lines on standard error, so leave it out while debugging.
|
||||||
|
|
||||||
|
## What ships beside the executable
|
||||||
|
|
||||||
|
```
|
||||||
|
app.exe
|
||||||
|
libEGL.dll ANGLE, optional
|
||||||
|
libGLESv2.dll ANGLE, optional
|
||||||
|
ltk\
|
||||||
|
themes\default\ the theme, as ltk-theme-default installs it on Debian
|
||||||
|
fonts\ fonts the theme names, found by file name
|
||||||
|
keyboards\ on-screen keyboard layouts, if the app uses them
|
||||||
|
mime\globs2 shared-mime-info's globs, for ltk::files' MIME types
|
||||||
|
```
|
||||||
|
|
||||||
|
`ltk\` is the app's resource bundle ([`ltk::resource_dir`]): themes, fonts, keyboard layouts and MIME globs are looked up there as on Android they are in the APK, and a font a theme names by a Debian path, such as `/usr/share/fonts/opentype/sora/Sora-Regular.otf`, is found in `ltk\fonts\` by its file name. An app that keeps its resources elsewhere calls `ltk::set_resource_dir` before its theme is first used. `LTK_THEMES_DIR` and `LTK_KEYBOARDS_DIR` still take precedence, and the user's own themes go under `%APPDATA%\ltk\themes\`.
|
||||||
|
|
||||||
|
ANGLE's two DLLs come with Chromium-based browsers and with the ANGLE releases built for Windows; any recent build that exposes `EGL_DEFAULT_DISPLAY` on Direct3D 11 works. Without them, or with `LTK_FORCE_SOFTWARE=1`, the window is drawn in software, and `[ltk] render backend:` on standard error says which path was taken.
|
||||||
|
|
||||||
|
The executable's icon, which the window, the taskbar and Alt+Tab show, is its icon resource 1, as the `embed-resource` and `winres` crates put it.
|
||||||
|
|
||||||
|
## What the backend does
|
||||||
|
|
||||||
|
| Area | On Windows |
|
||||||
|
| --- | --- |
|
||||||
|
| Window | One main window with Windows's frame, titled by `App::window_title`, its client area sized by `App::window_size_hint` (800 × 600 logical pixels by default) at the display's DPI, fixed when `App::window_resizable` says so. `App::fullscreen` fills the display without the frame. `WindowButton` and the app's own chrome move, resize and minimise it through Windows's modal loop. The view keeps drawing while the window is moved or resized. |
|
||||||
|
| Scale | Per-monitor DPI awareness: the scale is the display's DPI over 96, fractional at 125 % or 150 %, and follows the window from one display to another. The accessibility setting for text size (`TextScaleFactor`) is followed as GNOME's text scale is on Linux. |
|
||||||
|
| Input | Mouse, wheel and touchpad scrolling, the back side button, touch through `WM_POINTER` (several fingers), pens as a mouse, and keys with Windows's own repeat, dead keys and AltGr. The input method's committed text arrives as typed characters. |
|
||||||
|
| Clipboard | The system clipboard, read again whenever it changes. There is no primary selection; the middle button pastes what was selected within the app. |
|
||||||
|
| Fonts | ltk's embedded Sora as the face, as on Android; the fallback chain for other scripts and emoji is Segoe UI, Nirmala UI, Leelawadee UI, Segoe UI Symbol, Segoe UI Emoji, Malgun Gothic and Microsoft YaHei from `C:\Windows\Fonts`. Consolas is the monospace face. |
|
||||||
|
| Files | `ltk::files` and the file chooser on the file system; the chooser offers the user's profile folders and the root of each drive. |
|
||||||
|
| Locale | `LANG` when set, otherwise the user's locale (`es-ES` as `es_ES`), for the on-screen keyboard. |
|
||||||
|
| Session state | `App::save_state` is saved into `%LOCALAPPDATA%\<app_id>\` every 30 seconds, on exit and when the user signs out or shuts down, and handed back to `App::restore_state` after a crash, or on every launch when `App::restore_on_launch` says so, as on Wayland. |
|
||||||
|
| Folders | `ltk::dirs` gives the user's folders: `APPDATA` for data and settings, `LOCALAPPDATA` for the cache and state, `USERPROFILE` as the home. |
|
||||||
|
|
||||||
|
Not done yet: overlays, secondary surfaces and subsurfaces are not created; the input method has no preedit or candidate window placement; accessibility is not exposed (AccessKit's Windows adapter would do it); and the built-in on-screen keyboard is off, as on any desktop, while Windows's touch keyboard does not come up for ltk's fields on its own, so a tablet without a keyboard cannot type into them yet.
|
||||||
@@ -61,3 +61,46 @@ file_chooser:
|
|||||||
sort_size: "Größe"
|
sort_size: "Größe"
|
||||||
ascending: "Aufsteigend"
|
ascending: "Aufsteigend"
|
||||||
descending: "Absteigend"
|
descending: "Absteigend"
|
||||||
|
print:
|
||||||
|
title: "Drucken"
|
||||||
|
cancel: "Abbrechen"
|
||||||
|
print: "Drucken"
|
||||||
|
save: "Speichern"
|
||||||
|
destination: "Ziel"
|
||||||
|
pdf: "Als PDF speichern"
|
||||||
|
searching: "Drucker werden gesucht…"
|
||||||
|
copies: "Kopien"
|
||||||
|
pages: "Seiten"
|
||||||
|
all_pages: "Alle"
|
||||||
|
some_pages: "Benutzerdefiniert"
|
||||||
|
pages_hint: "Zum Beispiel 1-3, 5"
|
||||||
|
pages_bad: "Gib Seitenzahlen und Bereiche durch Kommas getrennt ein, etwa 1-3, 5."
|
||||||
|
paper: "Papier"
|
||||||
|
orientation: "Ausrichtung"
|
||||||
|
portrait: "Hochformat"
|
||||||
|
landscape: "Querformat"
|
||||||
|
margins: "Ränder"
|
||||||
|
margins_default: "Standard"
|
||||||
|
margins_narrow: "Schmal"
|
||||||
|
margins_wide: "Breit"
|
||||||
|
margins_none: "Keine"
|
||||||
|
scale: "Skalierung"
|
||||||
|
sides: "Beidseitig"
|
||||||
|
one_side: "Aus"
|
||||||
|
long_edge: "Lange Kante"
|
||||||
|
short_edge: "Kurze Kante"
|
||||||
|
color: "Farbe"
|
||||||
|
backgrounds: "Hintergrundgrafiken"
|
||||||
|
preparing: "Vorschau wird erstellt…"
|
||||||
|
page_of: "Seite %{page} von %{count}"
|
||||||
|
total: "%{count} im Dokument"
|
||||||
|
previous: "Vorherige Seite"
|
||||||
|
next: "Nächste Seite"
|
||||||
|
less: "Weniger"
|
||||||
|
more: "Mehr"
|
||||||
|
ready: "Bereit"
|
||||||
|
busy: "Druckt"
|
||||||
|
stopped: "Angehalten"
|
||||||
|
rejecting: "Nimmt keine Aufträge an"
|
||||||
|
no_cups: "Drucken ist auf diesem System nicht verfügbar."
|
||||||
|
failed: "Der Drucker hat den Auftrag nicht angenommen."
|
||||||
|
|||||||
@@ -61,3 +61,46 @@ file_chooser:
|
|||||||
sort_size: "Size"
|
sort_size: "Size"
|
||||||
ascending: "Ascending"
|
ascending: "Ascending"
|
||||||
descending: "Descending"
|
descending: "Descending"
|
||||||
|
print:
|
||||||
|
title: "Print"
|
||||||
|
cancel: "Cancel"
|
||||||
|
print: "Print"
|
||||||
|
save: "Save"
|
||||||
|
destination: "Destination"
|
||||||
|
pdf: "Save as PDF"
|
||||||
|
searching: "Looking for printers…"
|
||||||
|
copies: "Copies"
|
||||||
|
pages: "Pages"
|
||||||
|
all_pages: "All"
|
||||||
|
some_pages: "Custom"
|
||||||
|
pages_hint: "For example 1-3, 5"
|
||||||
|
pages_bad: "Write page numbers and ranges separated by commas, such as 1-3, 5."
|
||||||
|
paper: "Paper"
|
||||||
|
orientation: "Orientation"
|
||||||
|
portrait: "Portrait"
|
||||||
|
landscape: "Landscape"
|
||||||
|
margins: "Margins"
|
||||||
|
margins_default: "Default"
|
||||||
|
margins_narrow: "Narrow"
|
||||||
|
margins_wide: "Wide"
|
||||||
|
margins_none: "None"
|
||||||
|
scale: "Scale"
|
||||||
|
sides: "Two-sided"
|
||||||
|
one_side: "Off"
|
||||||
|
long_edge: "Long edge"
|
||||||
|
short_edge: "Short edge"
|
||||||
|
color: "Colour"
|
||||||
|
backgrounds: "Background graphics"
|
||||||
|
preparing: "Preparing the preview…"
|
||||||
|
page_of: "Page %{page} of %{count}"
|
||||||
|
total: "%{count} in the document"
|
||||||
|
previous: "Previous page"
|
||||||
|
next: "Next page"
|
||||||
|
less: "Less"
|
||||||
|
more: "More"
|
||||||
|
ready: "Ready"
|
||||||
|
busy: "Printing"
|
||||||
|
stopped: "Stopped"
|
||||||
|
rejecting: "Not accepting jobs"
|
||||||
|
no_cups: "Printing is not available on this system."
|
||||||
|
failed: "The printer did not take the job."
|
||||||
|
|||||||
@@ -61,3 +61,46 @@ file_chooser:
|
|||||||
sort_size: "Tamaño"
|
sort_size: "Tamaño"
|
||||||
ascending: "Ascendente"
|
ascending: "Ascendente"
|
||||||
descending: "Descendente"
|
descending: "Descendente"
|
||||||
|
print:
|
||||||
|
title: "Imprimir"
|
||||||
|
cancel: "Cancelar"
|
||||||
|
print: "Imprimir"
|
||||||
|
save: "Guardar"
|
||||||
|
destination: "Destino"
|
||||||
|
pdf: "Guardar como PDF"
|
||||||
|
searching: "Buscando impresoras…"
|
||||||
|
copies: "Copias"
|
||||||
|
pages: "Páginas"
|
||||||
|
all_pages: "Todas"
|
||||||
|
some_pages: "Personalizadas"
|
||||||
|
pages_hint: "Por ejemplo 1-3, 5"
|
||||||
|
pages_bad: "Escribe números de página e intervalos separados por comas, como 1-3, 5."
|
||||||
|
paper: "Papel"
|
||||||
|
orientation: "Orientación"
|
||||||
|
portrait: "Vertical"
|
||||||
|
landscape: "Horizontal"
|
||||||
|
margins: "Márgenes"
|
||||||
|
margins_default: "Predeterminados"
|
||||||
|
margins_narrow: "Estrechos"
|
||||||
|
margins_wide: "Anchos"
|
||||||
|
margins_none: "Ninguno"
|
||||||
|
scale: "Escala"
|
||||||
|
sides: "Doble cara"
|
||||||
|
one_side: "No"
|
||||||
|
long_edge: "Borde largo"
|
||||||
|
short_edge: "Borde corto"
|
||||||
|
color: "Color"
|
||||||
|
backgrounds: "Gráficos de fondo"
|
||||||
|
preparing: "Preparando la vista previa…"
|
||||||
|
page_of: "Página %{page} de %{count}"
|
||||||
|
total: "%{count} en el documento"
|
||||||
|
previous: "Página anterior"
|
||||||
|
next: "Página siguiente"
|
||||||
|
less: "Menos"
|
||||||
|
more: "Más"
|
||||||
|
ready: "Preparada"
|
||||||
|
busy: "Imprimiendo"
|
||||||
|
stopped: "Detenida"
|
||||||
|
rejecting: "No acepta trabajos"
|
||||||
|
no_cups: "La impresión no está disponible en este sistema."
|
||||||
|
failed: "La impresora no ha aceptado el trabajo."
|
||||||
|
|||||||
@@ -61,3 +61,46 @@ file_chooser:
|
|||||||
sort_size: "Taille"
|
sort_size: "Taille"
|
||||||
ascending: "Croissant"
|
ascending: "Croissant"
|
||||||
descending: "Décroissant"
|
descending: "Décroissant"
|
||||||
|
print:
|
||||||
|
title: "Imprimer"
|
||||||
|
cancel: "Annuler"
|
||||||
|
print: "Imprimer"
|
||||||
|
save: "Enregistrer"
|
||||||
|
destination: "Destination"
|
||||||
|
pdf: "Enregistrer en PDF"
|
||||||
|
searching: "Recherche des imprimantes…"
|
||||||
|
copies: "Copies"
|
||||||
|
pages: "Pages"
|
||||||
|
all_pages: "Toutes"
|
||||||
|
some_pages: "Personnalisées"
|
||||||
|
pages_hint: "Par exemple 1-3, 5"
|
||||||
|
pages_bad: "Saisissez des numéros de page et des plages séparés par des virgules, comme 1-3, 5."
|
||||||
|
paper: "Papier"
|
||||||
|
orientation: "Orientation"
|
||||||
|
portrait: "Portrait"
|
||||||
|
landscape: "Paysage"
|
||||||
|
margins: "Marges"
|
||||||
|
margins_default: "Par défaut"
|
||||||
|
margins_narrow: "Étroites"
|
||||||
|
margins_wide: "Larges"
|
||||||
|
margins_none: "Aucune"
|
||||||
|
scale: "Échelle"
|
||||||
|
sides: "Recto verso"
|
||||||
|
one_side: "Non"
|
||||||
|
long_edge: "Bord long"
|
||||||
|
short_edge: "Bord court"
|
||||||
|
color: "Couleur"
|
||||||
|
backgrounds: "Graphiques d’arrière-plan"
|
||||||
|
preparing: "Préparation de l’aperçu…"
|
||||||
|
page_of: "Page %{page} sur %{count}"
|
||||||
|
total: "%{count} dans le document"
|
||||||
|
previous: "Page précédente"
|
||||||
|
next: "Page suivante"
|
||||||
|
less: "Moins"
|
||||||
|
more: "Plus"
|
||||||
|
ready: "Prête"
|
||||||
|
busy: "Impression"
|
||||||
|
stopped: "Arrêtée"
|
||||||
|
rejecting: "N’accepte pas de travaux"
|
||||||
|
no_cups: "L’impression n’est pas disponible sur ce système."
|
||||||
|
failed: "L’imprimante n’a pas accepté le travail."
|
||||||
|
|||||||
@@ -61,3 +61,46 @@ file_chooser:
|
|||||||
sort_size: "Dimensione"
|
sort_size: "Dimensione"
|
||||||
ascending: "Crescente"
|
ascending: "Crescente"
|
||||||
descending: "Decrescente"
|
descending: "Decrescente"
|
||||||
|
print:
|
||||||
|
title: "Stampa"
|
||||||
|
cancel: "Annulla"
|
||||||
|
print: "Stampa"
|
||||||
|
save: "Salva"
|
||||||
|
destination: "Destinazione"
|
||||||
|
pdf: "Salva come PDF"
|
||||||
|
searching: "Ricerca delle stampanti…"
|
||||||
|
copies: "Copie"
|
||||||
|
pages: "Pagine"
|
||||||
|
all_pages: "Tutte"
|
||||||
|
some_pages: "Personalizzate"
|
||||||
|
pages_hint: "Per esempio 1-3, 5"
|
||||||
|
pages_bad: "Scrivi numeri di pagina e intervalli separati da virgole, come 1-3, 5."
|
||||||
|
paper: "Carta"
|
||||||
|
orientation: "Orientamento"
|
||||||
|
portrait: "Verticale"
|
||||||
|
landscape: "Orizzontale"
|
||||||
|
margins: "Margini"
|
||||||
|
margins_default: "Predefiniti"
|
||||||
|
margins_narrow: "Stretti"
|
||||||
|
margins_wide: "Larghi"
|
||||||
|
margins_none: "Nessuno"
|
||||||
|
scale: "Scala"
|
||||||
|
sides: "Fronte retro"
|
||||||
|
one_side: "No"
|
||||||
|
long_edge: "Lato lungo"
|
||||||
|
short_edge: "Lato corto"
|
||||||
|
color: "Colore"
|
||||||
|
backgrounds: "Grafica di sfondo"
|
||||||
|
preparing: "Preparazione dell’anteprima…"
|
||||||
|
page_of: "Pagina %{page} di %{count}"
|
||||||
|
total: "%{count} nel documento"
|
||||||
|
previous: "Pagina precedente"
|
||||||
|
next: "Pagina successiva"
|
||||||
|
less: "Meno"
|
||||||
|
more: "Più"
|
||||||
|
ready: "Pronta"
|
||||||
|
busy: "In stampa"
|
||||||
|
stopped: "Ferma"
|
||||||
|
rejecting: "Non accetta lavori"
|
||||||
|
no_cups: "La stampa non è disponibile su questo sistema."
|
||||||
|
failed: "La stampante non ha accettato il lavoro."
|
||||||
|
|||||||
@@ -61,3 +61,46 @@ file_chooser:
|
|||||||
sort_size: "Tamanho"
|
sort_size: "Tamanho"
|
||||||
ascending: "Ascendente"
|
ascending: "Ascendente"
|
||||||
descending: "Descendente"
|
descending: "Descendente"
|
||||||
|
print:
|
||||||
|
title: "Imprimir"
|
||||||
|
cancel: "Cancelar"
|
||||||
|
print: "Imprimir"
|
||||||
|
save: "Guardar"
|
||||||
|
destination: "Destino"
|
||||||
|
pdf: "Guardar como PDF"
|
||||||
|
searching: "A procurar impressoras…"
|
||||||
|
copies: "Cópias"
|
||||||
|
pages: "Páginas"
|
||||||
|
all_pages: "Todas"
|
||||||
|
some_pages: "Personalizadas"
|
||||||
|
pages_hint: "Por exemplo 1-3, 5"
|
||||||
|
pages_bad: "Escreva números de página e intervalos separados por vírgulas, como 1-3, 5."
|
||||||
|
paper: "Papel"
|
||||||
|
orientation: "Orientação"
|
||||||
|
portrait: "Vertical"
|
||||||
|
landscape: "Horizontal"
|
||||||
|
margins: "Margens"
|
||||||
|
margins_default: "Predefinidas"
|
||||||
|
margins_narrow: "Estreitas"
|
||||||
|
margins_wide: "Largas"
|
||||||
|
margins_none: "Nenhuma"
|
||||||
|
scale: "Escala"
|
||||||
|
sides: "Frente e verso"
|
||||||
|
one_side: "Não"
|
||||||
|
long_edge: "Margem longa"
|
||||||
|
short_edge: "Margem curta"
|
||||||
|
color: "Cor"
|
||||||
|
backgrounds: "Gráficos de fundo"
|
||||||
|
preparing: "A preparar a pré-visualização…"
|
||||||
|
page_of: "Página %{page} de %{count}"
|
||||||
|
total: "%{count} no documento"
|
||||||
|
previous: "Página anterior"
|
||||||
|
next: "Página seguinte"
|
||||||
|
less: "Menos"
|
||||||
|
more: "Mais"
|
||||||
|
ready: "Pronta"
|
||||||
|
busy: "A imprimir"
|
||||||
|
stopped: "Parada"
|
||||||
|
rejecting: "Não aceita trabalhos"
|
||||||
|
no_cups: "A impressão não está disponível neste sistema."
|
||||||
|
failed: "A impressora não aceitou o trabalho."
|
||||||
|
|||||||
@@ -61,3 +61,46 @@ file_chooser:
|
|||||||
sort_size: "Tamanho"
|
sort_size: "Tamanho"
|
||||||
ascending: "Crescente"
|
ascending: "Crescente"
|
||||||
descending: "Decrescente"
|
descending: "Decrescente"
|
||||||
|
print:
|
||||||
|
title: "Imprimir"
|
||||||
|
cancel: "Cancelar"
|
||||||
|
print: "Imprimir"
|
||||||
|
save: "Salvar"
|
||||||
|
destination: "Destino"
|
||||||
|
pdf: "Salvar como PDF"
|
||||||
|
searching: "Procurando impressoras…"
|
||||||
|
copies: "Cópias"
|
||||||
|
pages: "Páginas"
|
||||||
|
all_pages: "Todas"
|
||||||
|
some_pages: "Personalizadas"
|
||||||
|
pages_hint: "Por exemplo 1-3, 5"
|
||||||
|
pages_bad: "Digite números de página e intervalos separados por vírgulas, como 1-3, 5."
|
||||||
|
paper: "Papel"
|
||||||
|
orientation: "Orientação"
|
||||||
|
portrait: "Vertical"
|
||||||
|
landscape: "Horizontal"
|
||||||
|
margins: "Margens"
|
||||||
|
margins_default: "Padrão"
|
||||||
|
margins_narrow: "Estreitas"
|
||||||
|
margins_wide: "Largas"
|
||||||
|
margins_none: "Nenhuma"
|
||||||
|
scale: "Escala"
|
||||||
|
sides: "Frente e verso"
|
||||||
|
one_side: "Não"
|
||||||
|
long_edge: "Margem longa"
|
||||||
|
short_edge: "Margem curta"
|
||||||
|
color: "Cor"
|
||||||
|
backgrounds: "Gráficos de fundo"
|
||||||
|
preparing: "Preparando a visualização…"
|
||||||
|
page_of: "Página %{page} de %{count}"
|
||||||
|
total: "%{count} no documento"
|
||||||
|
previous: "Página anterior"
|
||||||
|
next: "Página seguinte"
|
||||||
|
less: "Menos"
|
||||||
|
more: "Mais"
|
||||||
|
ready: "Pronta"
|
||||||
|
busy: "Imprimindo"
|
||||||
|
stopped: "Parada"
|
||||||
|
rejecting: "Não aceita trabalhos"
|
||||||
|
no_cups: "A impressão não está disponível neste sistema."
|
||||||
|
failed: "A impressora não aceitou o trabalho."
|
||||||
|
|||||||
+3
-3
@@ -275,7 +275,7 @@ pub struct OverlaySpec<Message: Clone>
|
|||||||
|
|
||||||
/// When set, the overlay is rendered as a Wayland **xdg-popup**
|
/// When set, the overlay is rendered as a Wayland **xdg-popup**
|
||||||
/// anchored to the rect of the widget tagged with this
|
/// anchored to the rect of the widget tagged with this
|
||||||
/// [`crate::types::WidgetId`] in the previous frame's layout — the
|
/// [`WidgetId`](struct@crate::types::WidgetId) in the previous frame's layout — the
|
||||||
/// standard mechanism for combo / context-menu / tooltip popups in
|
/// standard mechanism for combo / context-menu / tooltip popups in
|
||||||
/// `xdg-shell` applications. The compositor positions the popup
|
/// `xdg-shell` applications. The compositor positions the popup
|
||||||
/// adjacent to the anchor and is allowed to flip it (drop-up
|
/// adjacent to the anchor and is allowed to flip it (drop-up
|
||||||
@@ -1013,7 +1013,7 @@ pub trait App: 'static
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `Some(id)` once to focus the widget with that [`WidgetId`](crate::types::WidgetId).
|
/// Return `Some(id)` once to focus the widget with that [`WidgetId`](struct@crate::types::WidgetId).
|
||||||
/// The app must return `None` after the first call.
|
/// The app must return `None` after the first call.
|
||||||
///
|
///
|
||||||
/// The runtime waits a few drawn frames for the widget to appear, so a
|
/// The runtime waits a few drawn frames for the widget to appear, so a
|
||||||
@@ -1038,7 +1038,7 @@ pub trait App: 'static
|
|||||||
fn on_paste( &mut self, _text: &str ) {}
|
fn on_paste( &mut self, _text: &str ) {}
|
||||||
|
|
||||||
/// Return `Some( ( id, command ) )` once to have the text field with
|
/// Return `Some( ( id, command ) )` once to have the text field with
|
||||||
/// that [`WidgetId`](crate::types::WidgetId) take the focus and do
|
/// that [`WidgetId`](struct@crate::types::WidgetId) take the focus and do
|
||||||
/// `command`, after the frame that lays it out is drawn. A field that
|
/// `command`, after the frame that lays it out is drawn. A field that
|
||||||
/// is not on screen then drops the command.
|
/// is not on screen then drops the command.
|
||||||
fn take_text_command( &mut self ) -> Option<( crate::types::WidgetId, TextCommand )> { None }
|
fn take_text_command( &mut self ) -> Option<( crate::types::WidgetId, TextCommand )> { None }
|
||||||
|
|||||||
+7
-7
@@ -9,9 +9,9 @@ use std::sync::mpsc::SendError;
|
|||||||
/// Delivers messages into the run loop from any thread and wakes it. Given to the app by [`crate::App::set_channel_sender`].
|
/// Delivers messages into the run loop from any thread and wakes it. Given to the app by [`crate::App::set_channel_sender`].
|
||||||
pub struct ChannelSender<T>
|
pub struct ChannelSender<T>
|
||||||
{
|
{
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( queued_sources ) ) ]
|
||||||
inner: calloop::channel::Sender<T>,
|
inner: calloop::channel::Sender<T>,
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
inner: std::sync::mpsc::Sender<T>,
|
inner: std::sync::mpsc::Sender<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,12 +21,12 @@ impl<T> ChannelSender<T>
|
|||||||
pub fn send( &self, msg: T ) -> Result<(), SendError<T>>
|
pub fn send( &self, msg: T ) -> Result<(), SendError<T>>
|
||||||
{
|
{
|
||||||
self.inner.send( msg )?;
|
self.inner.send( msg )?;
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
wake();
|
wake();
|
||||||
Ok( () )
|
Ok( () )
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( queued_sources ) ) ]
|
||||||
pub( crate ) fn from_calloop( inner: calloop::channel::Sender<T> ) -> Self
|
pub( crate ) fn from_calloop( inner: calloop::channel::Sender<T> ) -> Self
|
||||||
{
|
{
|
||||||
Self { inner }
|
Self { inner }
|
||||||
@@ -49,11 +49,11 @@ impl<T> fmt::Debug for ChannelSender<T>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
static WAKE: std::sync::OnceLock<fn()> = std::sync::OnceLock::new();
|
static WAKE: std::sync::OnceLock<fn()> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
/// A sender and the receiver the run loop drains; `wake` is called after every message sent.
|
/// A sender and the receiver the run loop drains; `wake` is called after every message sent.
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
pub( crate ) fn channel<T>( wake: fn() ) -> ( ChannelSender<T>, std::sync::mpsc::Receiver<T> )
|
pub( crate ) fn channel<T>( wake: fn() ) -> ( ChannelSender<T>, std::sync::mpsc::Receiver<T> )
|
||||||
{
|
{
|
||||||
let _ = WAKE.set( wake );
|
let _ = WAKE.set( wake );
|
||||||
@@ -61,7 +61,7 @@ pub( crate ) fn channel<T>( wake: fn() ) -> ( ChannelSender<T>, std::sync::mpsc:
|
|||||||
( ChannelSender { inner }, rx )
|
( ChannelSender { inner }, rx )
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
fn wake()
|
fn wake()
|
||||||
{
|
{
|
||||||
if let Some( wake ) = WAKE.get() { wake(); }
|
if let Some( wake ) = WAKE.get() { wake(); }
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! The user's folders: XDG's on Linux, the profile's on Windows. An app keeps its files in a folder of its own under one of these, named after itself.
|
||||||
|
//!
|
||||||
|
//! | Function | Linux | Windows |
|
||||||
|
//! | --- | --- | --- |
|
||||||
|
//! | [`home`] | `HOME` | `USERPROFILE` |
|
||||||
|
//! | [`data_home`] | `XDG_DATA_HOME`, else `~/.local/share` | `APPDATA`, roaming with the user |
|
||||||
|
//! | [`config_home`] | `XDG_CONFIG_HOME`, else `~/.config` | `APPDATA` |
|
||||||
|
//! | [`cache_home`] | `XDG_CACHE_HOME`, else `~/.cache` | `LOCALAPPDATA`, kept on this machine |
|
||||||
|
//! | [`state_home`] | `XDG_STATE_HOME`, else `~/.local/state` | `LOCALAPPDATA` |
|
||||||
|
//!
|
||||||
|
//! An `XDG_*` variable that is set wins on Windows too. Each is `None` when neither the variable nor what it falls back to is set.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn var( name: &str ) -> Option<PathBuf>
|
||||||
|
{
|
||||||
|
std::env::var_os( name ).filter( |v| !v.is_empty() ).map( PathBuf::from )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `xdg`, else `windows` on Windows and `~/<unix>` elsewhere.
|
||||||
|
fn base( xdg: &str, windows: &str, unix: &str ) -> Option<PathBuf>
|
||||||
|
{
|
||||||
|
let fallback = if cfg!( windows ) { var( windows ) } else { home().map( |home| home.join( unix ) ) };
|
||||||
|
var( xdg ).or( fallback )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's home folder: `HOME`, or on Windows, where it is seldom set, `USERPROFILE`.
|
||||||
|
pub fn home() -> Option<PathBuf>
|
||||||
|
{
|
||||||
|
if cfg!( windows ) { var( "HOME" ).or_else( || var( "USERPROFILE" ) ) } else { var( "HOME" ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the user's own data goes, such as bookmarks or history.
|
||||||
|
pub fn data_home() -> Option<PathBuf>
|
||||||
|
{
|
||||||
|
base( "XDG_DATA_HOME", "APPDATA", ".local/share" )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the user's settings go.
|
||||||
|
pub fn config_home() -> Option<PathBuf>
|
||||||
|
{
|
||||||
|
base( "XDG_CONFIG_HOME", "APPDATA", ".config" )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where what can be rebuilt goes, such as downloaded pages or thumbnails.
|
||||||
|
pub fn cache_home() -> Option<PathBuf>
|
||||||
|
{
|
||||||
|
base( "XDG_CACHE_HOME", "LOCALAPPDATA", ".cache" )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where state that should outlive a run but not follow the user goes, such as logs or a window's last place.
|
||||||
|
pub fn state_home() -> Option<PathBuf>
|
||||||
|
{
|
||||||
|
base( "XDG_STATE_HOME", "LOCALAPPDATA", ".local/state" )
|
||||||
|
}
|
||||||
+3
-1
@@ -40,7 +40,9 @@ pub( crate ) mod layout;
|
|||||||
pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects };
|
pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects };
|
||||||
pub( crate ) use layout::layout_and_draw;
|
pub( crate ) use layout::layout_and_draw;
|
||||||
#[ cfg( backend ) ]
|
#[ cfg( backend ) ]
|
||||||
pub( crate ) use paint::{ fit_canvas, paint_detached, paint_full, paint_partial };
|
pub( crate ) use paint::{ fit_canvas, paint_full, paint_partial };
|
||||||
|
#[ cfg( any( feature = "wayland", feature = "web" ) ) ]
|
||||||
|
pub( crate ) use paint::paint_detached;
|
||||||
|
|
||||||
/// Per-frame draw state threaded through [`layout_and_draw`]. Captures
|
/// Per-frame draw state threaded through [`layout_and_draw`]. Captures
|
||||||
/// the interaction snapshot (focus / hover / pressed), scratch space
|
/// the interaction snapshot (focus / hover / pressed), scratch space
|
||||||
|
|||||||
+29
-26
@@ -15,7 +15,7 @@
|
|||||||
//! must follow the surface's size; on Android an `ANativeWindow` the backend
|
//! must follow the surface's size; on Android an `ANativeWindow` the backend
|
||||||
//! holds, whose buffers follow `ANativeWindow_setBuffersGeometry`.
|
//! holds, whose buffers follow `ANativeWindow_setBuffersGeometry`.
|
||||||
//!
|
//!
|
||||||
//! Both need a backend feature, `wayland` or `android`; [`EglOffscreenContext`], the runtime-free target of `UiSurface`, does not.
|
//! Both need a backend feature, `wayland`, `android` or `windows`; [`EglOffscreenContext`], the runtime-free target of `UiSurface`, does not.
|
||||||
//!
|
//!
|
||||||
//! The bootstrap honours `LTK_FORCE_SOFTWARE=1` by failing fast with a
|
//! The bootstrap honours `LTK_FORCE_SOFTWARE=1` by failing fast with a
|
||||||
//! descriptive error. Backend selection is announced exactly once per
|
//! descriptive error. Backend selection is announced exactly once per
|
||||||
@@ -57,7 +57,7 @@ type EglInstance = egl::DynamicInstance<egl::EGL1_4>;
|
|||||||
/// Using `eglSwapBuffersWithDamage` makes Mesa emit
|
/// Using `eglSwapBuffersWithDamage` makes Mesa emit
|
||||||
/// `wl_surface.damage_buffer(x, y, w, h)` with the actual rect we pass,
|
/// `wl_surface.damage_buffer(x, y, w, h)` with the actual rect we pass,
|
||||||
/// which the compositor then uploads correctly.
|
/// which the compositor then uploads correctly.
|
||||||
#[ cfg( any( feature = "wayland", feature = "android" ) ) ]
|
#[ cfg( any( feature = "wayland", feature = "android", feature = "windows" ) ) ]
|
||||||
type SwapBuffersWithDamageFn = unsafe extern "system" fn(
|
type SwapBuffersWithDamageFn = unsafe extern "system" fn(
|
||||||
display: egl::EGLDisplay,
|
display: egl::EGLDisplay,
|
||||||
surface: egl::EGLSurface,
|
surface: egl::EGLSurface,
|
||||||
@@ -68,7 +68,7 @@ type SwapBuffersWithDamageFn = unsafe extern "system" fn(
|
|||||||
/// Process-wide EGL display + GLES context. Cheap to clone because the heavy
|
/// Process-wide EGL display + GLES context. Cheap to clone because the heavy
|
||||||
/// state (`Arc<EglInstance>`, `Arc<glow::Context>`) is reference-counted; the
|
/// state (`Arc<EglInstance>`, `Arc<glow::Context>`) is reference-counted; the
|
||||||
/// raw EGL handles are POD.
|
/// raw EGL handles are POD.
|
||||||
#[ cfg( any( feature = "wayland", feature = "android" ) ) ]
|
#[ cfg( any( feature = "wayland", feature = "android", feature = "windows" ) ) ]
|
||||||
pub struct EglContext
|
pub struct EglContext
|
||||||
{
|
{
|
||||||
/// Refcounted handle to the loaded `libEGL` instance. Shared with every
|
/// Refcounted handle to the loaded `libEGL` instance. Shared with every
|
||||||
@@ -101,7 +101,7 @@ pub struct EglContext
|
|||||||
/// Android the backend holds the `ANativeWindow` for as long. `egl` and
|
/// Android the backend holds the `ANativeWindow` for as long. `egl` and
|
||||||
/// `display` are kept so that `Drop` can call `eglDestroySurface` without
|
/// `display` are kept so that `Drop` can call `eglDestroySurface` without
|
||||||
/// requiring a `&EglContext` at the call site.
|
/// requiring a `&EglContext` at the call site.
|
||||||
#[ cfg( any( feature = "wayland", feature = "android" ) ) ]
|
#[ cfg( any( feature = "wayland", feature = "android", feature = "windows" ) ) ]
|
||||||
pub struct EglSurface
|
pub struct EglSurface
|
||||||
{
|
{
|
||||||
/// The `wl_egl_window` backing this surface. EGL holds a raw pointer into
|
/// The `wl_egl_window` backing this surface. EGL holds a raw pointer into
|
||||||
@@ -134,7 +134,7 @@ pub struct EglOffscreenContext
|
|||||||
gl: Arc<glow::Context>,
|
gl: Arc<glow::Context>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( any( feature = "wayland", feature = "android" ) ) ]
|
#[ cfg( any( feature = "wayland", feature = "android", feature = "windows" ) ) ]
|
||||||
impl Drop for EglSurface
|
impl Drop for EglSurface
|
||||||
{
|
{
|
||||||
fn drop( &mut self )
|
fn drop( &mut self )
|
||||||
@@ -155,7 +155,7 @@ impl Drop for EglOffscreenContext
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( any( feature = "wayland", feature = "android" ) ) ]
|
#[ cfg( any( feature = "wayland", feature = "android", feature = "windows" ) ) ]
|
||||||
impl EglContext
|
impl EglContext
|
||||||
{
|
{
|
||||||
/// Initialise EGL on `conn`. Returns `Err( reason )` if EGL cannot be
|
/// Initialise EGL on `conn`. Returns `Err( reason )` if EGL cannot be
|
||||||
@@ -182,6 +182,13 @@ impl EglContext
|
|||||||
Self::init( default_display, 1 )
|
Self::init( default_display, 1 )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialise ANGLE's EGL on its default display, with `eglSwapBuffers` waiting for the display as on Android. Fails as [`Self::new`] does, and when `libEGL.dll` is not found.
|
||||||
|
#[ cfg( feature = "windows" ) ]
|
||||||
|
pub fn new_windows() -> Result<Self, String>
|
||||||
|
{
|
||||||
|
Self::init( default_display, 1 )
|
||||||
|
}
|
||||||
|
|
||||||
fn init( get_display: impl FnOnce( &EglInstance ) -> Result<egl::Display, String>, swap_interval: i32 ) -> Result<Self, String>
|
fn init( get_display: impl FnOnce( &EglInstance ) -> Result<egl::Display, String>, swap_interval: i32 ) -> Result<Self, String>
|
||||||
{
|
{
|
||||||
if std::env::var( "LTK_FORCE_SOFTWARE" ).map( |v| v != "0" ).unwrap_or( false )
|
if std::env::var( "LTK_FORCE_SOFTWARE" ).map( |v| v != "0" ).unwrap_or( false )
|
||||||
@@ -189,16 +196,7 @@ impl EglContext
|
|||||||
return Err( "LTK_FORCE_SOFTWARE=1".to_string() );
|
return Err( "LTK_FORCE_SOFTWARE=1".to_string() );
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: `EglInstance::load_required` performs a `dlopen` on the
|
let egl = load_egl()?;
|
||||||
// system `libEGL.so` and is unsafe because the loaded library has
|
|
||||||
// arbitrary side effects on global process state. We tolerate that:
|
|
||||||
// libEGL is an established system component and ltk has no
|
|
||||||
// alternative path to GPU rendering.
|
|
||||||
let egl: Arc<EglInstance> = Arc::new(
|
|
||||||
unsafe { EglInstance::load_required() }
|
|
||||||
.map_err( |e| format!( "load libEGL: {e:?}" ) )?,
|
|
||||||
);
|
|
||||||
|
|
||||||
let display = get_display( &egl )?;
|
let display = get_display( &egl )?;
|
||||||
|
|
||||||
egl.initialize( display )
|
egl.initialize( display )
|
||||||
@@ -309,11 +307,11 @@ impl EglContext
|
|||||||
} )
|
} )
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an `EGLSurface` on an `ANativeWindow`, sized by its buffers.
|
/// Create an `EGLSurface` on an `ANativeWindow`, sized by its buffers, or on Windows on an `HWND`, sized by its client area.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// `window` must be a live `ANativeWindow *` that the caller keeps acquired until the returned surface is dropped.
|
/// `window` must be a live `ANativeWindow *` that the caller keeps acquired, or a live `HWND` the caller does not destroy, until the returned surface is dropped.
|
||||||
#[ cfg( feature = "android" ) ]
|
#[ cfg( any( feature = "android", feature = "windows" ) ) ]
|
||||||
pub unsafe fn create_native_surface( &self, window: *mut std::ffi::c_void ) -> Result<EglSurface, String>
|
pub unsafe fn create_native_surface( &self, window: *mut std::ffi::c_void ) -> Result<EglSurface, String>
|
||||||
{
|
{
|
||||||
let surface = unsafe { self.egl.create_window_surface( self.display, self.config, window as egl::NativeWindowType, None ) }
|
let surface = unsafe { self.egl.create_window_surface( self.display, self.config, window as egl::NativeWindowType, None ) }
|
||||||
@@ -450,13 +448,7 @@ impl EglOffscreenContext
|
|||||||
return Err( "LTK_FORCE_SOFTWARE=1".to_string() );
|
return Err( "LTK_FORCE_SOFTWARE=1".to_string() );
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: same as `EglContext::new` — `dlopen` of the system
|
let egl = load_egl()?;
|
||||||
// `libEGL.so`. Same tradeoff and rationale apply.
|
|
||||||
let egl: Arc<EglInstance> = Arc::new(
|
|
||||||
unsafe { EglInstance::load_required() }
|
|
||||||
.map_err( |e| format!( "load libEGL: {e:?}" ) )?,
|
|
||||||
);
|
|
||||||
|
|
||||||
let display = default_display( &egl )?;
|
let display = default_display( &egl )?;
|
||||||
egl.initialize( display )
|
egl.initialize( display )
|
||||||
.map_err( |e| format!( "eglInitialize: {e:?}" ) )?;
|
.map_err( |e| format!( "eglInitialize: {e:?}" ) )?;
|
||||||
@@ -554,6 +546,17 @@ impl EglOffscreenContext
|
|||||||
pub fn config( &self ) -> egl::Config { self.config }
|
pub fn config( &self ) -> egl::Config { self.config }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The system's `libEGL.so`, or on Windows ANGLE's `libEGL.dll`, which the loader looks for beside the executable first.
|
||||||
|
fn load_egl() -> Result<Arc<EglInstance>, String>
|
||||||
|
{
|
||||||
|
// SAFETY: loading the library runs its initialisers, which may touch global process state; libEGL is the platform's EGL, or ANGLE's, and ltk has no other path to the GPU.
|
||||||
|
#[ cfg( not( windows ) ) ]
|
||||||
|
let egl = unsafe { EglInstance::load_required() };
|
||||||
|
#[ cfg( windows ) ]
|
||||||
|
let egl = unsafe { EglInstance::load_required_from_filename( "libEGL.dll" ) };
|
||||||
|
egl.map( Arc::new ).map_err( |e| format!( "load libEGL: {e:?}" ) )
|
||||||
|
}
|
||||||
|
|
||||||
fn try_create_context(
|
fn try_create_context(
|
||||||
egl: &EglInstance, display: egl::Display, config: egl::Config, major: i32,
|
egl: &EglInstance, display: egl::Display, config: egl::Config, major: i32,
|
||||||
) -> Result<egl::Context, egl::Error>
|
) -> Result<egl::Context, egl::Error>
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ pub enum RunError
|
|||||||
/// In a browser, the page's desktop (forge/web) refused the main
|
/// In a browser, the page's desktop (forge/web) refused the main
|
||||||
/// surface.
|
/// surface.
|
||||||
Desktop( String ),
|
Desktop( String ),
|
||||||
|
/// On Windows, the window class could not be registered or the
|
||||||
|
/// main window could not be created.
|
||||||
|
Window( String ),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for RunError
|
impl std::fmt::Display for RunError
|
||||||
@@ -69,6 +72,8 @@ impl std::fmt::Display for RunError
|
|||||||
write!( f, "no window or document: ltk::run runs on a page's main thread" ),
|
write!( f, "no window or document: ltk::run runs on a page's main thread" ),
|
||||||
Self::Desktop( d ) =>
|
Self::Desktop( d ) =>
|
||||||
write!( f, "the page's desktop refused the main surface: {d}" ),
|
write!( f, "the page's desktop refused the main surface: {d}" ),
|
||||||
|
Self::Window( d ) =>
|
||||||
|
write!( f, "the main window could not be created: {d}" ),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub( crate ) mod setup;
|
|||||||
pub( crate ) mod sources;
|
pub( crate ) mod sources;
|
||||||
pub( crate ) mod surface;
|
pub( crate ) mod surface;
|
||||||
pub( crate ) mod text_editing;
|
pub( crate ) mod text_editing;
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( unix ) ]
|
||||||
pub( crate ) mod text_scale;
|
pub( crate ) mod text_scale;
|
||||||
pub( crate ) mod tooltip;
|
pub( crate ) mod tooltip;
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ impl<A: App, P: Platform> AppData<A, P>
|
|||||||
}
|
}
|
||||||
} )
|
} )
|
||||||
.map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e}" ) ) )?;
|
.map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e}" ) ) )?;
|
||||||
|
#[ cfg( unix ) ]
|
||||||
super::text_scale::spawn_watcher( tx );
|
super::text_scale::spawn_watcher( tx );
|
||||||
|
#[ cfg( feature = "windows" ) ]
|
||||||
|
crate::platform::windows::text_scale::spawn_watcher( tx );
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some( dur ) = self.app.poll_interval()
|
if let Some( dur ) = self.app.poll_interval()
|
||||||
|
|||||||
+33
-13
@@ -1,30 +1,30 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
//! The timers and channels the runtime puts on the backend's run loop, held by `AppData::sources`: calloop's natively; in a browser, a queue the web backend runs from its own loop.
|
//! The timers and channels the runtime puts on the backend's run loop, held by `AppData::sources`: calloop's natively; in a browser and on Windows, a queue the backend runs from its own loop.
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( queued_sources ) ) ]
|
||||||
use calloop::channel::Event;
|
use calloop::channel::Event;
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( queued_sources ) ) ]
|
||||||
use calloop::timer::{ TimeoutAction, Timer };
|
use calloop::timer::{ TimeoutAction, Timer };
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( queued_sources ) ) ]
|
||||||
use calloop::{ LoopHandle, RegistrationToken };
|
use calloop::{ LoopHandle, RegistrationToken };
|
||||||
|
|
||||||
use crate::app::ChannelSender;
|
use crate::app::ChannelSender;
|
||||||
|
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( queued_sources ) ) ]
|
||||||
pub( crate ) struct Sources<D: 'static>
|
pub( crate ) struct Sources<D: 'static>
|
||||||
{
|
{
|
||||||
handle: LoopHandle<'static, D>,
|
handle: LoopHandle<'static, D>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Names an armed timer to [`Sources::remove`].
|
/// Names an armed timer to [`Sources::remove`].
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( queued_sources ) ) ]
|
||||||
pub( crate ) struct TimerToken( RegistrationToken );
|
pub( crate ) struct TimerToken( RegistrationToken );
|
||||||
|
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( queued_sources ) ) ]
|
||||||
impl<D: 'static> Sources<D>
|
impl<D: 'static> Sources<D>
|
||||||
{
|
{
|
||||||
pub( crate ) fn new( handle: LoopHandle<'static, D> ) -> Self
|
pub( crate ) fn new( handle: LoopHandle<'static, D> ) -> Self
|
||||||
@@ -65,13 +65,13 @@ impl<D: 'static> Sources<D>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
pub( crate ) type Tick<D> = Box<dyn FnMut( &mut D ) -> Option<Duration>>;
|
pub( crate ) type Tick<D> = Box<dyn FnMut( &mut D ) -> Option<Duration>>;
|
||||||
|
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
pub( crate ) type Drain<D> = Box<dyn FnMut( &mut D )>;
|
pub( crate ) type Drain<D> = Box<dyn FnMut( &mut D )>;
|
||||||
|
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
struct Armed<D>
|
struct Armed<D>
|
||||||
{
|
{
|
||||||
id: u64,
|
id: u64,
|
||||||
@@ -80,7 +80,7 @@ struct Armed<D>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The backend takes the due timers out with [`Self::take_due`], runs them on its `AppData` and hands them back to [`Self::rearm`]; likewise the channels with [`Self::take_channels`] and [`Self::restore_channels`].
|
/// The backend takes the due timers out with [`Self::take_due`], runs them on its `AppData` and hands them back to [`Self::rearm`]; likewise the channels with [`Self::take_channels`] and [`Self::restore_channels`].
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
pub( crate ) struct Sources<D: 'static>
|
pub( crate ) struct Sources<D: 'static>
|
||||||
{
|
{
|
||||||
wake: fn(),
|
wake: fn(),
|
||||||
@@ -93,10 +93,10 @@ pub( crate ) struct Sources<D: 'static>
|
|||||||
channels: std::cell::RefCell<Vec<Drain<D>>>,
|
channels: std::cell::RefCell<Vec<Drain<D>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
pub( crate ) struct TimerToken( u64 );
|
pub( crate ) struct TimerToken( u64 );
|
||||||
|
|
||||||
#[ cfg( target_arch = "wasm32" ) ]
|
#[ cfg( queued_sources ) ]
|
||||||
impl<D: 'static> Sources<D>
|
impl<D: 'static> Sources<D>
|
||||||
{
|
{
|
||||||
/// `wake` asks the backend for an iteration once a message is sent.
|
/// `wake` asks the backend for an iteration once a message is sent.
|
||||||
@@ -188,3 +188,23 @@ impl<D: 'static> Sources<D>
|
|||||||
*channels = drains;
|
*channels = drains;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( queued_sources ) ]
|
||||||
|
impl<A: crate::app::App, P: crate::platform::Platform> super::AppData<A, P>
|
||||||
|
{
|
||||||
|
/// Run the timers due by now and the messages waiting on the channels; a backend whose loop holds the queue calls it on every iteration.
|
||||||
|
pub( crate ) fn dispatch_sources( &mut self )
|
||||||
|
{
|
||||||
|
for ( id, mut tick ) in self.sources.take_due( crate::time::Instant::now() )
|
||||||
|
{
|
||||||
|
let next = tick( self );
|
||||||
|
self.sources.rearm( id, tick, next );
|
||||||
|
}
|
||||||
|
let mut drains = self.sources.take_channels();
|
||||||
|
for drain in &mut drains
|
||||||
|
{
|
||||||
|
drain( self );
|
||||||
|
}
|
||||||
|
self.sources.restore_channels( drains );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
mod places;
|
mod places;
|
||||||
mod sort;
|
mod sort;
|
||||||
mod view;
|
pub( crate ) mod view;
|
||||||
#[ cfg( test ) ]
|
#[ cfg( test ) ]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const USER_DIRS: [ ( PlaceKind, &str, &str ); 6 ] = [
|
|||||||
/// The user's home folder, or in a browser where its folders are mounted.
|
/// The user's home folder, or in a browser where its folders are mounted.
|
||||||
pub fn home() -> PathBuf
|
pub fn home() -> PathBuf
|
||||||
{
|
{
|
||||||
std::env::var_os( "HOME" ).map_or_else( || PathBuf::from( MEDIA ), PathBuf::from )
|
crate::dirs::home().unwrap_or_else( || PathBuf::from( MEDIA ) )
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The home folder, the user's folders that exist, the root, and the mounted folders.
|
/// The home folder, the user's folders that exist, the root, and the mounted folders.
|
||||||
@@ -59,7 +59,7 @@ pub fn standard() -> Vec<Place>
|
|||||||
let path = user_dir( &home, key ).unwrap_or_else( || home.join( fallback ) );
|
let path = user_dir( &home, key ).unwrap_or_else( || home.join( fallback ) );
|
||||||
if path.is_dir() { out.push( Place { kind, path, name: None } ); }
|
if path.is_dir() { out.push( Place { kind, path, name: None } ); }
|
||||||
}
|
}
|
||||||
if Path::new( "/" ).is_dir() { out.push( Place { kind: PlaceKind::Root, path: PathBuf::from( "/" ), name: None } ); }
|
out.extend( roots() );
|
||||||
for mount in files::mounts().unwrap_or_default()
|
for mount in files::mounts().unwrap_or_default()
|
||||||
{
|
{
|
||||||
out.push( Place { kind: PlaceKind::Mount, path: mount.path, name: Some( mount.name ) } );
|
out.push( Place { kind: PlaceKind::Mount, path: mount.path, name: Some( mount.name ) } );
|
||||||
@@ -67,10 +67,27 @@ pub fn standard() -> Vec<Place>
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( not( windows ) ) ]
|
||||||
|
fn roots() -> Option<Place>
|
||||||
|
{
|
||||||
|
Path::new( "/" ).is_dir().then( || Place { kind: PlaceKind::Root, path: PathBuf::from( "/" ), name: None } )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each drive's root, named by its letter; from C, since asking an empty floppy drive may stall.
|
||||||
|
#[ cfg( windows ) ]
|
||||||
|
fn roots() -> Vec<Place>
|
||||||
|
{
|
||||||
|
( 'C'..='Z' )
|
||||||
|
.map( |letter| ( format!( "{letter}:" ), PathBuf::from( format!( "{letter}:\\" ) ) ) )
|
||||||
|
.filter( |( _, path )| path.is_dir() )
|
||||||
|
.map( |( name, path )| Place { kind: PlaceKind::Root, path, name: Some( name ) } )
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// From xdg-user-dirs; `None` when set to the home itself, which is how it disables one.
|
/// From xdg-user-dirs; `None` when set to the home itself, which is how it disables one.
|
||||||
fn user_dir( home: &Path, key: &str ) -> Option<PathBuf>
|
fn user_dir( home: &Path, key: &str ) -> Option<PathBuf>
|
||||||
{
|
{
|
||||||
let config = std::env::var_os( "XDG_CONFIG_HOME" ).map( PathBuf::from ).filter( |p| p.is_absolute() ).unwrap_or_else( || home.join( ".config" ) );
|
let config = crate::dirs::config_home().filter( |p| p.is_absolute() ).unwrap_or_else( || home.join( ".config" ) );
|
||||||
let text = std::fs::read_to_string( config.join( "user-dirs.dirs" ) ).ok()?;
|
let text = std::fs::read_to_string( config.join( "user-dirs.dirs" ) ).ok()?;
|
||||||
parse_user_dir( &text, home, key )
|
parse_user_dir( &text, home, key )
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ thread_local!
|
|||||||
static ICONS: RefCell<HashMap<( &'static str, [ u8; 4 ] ), Option<ImageData>>> = RefCell::new( HashMap::new() );
|
static ICONS: RefCell<HashMap<( &'static str, [ u8; 4 ] ), Option<ImageData>>> = RefCell::new( HashMap::new() );
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon( name: &'static str, tint: Color ) -> Option<ImageData>
|
pub( crate ) fn icon( name: &'static str, tint: Color ) -> Option<ImageData>
|
||||||
{
|
{
|
||||||
let channel = |v: f32| ( v.clamp( 0.0, 1.0 ) * 255.0 ) as u8;
|
let channel = |v: f32| ( v.clamp( 0.0, 1.0 ) * 255.0 ) as u8;
|
||||||
let key = ( name, [ channel( tint.r ), channel( tint.g ), channel( tint.b ), channel( tint.a ) ] );
|
let key = ( name, [ channel( tint.r ), channel( tint.g ), channel( tint.b ), channel( tint.a ) ] );
|
||||||
|
|||||||
+1
-2
@@ -24,8 +24,7 @@ pub fn mime_type( name: &str ) -> Option<&'static str>
|
|||||||
fn load() -> Vec<Glob>
|
fn load() -> Vec<Glob>
|
||||||
{
|
{
|
||||||
let bundled = crate::resource_dir().map( |dir| dir.join( "mime" ) );
|
let bundled = crate::resource_dir().map( |dir| dir.join( "mime" ) );
|
||||||
let home = std::env::var_os( "XDG_DATA_HOME" ).map( PathBuf::from )
|
let home = crate::dirs::data_home();
|
||||||
.or_else( || std::env::var_os( "HOME" ).map( |home| PathBuf::from( home ).join( ".local/share" ) ) );
|
|
||||||
let system = std::env::var( "XDG_DATA_DIRS" ).ok().filter( |dirs| !dirs.is_empty() ).unwrap_or_else( || "/usr/local/share:/usr/share".into() );
|
let system = std::env::var( "XDG_DATA_DIRS" ).ok().filter( |dirs| !dirs.is_empty() ).unwrap_or_else( || "/usr/local/share:/usr/share".into() );
|
||||||
let dirs = bundled.into_iter()
|
let dirs = bundled.into_iter()
|
||||||
.chain( home.map( |dir| dir.join( "mime" ) ) )
|
.chain( home.map( |dir| dir.join( "mime" ) ) )
|
||||||
|
|||||||
@@ -343,6 +343,12 @@ impl GlesCanvas
|
|||||||
/// FBO), but the FBO→FB0 transfer itself is a single cheap fullscreen
|
/// FBO), but the FBO→FB0 transfer itself is a single cheap fullscreen
|
||||||
/// op.
|
/// op.
|
||||||
pub fn present( &mut self )
|
pub fn present( &mut self )
|
||||||
|
{
|
||||||
|
self.present_to( self.width, self.height );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::present`] onto a default framebuffer of `width × height`, which the quad is stretched to cover.
|
||||||
|
pub fn present_to( &mut self, width: u32, height: u32 )
|
||||||
{
|
{
|
||||||
// Scoped guards: bind the default framebuffer (id 0) and the
|
// Scoped guards: bind the default framebuffer (id 0) and the
|
||||||
// blit program for the duration of this fn. On Drop they restore
|
// blit program for the duration of this fn. On Drop they restore
|
||||||
@@ -365,7 +371,7 @@ impl GlesCanvas
|
|||||||
// defaults. FBO + program are restored by the guards on scope exit.
|
// defaults. FBO + program are restored by the guards on scope exit.
|
||||||
unsafe
|
unsafe
|
||||||
{
|
{
|
||||||
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
|
self.gl.viewport( 0, 0, width as i32, height as i32 );
|
||||||
self.gl.disable( glow::SCISSOR_TEST );
|
self.gl.disable( glow::SCISSOR_TEST );
|
||||||
self.gl.disable( glow::BLEND );
|
self.gl.disable( glow::BLEND );
|
||||||
self.gl.active_texture( glow::TEXTURE0 );
|
self.gl.active_texture( glow::TEXTURE0 );
|
||||||
|
|||||||
+5
-2
@@ -123,7 +123,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Geometry and primitive values that flow through every builder:
|
//! Geometry and primitive values that flow through every builder:
|
||||||
//!
|
//!
|
||||||
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`].
|
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`](struct@WidgetId).
|
||||||
//! - [`Length`] — a size/distance that may be absolute pixels
|
//! - [`Length`] — a size/distance that may be absolute pixels
|
||||||
//! ([`LengthBase::Px`]), relative to the surface viewport
|
//! ([`LengthBase::Px`]), relative to the surface viewport
|
||||||
//! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] /
|
//! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] /
|
||||||
@@ -315,14 +315,16 @@ pub( crate ) mod event_loop;
|
|||||||
pub( crate ) mod platform;
|
pub( crate ) mod platform;
|
||||||
#[ cfg( feature = "wayland" ) ]
|
#[ cfg( feature = "wayland" ) ]
|
||||||
pub( crate ) mod protocol;
|
pub( crate ) mod protocol;
|
||||||
#[ cfg( feature = "wayland" ) ]
|
#[ cfg( any( feature = "wayland", feature = "windows" ) ) ]
|
||||||
pub( crate ) mod session_state;
|
pub( crate ) mod session_state;
|
||||||
pub( crate ) mod secure_mem;
|
pub( crate ) mod secure_mem;
|
||||||
pub( crate ) mod resources;
|
pub( crate ) mod resources;
|
||||||
|
pub mod dirs;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
pub mod vfs;
|
pub mod vfs;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod file_chooser;
|
pub mod file_chooser;
|
||||||
|
pub mod print;
|
||||||
pub mod gles_render;
|
pub mod gles_render;
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
||||||
pub mod egl_context;
|
pub mod egl_context;
|
||||||
@@ -418,6 +420,7 @@ pub use widget::color_picker::
|
|||||||
};
|
};
|
||||||
pub use widget::dialog::{ Dialog, dialog };
|
pub use widget::dialog::{ Dialog, dialog };
|
||||||
pub use file_chooser::{ Chosen, FileChooser, FileChooserMsg };
|
pub use file_chooser::{ Chosen, FileChooser, FileChooserMsg };
|
||||||
|
pub use print::{ PrintDialog, PrintDialogMsg, PrintEvent };
|
||||||
pub use layout::spacer::{ Spacer, spacer };
|
pub use layout::spacer::{ Spacer, spacer };
|
||||||
pub use layout::column::{ Column, column };
|
pub use layout::column::{ Column, column };
|
||||||
pub use layout::row::{ Row, row };
|
pub use layout::row::{ Row, row };
|
||||||
|
|||||||
+1
-6
@@ -228,12 +228,7 @@ pub fn search_paths() -> Vec<PathBuf>
|
|||||||
{
|
{
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
if let Some( dev ) = std::env::var_os( "LTK_KEYBOARDS_DIR" ) { out.push( PathBuf::from( dev ) ); }
|
if let Some( dev ) = std::env::var_os( "LTK_KEYBOARDS_DIR" ) { out.push( PathBuf::from( dev ) ); }
|
||||||
if let Some( home ) = std::env::var_os( "XDG_DATA_HOME" )
|
if let Some( data ) = crate::dirs::data_home() { out.push( data.join( "ltk" ).join( "keyboards" ) ); }
|
||||||
{
|
|
||||||
out.push( PathBuf::from( home ).join( "ltk/keyboards" ) );
|
|
||||||
} else if let Some( home ) = std::env::var_os( "HOME" ) {
|
|
||||||
out.push( PathBuf::from( home ).join( ".local/share/ltk/keyboards" ) );
|
|
||||||
}
|
|
||||||
if let Some( bundle ) = crate::resources::resource_dir() { out.push( bundle.join( "keyboards" ) ); }
|
if let Some( bundle ) = crate::resources::resource_dir() { out.push( bundle.join( "keyboards" ) ); }
|
||||||
out.push( PathBuf::from( "/usr/share/ltk/keyboards" ) );
|
out.push( PathBuf::from( "/usr/share/ltk/keyboards" ) );
|
||||||
out
|
out
|
||||||
|
|||||||
+17
-3
@@ -7,7 +7,7 @@
|
|||||||
//!
|
//!
|
||||||
//! A backend also owns the run loop: it connects to the system, builds `AppData` with `AppData::new`, installs the shared event sources with `AppData::install_sources`, and on every wake-up calls `AppData::before_draw`, `AppData::draw_due` and `AppData::after_draw` around its own work.
|
//! A backend also owns the run loop: it connects to the system, builds `AppData` with `AppData::new`, installs the shared event sources with `AppData::install_sources`, and on every wake-up calls `AppData::before_draw`, `AppData::draw_due` and `AppData::after_draw` around its own work.
|
||||||
//!
|
//!
|
||||||
//! The backends are `wayland`, `android` and `web`, each behind the feature of its name; `build.rs` refuses more than one. Each backend sets the two aliases below and `run` / `try_run` under its own `cfg`, and `build.rs` sets `cfg( backend )`, which builds the runtime, when one is enabled.
|
//! The backends are `wayland`, `android`, `web` and `windows`, each behind the feature of its name; `build.rs` refuses more than one. Each backend sets the two aliases below and `run` / `try_run` under its own `cfg`, and `build.rs` sets `cfg( backend )`, which builds the runtime, when one is enabled.
|
||||||
|
|
||||||
#[ cfg( feature = "android" ) ]
|
#[ cfg( feature = "android" ) ]
|
||||||
pub( crate ) mod android;
|
pub( crate ) mod android;
|
||||||
@@ -15,6 +15,8 @@ pub( crate ) mod android;
|
|||||||
pub( crate ) mod wayland;
|
pub( crate ) mod wayland;
|
||||||
#[ cfg( feature = "web" ) ]
|
#[ cfg( feature = "web" ) ]
|
||||||
pub( crate ) mod web;
|
pub( crate ) mod web;
|
||||||
|
#[ cfg( feature = "windows" ) ]
|
||||||
|
pub( crate ) mod windows;
|
||||||
#[ cfg( test ) ]
|
#[ cfg( test ) ]
|
||||||
pub( crate ) mod mock;
|
pub( crate ) mod mock;
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ pub( crate ) use android::run::{ run, try_run };
|
|||||||
pub( crate ) use wayland::run::{ run, try_run };
|
pub( crate ) use wayland::run::{ run, try_run };
|
||||||
#[ cfg( feature = "web" ) ]
|
#[ cfg( feature = "web" ) ]
|
||||||
pub( crate ) use web::run::{ run, try_run };
|
pub( crate ) use web::run::{ run, try_run };
|
||||||
|
#[ cfg( feature = "windows" ) ]
|
||||||
|
pub( crate ) use windows::run::{ run, try_run };
|
||||||
|
|
||||||
use crate::event_loop::SurfaceFocus;
|
use crate::event_loop::SurfaceFocus;
|
||||||
use crate::event_loop::cursor_shape::ResizeEdge;
|
use crate::event_loop::cursor_shape::ResizeEdge;
|
||||||
@@ -38,6 +42,8 @@ pub( crate ) type NativePlatform<A> = wayland::WaylandPlatform<A>;
|
|||||||
pub( crate ) type NativePlatform<A> = android::AndroidPlatform<A>;
|
pub( crate ) type NativePlatform<A> = android::AndroidPlatform<A>;
|
||||||
#[ cfg( feature = "web" ) ]
|
#[ cfg( feature = "web" ) ]
|
||||||
pub( crate ) type NativePlatform<A> = web::WebPlatform<A>;
|
pub( crate ) type NativePlatform<A> = web::WebPlatform<A>;
|
||||||
|
#[ cfg( feature = "windows" ) ]
|
||||||
|
pub( crate ) type NativePlatform<A> = windows::WindowsPlatform<A>;
|
||||||
|
|
||||||
/// The backend's half of every surface, held by `SurfaceState::platform`.
|
/// The backend's half of every surface, held by `SurfaceState::platform`.
|
||||||
#[ cfg( feature = "wayland" ) ]
|
#[ cfg( feature = "wayland" ) ]
|
||||||
@@ -46,14 +52,22 @@ pub( crate ) type NativeSurface = wayland::WaylandSurfaceState;
|
|||||||
pub( crate ) type NativeSurface = android::AndroidSurfaceState;
|
pub( crate ) type NativeSurface = android::AndroidSurfaceState;
|
||||||
#[ cfg( feature = "web" ) ]
|
#[ cfg( feature = "web" ) ]
|
||||||
pub( crate ) type NativeSurface = web::WebSurfaceState;
|
pub( crate ) type NativeSurface = web::WebSurfaceState;
|
||||||
|
#[ cfg( feature = "windows" ) ]
|
||||||
|
pub( crate ) type NativeSurface = windows::WindowsSurfaceState;
|
||||||
|
|
||||||
/// The user's language as `LANG` gives it (`es_ES.UTF-8`), or in a browser as the page does (`es-ES`).
|
/// The user's language as `LANG` gives it (`es_ES.UTF-8`), or in a browser as the page does (`es-ES`), or on Windows as the user's locale does when `LANG` is unset.
|
||||||
#[ cfg( not( feature = "web" ) ) ]
|
#[ cfg( not( any( feature = "web", feature = "windows" ) ) ) ]
|
||||||
pub( crate ) fn language() -> String
|
pub( crate ) fn language() -> String
|
||||||
{
|
{
|
||||||
std::env::var( "LANG" ).unwrap_or_default()
|
std::env::var( "LANG" ).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( feature = "windows" ) ]
|
||||||
|
pub( crate ) fn language() -> String
|
||||||
|
{
|
||||||
|
std::env::var( "LANG" ).ok().filter( |l| !l.is_empty() ).unwrap_or_else( windows::user_locale )
|
||||||
|
}
|
||||||
|
|
||||||
#[ cfg( feature = "web" ) ]
|
#[ cfg( feature = "web" ) ]
|
||||||
pub( crate ) fn language() -> String
|
pub( crate ) fn language() -> String
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
use std::os::fd::{ AsRawFd, FromRawFd, OwnedFd };
|
use std::os::fd::{ AsRawFd, FromRawFd, OwnedFd };
|
||||||
use std::sync::atomic::{ AtomicI32, Ordering };
|
use std::sync::atomic::{ AtomicI32, Ordering };
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use calloop::timer::{ TimeoutAction, Timer };
|
use calloop::timer::{ TimeoutAction, Timer };
|
||||||
use calloop::LoopHandle;
|
use calloop::LoopHandle;
|
||||||
@@ -22,11 +21,9 @@ use crate::protocol::xdg_session_management_v1::
|
|||||||
xdg_session_v1::{ self, XdgSessionV1 },
|
xdg_session_v1::{ self, XdgSessionV1 },
|
||||||
xdg_toplevel_session_v1::{ self, XdgToplevelSessionV1 },
|
xdg_toplevel_session_v1::{ self, XdgToplevelSessionV1 },
|
||||||
};
|
};
|
||||||
use crate::session_state::{ RestoreReason, Startup, StateStore };
|
use crate::session_state::{ RestoreReason, StateStore, SAVE_INTERVAL };
|
||||||
|
|
||||||
pub( crate ) const TOPLEVEL_NAME: &str = "main";
|
pub( crate ) const TOPLEVEL_NAME: &str = "main";
|
||||||
pub( crate ) const SAVE_INTERVAL: Duration = Duration::from_secs( 30 );
|
|
||||||
pub( crate ) const RESTORE_ENV: &str = "LTK_SESSION_RESTORE";
|
|
||||||
|
|
||||||
pub( crate ) struct SessionRuntime
|
pub( crate ) struct SessionRuntime
|
||||||
{
|
{
|
||||||
@@ -57,37 +54,8 @@ impl SessionRuntime
|
|||||||
/// the run as live.
|
/// the run as live.
|
||||||
pub fn bootstrap<A: App>( app: &mut A ) -> Self
|
pub fn bootstrap<A: App>( app: &mut A ) -> Self
|
||||||
{
|
{
|
||||||
let env_restore = std::env::var_os( RESTORE_ENV ).is_some_and( |v| v == "1" );
|
let ( store, reason ) = crate::session_state::bootstrap( app );
|
||||||
if std::env::var_os( RESTORE_ENV ).is_some()
|
Self { store, reason, ..Self::disabled() }
|
||||||
{
|
|
||||||
// SAFETY: removing an env var is sound only when no other thread
|
|
||||||
// is reading the environment concurrently. We are still in the
|
|
||||||
// init phase before `set_channel_sender`, so the app has had
|
|
||||||
// no opportunity to spawn worker threads yet.
|
|
||||||
unsafe { std::env::remove_var( RESTORE_ENV ); }
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rt = Self::disabled();
|
|
||||||
let Some( mut store ) = StateStore::open( app.app_id() ) else { return rt };
|
|
||||||
match store.decide( env_restore )
|
|
||||||
{
|
|
||||||
Startup::Concurrent =>
|
|
||||||
{
|
|
||||||
eprintln!( "ltk: another instance of {} is running; session persistence disabled", app.app_id() );
|
|
||||||
return rt;
|
|
||||||
}
|
|
||||||
Startup::Reason( reason ) => rt.reason = reason,
|
|
||||||
}
|
|
||||||
if rt.reason != RestoreReason::Launch || app.restore_on_launch()
|
|
||||||
{
|
|
||||||
if let Some( bytes ) = store.load_state()
|
|
||||||
{
|
|
||||||
app.restore_state( bytes );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
store.mark_running();
|
|
||||||
rt.store = Some( store );
|
|
||||||
rt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 2: bind the manager and open the session. Silent when the
|
/// Phase 2: bind the manager and open the session. Silent when the
|
||||||
|
|||||||
@@ -358,21 +358,6 @@ impl<A: App> AppData<A>
|
|||||||
Next { frame, after, exit: false }
|
Next { frame, after, exit: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_sources( &mut self )
|
|
||||||
{
|
|
||||||
for ( id, mut tick ) in self.sources.take_due( crate::time::Instant::now() )
|
|
||||||
{
|
|
||||||
let next = tick( self );
|
|
||||||
self.sources.rearm( id, tick, next );
|
|
||||||
}
|
|
||||||
let mut drains = self.sources.take_channels();
|
|
||||||
for drain in &mut drains
|
|
||||||
{
|
|
||||||
drain( self );
|
|
||||||
}
|
|
||||||
self.sources.restore_channels( drains );
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop listening to the page, give the desktop its surfaces back, and tell the page, with an `ltk-exit` event on the canvas, that the app has ended.
|
/// Stop listening to the page, give the desktop its surfaces back, and tell the page, with an `ltk-exit` event on the canvas, that the app has ended.
|
||||||
fn leave_page( &mut self )
|
fn leave_page( &mut self )
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! The system clipboard as `CF_UNICODETEXT`, whose lines Windows ends with CR LF.
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::{ GlobalFree, HWND };
|
||||||
|
use windows_sys::Win32::System::DataExchange::{ CloseClipboard, EmptyClipboard, GetClipboardData, OpenClipboard, SetClipboardData };
|
||||||
|
use windows_sys::Win32::System::Memory::{ GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE };
|
||||||
|
use windows_sys::Win32::System::Ole::CF_UNICODETEXT;
|
||||||
|
|
||||||
|
/// Offer `text` to other applications as the clipboard.
|
||||||
|
pub( super ) fn set( hwnd: HWND, text: &str )
|
||||||
|
{
|
||||||
|
let wide: Vec<u16> = text.replace( "\r\n", "\n" ).replace( '\n', "\r\n" ).encode_utf16().chain( std::iter::once( 0 ) ).collect();
|
||||||
|
// SAFETY: the clipboard is opened and closed here; the memory handed to it is a movable global block of the text's size, which the clipboard owns once `SetClipboardData` succeeds.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
if OpenClipboard( hwnd ) == 0 { return; }
|
||||||
|
EmptyClipboard();
|
||||||
|
let memory = GlobalAlloc( GMEM_MOVEABLE, wide.len() * 2 );
|
||||||
|
if !memory.is_null()
|
||||||
|
{
|
||||||
|
let target = GlobalLock( memory ).cast::<u16>();
|
||||||
|
if target.is_null()
|
||||||
|
{
|
||||||
|
GlobalFree( memory );
|
||||||
|
} else {
|
||||||
|
std::ptr::copy_nonoverlapping( wide.as_ptr(), target, wide.len() );
|
||||||
|
GlobalUnlock( memory );
|
||||||
|
if SetClipboardData( CF_UNICODETEXT as u32, memory ).is_null() { GlobalFree( memory ); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseClipboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clipboard's text; `None` when it holds none or another application has it open.
|
||||||
|
pub( super ) fn text( hwnd: HWND ) -> Option<String>
|
||||||
|
{
|
||||||
|
// SAFETY: the clipboard is opened and closed here; its block is read only while locked, up to its size or its terminating NUL.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
if OpenClipboard( hwnd ) == 0 { return None; }
|
||||||
|
let text = read();
|
||||||
|
CloseClipboard();
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn read() -> Option<String>
|
||||||
|
{
|
||||||
|
let memory = unsafe { GetClipboardData( CF_UNICODETEXT as u32 ) };
|
||||||
|
if memory.is_null() { return None; }
|
||||||
|
let units = unsafe { GlobalSize( memory ) } / 2;
|
||||||
|
let start = unsafe { GlobalLock( memory ) }.cast::<u16>().cast_const();
|
||||||
|
if start.is_null() { return None; }
|
||||||
|
let all = unsafe { std::slice::from_raw_parts( start, units ) };
|
||||||
|
let len = all.iter().position( |&u| u == 0 ).unwrap_or( units );
|
||||||
|
let text = String::from_utf16_lossy( &all[ ..len ] );
|
||||||
|
unsafe { GlobalUnlock( memory ); }
|
||||||
|
Some( text.replace( "\r\n", "\n" ) )
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! The pointer's shape over the client area: the system cursor nearest to each [`CursorShape`], which the window procedure sets on every `WM_SETCURSOR`.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::ptr::null_mut;
|
||||||
|
use std::sync::atomic::{ AtomicPtr, Ordering };
|
||||||
|
|
||||||
|
use windows_sys::core::PCWSTR;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::
|
||||||
|
{
|
||||||
|
LoadCursorW, SetCursor, HCURSOR, IDC_APPSTARTING, IDC_ARROW, IDC_CROSS, IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO,
|
||||||
|
IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, IDC_SIZENWSE, IDC_SIZEWE, IDC_WAIT,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::types::CursorShape;
|
||||||
|
|
||||||
|
static CURSOR: AtomicPtr<c_void> = AtomicPtr::new( null_mut() );
|
||||||
|
|
||||||
|
fn load( id: PCWSTR ) -> HCURSOR
|
||||||
|
{
|
||||||
|
// SAFETY: a null instance with a predefined id loads a shared system cursor, which is never freed.
|
||||||
|
unsafe { LoadCursorW( null_mut(), id ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cursor the app asked for last, the arrow until it asks.
|
||||||
|
pub( super ) fn current() -> HCURSOR
|
||||||
|
{
|
||||||
|
let cursor = CURSOR.load( Ordering::Relaxed );
|
||||||
|
if cursor.is_null() { load( IDC_ARROW ) } else { cursor }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use the cursor for `shape` from now on, and show it at once when the pointer is over the window.
|
||||||
|
pub( super ) fn set( shape: CursorShape, show: bool )
|
||||||
|
{
|
||||||
|
let cursor = load( id( shape ) );
|
||||||
|
CURSOR.store( cursor, Ordering::Relaxed );
|
||||||
|
// SAFETY: `cursor` is a system cursor, valid for the process.
|
||||||
|
if show { unsafe { SetCursor( cursor ); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id( shape: CursorShape ) -> PCWSTR
|
||||||
|
{
|
||||||
|
match shape
|
||||||
|
{
|
||||||
|
CursorShape::Help => IDC_HELP,
|
||||||
|
CursorShape::Pointer => IDC_HAND,
|
||||||
|
CursorShape::Progress => IDC_APPSTARTING,
|
||||||
|
CursorShape::Wait => IDC_WAIT,
|
||||||
|
CursorShape::Cell | CursorShape::Crosshair => IDC_CROSS,
|
||||||
|
CursorShape::Text | CursorShape::VerticalText => IDC_IBEAM,
|
||||||
|
CursorShape::Move | CursorShape::AllScroll | CursorShape::Grab | CursorShape::Grabbing => IDC_SIZEALL,
|
||||||
|
CursorShape::NoDrop | CursorShape::NotAllowed => IDC_NO,
|
||||||
|
CursorShape::EResize | CursorShape::WResize | CursorShape::EwResize | CursorShape::ColResize => IDC_SIZEWE,
|
||||||
|
CursorShape::NResize | CursorShape::SResize | CursorShape::NsResize | CursorShape::RowResize => IDC_SIZENS,
|
||||||
|
CursorShape::NeResize | CursorShape::SwResize | CursorShape::NeswResize => IDC_SIZENESW,
|
||||||
|
CursorShape::NwResize | CursorShape::SeResize | CursorShape::NwseResize => IDC_SIZENWSE,
|
||||||
|
_ => IDC_ARROW,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! The window procedure's queued events turned into [`crate::input::event`].
|
||||||
|
//!
|
||||||
|
//! Mice, touchpads and pens are a pointer; fingers are touches, from `WM_POINTER`, which then send no mouse messages of their own. Positions come in client pixels and are divided by `WindowsPlatform::px_per_logical`. Keys repeat on Windows's own clock, so the runtime's repeat stays off, and dead keys are composed by Windows.
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::event_loop::{ AppData, SurfaceFocus };
|
||||||
|
use crate::input::event::{ Axis, AxisSource, KeyInput, PointerInput, PointerKind, TouchInput, TouchPhase };
|
||||||
|
use crate::Keysym;
|
||||||
|
|
||||||
|
use super::clipboard;
|
||||||
|
use super::keys::{ self, Modifiers };
|
||||||
|
use super::wndproc::{ self, Event, MouseKind, TouchKind };
|
||||||
|
|
||||||
|
/// Logical pixels per wheel detent, as libinput reports one.
|
||||||
|
const WHEEL_STEP: f64 = 15.0;
|
||||||
|
|
||||||
|
/// Windows's wheel units per detent.
|
||||||
|
const WHEEL_DELTA: f32 = 120.0;
|
||||||
|
|
||||||
|
impl<A: App> AppData<A>
|
||||||
|
{
|
||||||
|
/// Hand everything the window procedure queued to the runtime.
|
||||||
|
pub( super ) fn windows_events( &mut self )
|
||||||
|
{
|
||||||
|
for event in wndproc::take_events()
|
||||||
|
{
|
||||||
|
match event
|
||||||
|
{
|
||||||
|
Event::Resized => self.fit_window(),
|
||||||
|
Event::Minimized =>
|
||||||
|
{
|
||||||
|
self.main.configured = false;
|
||||||
|
self.main.frame_pending = false;
|
||||||
|
}
|
||||||
|
Event::Exposed => self.main.request_redraw(),
|
||||||
|
Event::Close => self.request_close(),
|
||||||
|
Event::EndSession =>
|
||||||
|
{
|
||||||
|
self.finish_session();
|
||||||
|
self.exit_requested = true;
|
||||||
|
}
|
||||||
|
Event::Focus( true ) =>
|
||||||
|
{
|
||||||
|
self.read_clipboard();
|
||||||
|
self.keyboard_enter( SurfaceFocus::Main );
|
||||||
|
}
|
||||||
|
Event::Focus( false ) => self.keyboard_leave(),
|
||||||
|
Event::ClipboardChanged => self.read_clipboard(),
|
||||||
|
Event::Mouse { x, y, kind } => self.windows_mouse( x, y, kind ),
|
||||||
|
Event::Wheel { x, y, delta, horizontal } =>
|
||||||
|
{
|
||||||
|
let notches = delta as f32 / WHEEL_DELTA;
|
||||||
|
let ( horizontal, vertical ) = if horizontal { ( wheel( notches ), Axis::default() ) } else { ( Axis::default(), wheel( -notches ) ) };
|
||||||
|
let position = self.client_to_logical( x, y );
|
||||||
|
self.pointer_input( &PointerInput { focus: SurfaceFocus::Main, position, kind: PointerKind::Axis { horizontal, vertical, source: AxisSource::Wheel } } );
|
||||||
|
}
|
||||||
|
Event::Touch { id, x, y, time, phase } =>
|
||||||
|
{
|
||||||
|
let position = self.client_to_logical( x, y );
|
||||||
|
let phase = match phase
|
||||||
|
{
|
||||||
|
TouchKind::Down => TouchPhase::Down { focus: SurfaceFocus::Main, position },
|
||||||
|
TouchKind::Move => TouchPhase::Motion { position },
|
||||||
|
TouchKind::Up => TouchPhase::Up,
|
||||||
|
};
|
||||||
|
self.touch_input( &TouchInput { id: id as i32, time, phase } );
|
||||||
|
}
|
||||||
|
Event::TouchCancel => { self.reset_touch_state(); }
|
||||||
|
Event::Key { vk, scan, pressed, repeat, text, modifiers } => self.windows_key( vk, scan, pressed, repeat, text, modifiers ),
|
||||||
|
Event::Text( text ) => self.windows_text( &text ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_to_logical( &self, x: i32, y: i32 ) -> ( f64, f64 )
|
||||||
|
{
|
||||||
|
let ( sx, sy ) = self.platform.px_per_logical;
|
||||||
|
( x as f64 / sx, y as f64 / sy )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_clipboard( &mut self )
|
||||||
|
{
|
||||||
|
if let Some( text ) = clipboard::text( self.platform.hwnd ) { self.clipboard = text; }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn windows_mouse( &mut self, x: i32, y: i32, kind: MouseKind )
|
||||||
|
{
|
||||||
|
let kind = match kind
|
||||||
|
{
|
||||||
|
MouseKind::Enter =>
|
||||||
|
{
|
||||||
|
self.platform.pointer_inside = true;
|
||||||
|
PointerKind::Enter
|
||||||
|
}
|
||||||
|
MouseKind::Leave =>
|
||||||
|
{
|
||||||
|
self.platform.pointer_inside = false;
|
||||||
|
PointerKind::Leave
|
||||||
|
}
|
||||||
|
MouseKind::Move => PointerKind::Motion,
|
||||||
|
MouseKind::Press( button ) => PointerKind::Press( button ),
|
||||||
|
MouseKind::Release( button ) => PointerKind::Release( button ),
|
||||||
|
};
|
||||||
|
let position = self.client_to_logical( x, y );
|
||||||
|
self.pointer_input( &PointerInput { focus: SurfaceFocus::Main, position, kind } );
|
||||||
|
}
|
||||||
|
|
||||||
|
fn windows_key( &mut self, vk: u16, scan: u32, pressed: bool, repeat: bool, text: String, modifiers: Modifiers )
|
||||||
|
{
|
||||||
|
if ( modifiers.ctrl, modifiers.shift, modifiers.alt ) != ( self.ctrl_pressed, self.shift_pressed, self.alt_pressed )
|
||||||
|
{
|
||||||
|
self.modifiers_changed( modifiers.ctrl, modifiers.shift, modifiers.alt );
|
||||||
|
}
|
||||||
|
let typed = text.chars().next().filter( |c| !c.is_control() );
|
||||||
|
let keysym = keys::keysym( vk, scan )
|
||||||
|
.or( typed.map( Keysym::from_char ) )
|
||||||
|
.or_else( || keys::base_char( vk ).map( Keysym::from_char ) );
|
||||||
|
let Some( keysym ) = keysym else { return };
|
||||||
|
let text = Some( text ).filter( |t| !t.is_empty() && !modifiers.ctrl && !t.chars().any( char::is_control ) );
|
||||||
|
let key = KeyInput { keysym, keycode: scan, text };
|
||||||
|
match ( pressed, repeat )
|
||||||
|
{
|
||||||
|
( true, true ) => self.key_repeated( key ),
|
||||||
|
( true, false ) => self.key_press( key ),
|
||||||
|
( false, _ ) => self.key_release( &key ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text with no key of its own, each character as a key pressed and released.
|
||||||
|
fn windows_text( &mut self, text: &str )
|
||||||
|
{
|
||||||
|
for c in text.chars().filter( |c| !c.is_control() )
|
||||||
|
{
|
||||||
|
let key = KeyInput::synthetic( Keysym::from_char( c ), Some( c.to_string() ) );
|
||||||
|
self.key_press( key.clone() );
|
||||||
|
self.key_release( &key );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wheel( notches: f32 ) -> Axis
|
||||||
|
{
|
||||||
|
Axis { distance: notches as f64 * WHEEL_STEP, notches }
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! Windows's virtual keys as keysyms, and the modifiers held.
|
||||||
|
|
||||||
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::*;
|
||||||
|
|
||||||
|
use crate::Keysym;
|
||||||
|
|
||||||
|
#[ derive( Clone, Copy, Debug, Default, PartialEq, Eq ) ]
|
||||||
|
pub( super ) struct Modifiers
|
||||||
|
{
|
||||||
|
pub ctrl: bool,
|
||||||
|
pub shift: bool,
|
||||||
|
pub alt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The modifiers held as of the message being handled. AltGr reaches Windows as the left Ctrl with the right Alt, and is neither.
|
||||||
|
pub( super ) fn held() -> Modifiers
|
||||||
|
{
|
||||||
|
// SAFETY: `GetKeyState` only reads the thread's keyboard state.
|
||||||
|
let down = |vk: VIRTUAL_KEY| unsafe { GetKeyState( vk as i32 ) } < 0;
|
||||||
|
let altgr = down( VK_RMENU ) && down( VK_LCONTROL );
|
||||||
|
Modifiers
|
||||||
|
{
|
||||||
|
ctrl: ( down( VK_LCONTROL ) && !altgr ) || down( VK_RCONTROL ),
|
||||||
|
shift: down( VK_SHIFT ),
|
||||||
|
alt: down( VK_LMENU ) || ( down( VK_RMENU ) && !altgr ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keysym of a key that types no character: navigation, editing, function and modifier keys. `None` for the others, whose keysym is the character they type. `scan` tells the left and right modifiers apart, and the keypad's Enter.
|
||||||
|
pub( super ) fn keysym( vk: u16, scan: u32 ) -> Option<Keysym>
|
||||||
|
{
|
||||||
|
let extended = scan & 0xE000 != 0;
|
||||||
|
Some( match vk
|
||||||
|
{
|
||||||
|
VK_LEFT => Keysym::Left,
|
||||||
|
VK_RIGHT => Keysym::Right,
|
||||||
|
VK_UP => Keysym::Up,
|
||||||
|
VK_DOWN => Keysym::Down,
|
||||||
|
VK_RETURN if extended => Keysym::KP_Enter,
|
||||||
|
VK_RETURN => Keysym::Return,
|
||||||
|
VK_BACK => Keysym::BackSpace,
|
||||||
|
VK_DELETE => Keysym::Delete,
|
||||||
|
VK_TAB => Keysym::Tab,
|
||||||
|
VK_ESCAPE => Keysym::Escape,
|
||||||
|
VK_HOME => Keysym::Home,
|
||||||
|
VK_END => Keysym::End,
|
||||||
|
VK_PRIOR => Keysym::Page_Up,
|
||||||
|
VK_NEXT => Keysym::Page_Down,
|
||||||
|
VK_INSERT => Keysym::Insert,
|
||||||
|
VK_F1 => Keysym::F1,
|
||||||
|
VK_F2 => Keysym::F2,
|
||||||
|
VK_F3 => Keysym::F3,
|
||||||
|
VK_F4 => Keysym::F4,
|
||||||
|
VK_F5 => Keysym::F5,
|
||||||
|
VK_F6 => Keysym::F6,
|
||||||
|
VK_F7 => Keysym::F7,
|
||||||
|
VK_F8 => Keysym::F8,
|
||||||
|
VK_F9 => Keysym::F9,
|
||||||
|
VK_F10 => Keysym::F10,
|
||||||
|
VK_F11 => Keysym::F11,
|
||||||
|
VK_F12 => Keysym::F12,
|
||||||
|
VK_BROWSER_BACK => Keysym::XF86_Back,
|
||||||
|
VK_BROWSER_FORWARD => Keysym::XF86_Forward,
|
||||||
|
VK_APPS => Keysym::Menu,
|
||||||
|
// The right Shift's scan code is 0x36; Windows sends both as `VK_SHIFT`.
|
||||||
|
VK_SHIFT if scan == 0x36 => Keysym::Shift_R,
|
||||||
|
VK_SHIFT | VK_LSHIFT => Keysym::Shift_L,
|
||||||
|
VK_RSHIFT => Keysym::Shift_R,
|
||||||
|
VK_CONTROL if extended => Keysym::Control_R,
|
||||||
|
VK_CONTROL | VK_LCONTROL => Keysym::Control_L,
|
||||||
|
VK_RCONTROL => Keysym::Control_R,
|
||||||
|
VK_MENU if extended => Keysym::Alt_R,
|
||||||
|
VK_MENU | VK_LMENU => Keysym::Alt_L,
|
||||||
|
VK_RMENU => Keysym::Alt_R,
|
||||||
|
VK_LWIN => Keysym::Super_L,
|
||||||
|
VK_RWIN => Keysym::Super_R,
|
||||||
|
VK_CAPITAL => Keysym::Caps_Lock,
|
||||||
|
VK_NUMLOCK => Keysym::Num_Lock,
|
||||||
|
VK_SCROLL => Keysym::Scroll_Lock,
|
||||||
|
VK_SNAPSHOT => Keysym::Print,
|
||||||
|
VK_PAUSE => Keysym::Pause,
|
||||||
|
_ => return None,
|
||||||
|
} )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The character `vk` types in the current layout with no modifier, lower case for a letter; `None` for a dead key and a key that types none. Ctrl turns what a key types into a control character, so a shortcut is told by this.
|
||||||
|
pub( super ) fn base_char( vk: u16 ) -> Option<char>
|
||||||
|
{
|
||||||
|
// SAFETY: `MapVirtualKeyW` only reads the layout; it leaves the dead-key state alone.
|
||||||
|
let code = unsafe { MapVirtualKeyW( vk as u32, MAPVK_VK_TO_CHAR ) };
|
||||||
|
if code == 0 || code & 0x8000_0000 != 0 { return None; }
|
||||||
|
char::from_u32( code ).map( |c| c.to_lowercase().next().unwrap_or( c ) )
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! The Windows backend: `ltk::run` in a Win32 window, on Windows 10 1703 or later.
|
||||||
|
//!
|
||||||
|
//! | File | What it does for the runtime |
|
||||||
|
//! | --- | --- |
|
||||||
|
//! | `state.rs` | [`WindowsPlatform`]: the window, the EGL context, the window's scale and what the runtime asked of the system |
|
||||||
|
//! | `window.rs` | The window class, creating the window at its size for the display's DPI, full screen, and a move or resize begun from the app's own chrome |
|
||||||
|
//! | `wndproc.rs` | The window procedure, which queues what Windows sends as [`wndproc::Event`]s; waking the loop, waiting and pumping messages |
|
||||||
|
//! | `run.rs` | `ltk::run`: the loop around the runtime's steps, and its steps while Windows runs a modal loop of its own |
|
||||||
|
//! | `session.rs` | Saving [`App::save_state`] periodically, on exit and when the user's session ends, into `%LOCALAPPDATA%\<app_id>\` |
|
||||||
|
//! | `surface.rs` | [`WindowsSurfaceState`]: the window's EGL surface; sizing the main surface to the client area at the display's scale |
|
||||||
|
//! | `present.rs` | Putting a painted canvas on screen through ANGLE's `eglSwapBuffers`, or through GDI |
|
||||||
|
//! | `input.rs`, `keys.rs` | The queued mouse, touch and key events turned into [`crate::input::event`], virtual keys into keysyms |
|
||||||
|
//! | `cursor.rs`, `clipboard.rs` | The pointer's shape and the system clipboard |
|
||||||
|
//! | `text_scale.rs` | The text size the user chose in the accessibility settings |
|
||||||
|
//!
|
||||||
|
//! The window has one surface, the main one, filling its client area, with Windows's own frame, so the built-in title bar is never drawn. Overlays, secondary surfaces and subsurfaces are not created. ANGLE's `libEGL.dll` and `libGLESv2.dll`, when they sit beside the executable, draw it through Direct3D; without them it is drawn in software and copied with GDI. Resources the system does not install, themes and fonts, come from `ltk\` beside the executable ([`crate::resource_dir`]). The input method's text arrives as typed characters, without a preedit yet, and accessibility is not exposed yet.
|
||||||
|
|
||||||
|
mod clipboard;
|
||||||
|
mod cursor;
|
||||||
|
mod input;
|
||||||
|
mod keys;
|
||||||
|
mod present;
|
||||||
|
pub( crate ) mod run;
|
||||||
|
mod session;
|
||||||
|
mod state;
|
||||||
|
mod surface;
|
||||||
|
pub( crate ) mod text_scale;
|
||||||
|
mod window;
|
||||||
|
mod wndproc;
|
||||||
|
|
||||||
|
pub( crate ) use state::WindowsPlatform;
|
||||||
|
pub( crate ) use surface::WindowsSurfaceState;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Globalization::GetUserDefaultLocaleName;
|
||||||
|
use windows_sys::Win32::System::SystemServices::LOCALE_NAME_MAX_LENGTH;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{ ShowWindow, SW_MINIMIZE };
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::event_loop::SurfaceFocus;
|
||||||
|
use crate::event_loop::cursor_shape::ResizeEdge;
|
||||||
|
use crate::osk::Purpose;
|
||||||
|
use crate::render::Canvas;
|
||||||
|
use crate::types::CursorShape;
|
||||||
|
|
||||||
|
use super::{ Platform, Presentation };
|
||||||
|
|
||||||
|
/// The user's locale as `LANG` would name it, `es_ES` for Windows's `es-ES`; empty when Windows gives none.
|
||||||
|
pub( crate ) fn user_locale() -> String
|
||||||
|
{
|
||||||
|
let mut name = [ 0u16; LOCALE_NAME_MAX_LENGTH as usize ];
|
||||||
|
// SAFETY: `name` has room for the `LOCALE_NAME_MAX_LENGTH` characters the call may write.
|
||||||
|
let len = unsafe { GetUserDefaultLocaleName( name.as_mut_ptr(), name.len() as i32 ) };
|
||||||
|
if len <= 1 { return String::new(); }
|
||||||
|
String::from_utf16_lossy( &name[ ..len as usize - 1 ] ).replace( '-', "_" )
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A: App> Platform for WindowsPlatform<A>
|
||||||
|
{
|
||||||
|
type Surface = WindowsSurfaceState;
|
||||||
|
type Output = ();
|
||||||
|
|
||||||
|
fn is_toplevel( &self, _main: &WindowsSurfaceState ) -> bool
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_leaves( &self, _main: &WindowsSurfaceState ) -> bool
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_window_move( &self, _main: &WindowsSurfaceState )
|
||||||
|
{
|
||||||
|
self.frame_drag.set( Some( window::move_hit() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_window_resize( &self, _main: &WindowsSurfaceState, edge: ResizeEdge )
|
||||||
|
{
|
||||||
|
self.frame_drag.set( Some( window::resize_hit( edge ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minimize_window( &self, _main: &WindowsSurfaceState )
|
||||||
|
{
|
||||||
|
// SAFETY: `hwnd` is the live main window, destroyed only after the runtime is dropped.
|
||||||
|
unsafe { ShowWindow( self.hwnd, SW_MINIMIZE ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_fullscreen( &self, _main: &WindowsSurfaceState, fullscreen: bool ) -> bool
|
||||||
|
{
|
||||||
|
window::set_fullscreen( self.hwnd, &mut self.restore.borrow_mut(), fullscreen );
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_frame( &self, _surface: &WindowsSurfaceState, _focus: SurfaceFocus ) -> bool
|
||||||
|
{
|
||||||
|
self.frame_requested.set( true );
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn precise_damage( &self, _surface: &WindowsSurfaceState ) -> bool
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_canvas( &self, surface: &mut WindowsSurfaceState, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: f32 ) -> bool
|
||||||
|
{
|
||||||
|
present::prepare_canvas( self, surface, canvas, pw, ph, scale )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn present( &self, surface: &mut WindowsSurfaceState, canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool
|
||||||
|
{
|
||||||
|
present::present( self, surface, canvas, frame )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_clipboard( &mut self, text: &str )
|
||||||
|
{
|
||||||
|
clipboard::set( self.hwnd, text );
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_primary_selection( &mut self, _text: &str ) {}
|
||||||
|
|
||||||
|
fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool
|
||||||
|
{
|
||||||
|
cursor::set( shape, self.pointer_inside );
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_input_active( &self ) -> bool
|
||||||
|
{
|
||||||
|
self.text_input
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate_text_input( &mut self, _secure: bool, _purpose: Purpose )
|
||||||
|
{
|
||||||
|
self.text_input = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deactivate_text_input( &mut self )
|
||||||
|
{
|
||||||
|
self.text_input = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reenable_text_input( &self, _secure: bool, _purpose: Purpose ) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! Putting a painted canvas on screen: through ANGLE's `eglSwapBuffers` for the GPU renderer, through GDI for the software one. Either way the frame covers the whole client area, which at a fractional scale may be a pixel off the canvas.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::HWND;
|
||||||
|
use windows_sys::Win32::Graphics::Dwm::DwmFlush;
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{ GetDC, ReleaseDC, StretchDIBits, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, SRCCOPY };
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::draw::fit_canvas;
|
||||||
|
use crate::egl_context::egl_damage;
|
||||||
|
use crate::event_loop::perf::RasterTrace;
|
||||||
|
use crate::platform::Presentation;
|
||||||
|
use crate::render::Canvas;
|
||||||
|
|
||||||
|
use super::{ window, WindowsPlatform, WindowsSurfaceState };
|
||||||
|
|
||||||
|
/// The GPU path when both the process and the window have EGL, software otherwise.
|
||||||
|
pub( super ) fn prepare_canvas<A: App>
|
||||||
|
(
|
||||||
|
platform: &WindowsPlatform<A>,
|
||||||
|
surface: &mut WindowsSurfaceState,
|
||||||
|
canvas: &mut Option<Canvas>,
|
||||||
|
pw: u32,
|
||||||
|
ph: u32,
|
||||||
|
scale: f32,
|
||||||
|
) -> bool
|
||||||
|
{
|
||||||
|
if let ( Some( ctx ), Some( es ) ) = ( platform.egl_context.as_ref(), surface.egl_surface.as_ref() )
|
||||||
|
{
|
||||||
|
if ctx.make_current( es ).is_err() { return false; }
|
||||||
|
surface.raster_trace = RasterTrace::begin();
|
||||||
|
fit_canvas( canvas, pw, ph, scale, || Canvas::new_gles( Arc::clone( ctx.gl() ), ctx.version, pw, ph ) );
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) );
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( super ) fn present<A: App>
|
||||||
|
(
|
||||||
|
platform: &WindowsPlatform<A>,
|
||||||
|
surface: &mut WindowsSurfaceState,
|
||||||
|
canvas: &mut Canvas,
|
||||||
|
frame: &Presentation<'_>,
|
||||||
|
) -> bool
|
||||||
|
{
|
||||||
|
let ( pw, ph ) = canvas.size();
|
||||||
|
let ( cw, ch ) = window::client_size( surface.hwnd );
|
||||||
|
if cw == 0 || ch == 0 { return false; }
|
||||||
|
let shown = match platform.egl_context.as_ref().zip( surface.egl_surface.as_ref() )
|
||||||
|
{
|
||||||
|
Some( ( ctx, es ) ) =>
|
||||||
|
{
|
||||||
|
canvas.present_to( cw, ch );
|
||||||
|
if let Some( t ) = surface.raster_trace.take()
|
||||||
|
{
|
||||||
|
t.end( ctx.gl(), if frame.damage.is_empty() { "full" } else { "partial" }, pw, ph );
|
||||||
|
}
|
||||||
|
let damage = if ( cw, ch ) == ( pw, ph ) { egl_damage( frame.damage, pw, ph ) } else { egl_damage( &[], cw, ch ) };
|
||||||
|
ctx.swap_buffers_with_damage( es, &damage ).is_ok()
|
||||||
|
}
|
||||||
|
None => present_gdi( surface, canvas, cw, ch ),
|
||||||
|
};
|
||||||
|
if shown && frame.frame.is_some() { platform.presented.set( true ); }
|
||||||
|
shown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy the canvas onto the client area, then wait for the compositor to take it, as a swap does.
|
||||||
|
fn present_gdi( surface: &mut WindowsSurfaceState, canvas: &Canvas, cw: u32, ch: u32 ) -> bool
|
||||||
|
{
|
||||||
|
let ( pw, ph ) = canvas.size();
|
||||||
|
surface.pixels.resize( pw as usize * ph as usize * 4, 0 );
|
||||||
|
canvas.write_to_wayland_buf( &mut surface.pixels, true );
|
||||||
|
if !blit( surface.hwnd, &surface.pixels, ( pw, ph ), ( cw, ch ) ) { return false; }
|
||||||
|
// SAFETY: no arguments; fails harmlessly when nothing is composited.
|
||||||
|
unsafe { DwmFlush(); }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blit( hwnd: HWND, pixels: &[u8], ( pw, ph ): ( u32, u32 ), ( cw, ch ): ( u32, u32 ) ) -> bool
|
||||||
|
{
|
||||||
|
// SAFETY: the header describes `pixels` exactly, top-down 32-bit BGRA rows of `pw` pixels; the window's own DC is released after the copy.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let mut info: BITMAPINFO = std::mem::zeroed();
|
||||||
|
info.bmiHeader = BITMAPINFOHEADER
|
||||||
|
{
|
||||||
|
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||||
|
biWidth: pw as i32,
|
||||||
|
biHeight: -( ph as i32 ),
|
||||||
|
biPlanes: 1,
|
||||||
|
biBitCount: 32,
|
||||||
|
biCompression: BI_RGB,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let dc = GetDC( hwnd );
|
||||||
|
if dc.is_null() { return false; }
|
||||||
|
let lines = StretchDIBits( dc, 0, 0, cw as i32, ch as i32, 0, 0, pw as i32, ph as i32, pixels.as_ptr().cast(), &info, DIB_RGB_COLORS, SRCCOPY );
|
||||||
|
ReleaseDC( hwnd, dc );
|
||||||
|
lines > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! `ltk::run` on Windows.
|
||||||
|
//!
|
||||||
|
//! The thread sleeps in `MsgWaitForMultipleObjectsEx` until a message comes, a channel wakes it or the runtime's next deadline or timer is due, pumps the messages, and runs a step: the queued events, the due timers and channel messages, then the runtime's own steps around drawing. The runtime lives in a box the loop reaches only through a raw pointer, each borrow ending before a call that may run the window procedure, so the procedure can run the same step while Windows's modal loop holds the thread.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::ptr::null_mut;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::HWND;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::DestroyWindow;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::egl_context::{ log_software_fallback, EglContext };
|
||||||
|
use crate::event_loop::{ AppData, RunError, Sources, SurfaceFocus, SurfaceState };
|
||||||
|
use crate::session_state::StateStore;
|
||||||
|
|
||||||
|
use super::{ window, wndproc, WindowsPlatform, WindowsSurfaceState };
|
||||||
|
|
||||||
|
/// How long a frame the runtime asked for without drawing waits, one refresh at 60 Hz.
|
||||||
|
const FRAME: Duration = Duration::from_millis( 16 );
|
||||||
|
|
||||||
|
/// The client area, in logical pixels, of an app that gives no size.
|
||||||
|
const DEFAULT_SIZE: ( u32, u32 ) = ( 800, 600 );
|
||||||
|
|
||||||
|
pub( crate ) fn run<A: App>( app: A )
|
||||||
|
{
|
||||||
|
if let Err( e ) = try_run( app )
|
||||||
|
{
|
||||||
|
panic!( "ltk::run failed during init: {e}" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
|
||||||
|
{
|
||||||
|
let ( session, _ ) = crate::session_state::bootstrap( &mut app );
|
||||||
|
let resizable = app.window_size_hint().is_none() || app.window_resizable();
|
||||||
|
let size = app.window_size_hint().unwrap_or( DEFAULT_SIZE );
|
||||||
|
let hwnd = window::create( &app.window_title(), size, resizable ).map_err( RunError::Window )?;
|
||||||
|
let data = match start( app, hwnd, session )
|
||||||
|
{
|
||||||
|
Ok( data ) => data,
|
||||||
|
Err( e ) =>
|
||||||
|
{
|
||||||
|
destroy( hwnd );
|
||||||
|
return Err( e );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window::show( hwnd );
|
||||||
|
|
||||||
|
let runtime = Box::into_raw( data );
|
||||||
|
let modal = wndproc::ModalStep::install( runtime.cast(), modal_step::<A> );
|
||||||
|
loop
|
||||||
|
{
|
||||||
|
// SAFETY: `runtime` is the box's until it is taken back below; no borrow of it outlives its statement, and the window procedure steps it only while none is held.
|
||||||
|
wndproc::wait( timeout( unsafe { &*runtime } ) );
|
||||||
|
wndproc::pump();
|
||||||
|
if wndproc::stepping( || step( unsafe { &mut *runtime } ) ) { break; }
|
||||||
|
let drag = unsafe { &*runtime }.platform.frame_drag.take();
|
||||||
|
if let Some( hit ) = drag { window::drag_frame( hwnd, hit ); }
|
||||||
|
}
|
||||||
|
drop( modal );
|
||||||
|
|
||||||
|
// SAFETY: `runtime` came from `Box::into_raw` above, and the window procedure no longer reaches it.
|
||||||
|
let mut data = unsafe { Box::from_raw( runtime ) };
|
||||||
|
data.finish_session();
|
||||||
|
if let Some( ref ctx ) = data.platform.egl_context { ctx.release_current(); }
|
||||||
|
// The EGL surface goes with the runtime, before the window it draws into.
|
||||||
|
drop( data );
|
||||||
|
destroy( hwnd );
|
||||||
|
Ok( () )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The runtime for `app` on the window, drawn through ANGLE when it can be, saving its state into `session`.
|
||||||
|
fn start<A: App>( app: A, hwnd: HWND, session: Option<StateStore> ) -> Result<Box<AppData<A>>, RunError>
|
||||||
|
{
|
||||||
|
let egl_context = match EglContext::new_windows()
|
||||||
|
{
|
||||||
|
Ok( ctx ) => Some( Arc::new( ctx ) ),
|
||||||
|
Err( reason ) =>
|
||||||
|
{
|
||||||
|
log_software_fallback( &reason );
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
crate::render::set_software_render( egl_context.is_none() );
|
||||||
|
|
||||||
|
let platform = WindowsPlatform::new( hwnd, egl_context, session );
|
||||||
|
let main = SurfaceState::new( WindowsSurfaceState::new( hwnd ), 0.0, String::new() );
|
||||||
|
let mut data = Box::new( AppData::new( app, platform, main, Sources::new( wndproc::wake ), false, None ) );
|
||||||
|
data.install_sources()?;
|
||||||
|
data.install_save_timer()?;
|
||||||
|
data.attach_window();
|
||||||
|
if data.app.start_fullscreen()
|
||||||
|
{
|
||||||
|
window::set_fullscreen( hwnd, &mut data.platform.restore.borrow_mut(), true );
|
||||||
|
}
|
||||||
|
Ok( data )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn destroy( hwnd: HWND )
|
||||||
|
{
|
||||||
|
wndproc::set_window( null_mut() );
|
||||||
|
// SAFETY: `hwnd` is the main window, which nothing uses after this.
|
||||||
|
unsafe { DestroyWindow( hwnd ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The step the window procedure runs during Windows's modal loop.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `runtime` is the loop's `AppData<A>`, which nothing borrows while this runs.
|
||||||
|
unsafe fn modal_step<A: App>( runtime: *mut c_void )
|
||||||
|
{
|
||||||
|
let data = unsafe { &mut *runtime.cast::<AppData<A>>() };
|
||||||
|
wndproc::stepping( || step( data ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One iteration of the runtime. Returns whether the app asked to exit.
|
||||||
|
fn step<A: App>( data: &mut AppData<A> ) -> bool
|
||||||
|
{
|
||||||
|
data.windows_events();
|
||||||
|
data.dispatch_sources();
|
||||||
|
if data.platform.frame_requested.take() { data.frame_presented( SurfaceFocus::Main, false ); }
|
||||||
|
|
||||||
|
data.before_draw();
|
||||||
|
let drawn = data.draw_due();
|
||||||
|
if data.platform.presented.take() { data.frame_presented( SurfaceFocus::Main, false ); }
|
||||||
|
data.after_draw( drawn );
|
||||||
|
data.exit_requested
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The longest the loop may sleep: not at all while events wait or the main surface waits to be drawn, a refresh while a frame is owed, and otherwise until the runtime's next deadline or timer.
|
||||||
|
fn timeout<A: App>( data: &AppData<A> ) -> Option<Duration>
|
||||||
|
{
|
||||||
|
let main = &data.main;
|
||||||
|
if wndproc::has_events() || ( main.configured && main.needs_redraw && !main.frame_pending ) { return Some( Duration::ZERO ); }
|
||||||
|
let now = crate::time::Instant::now();
|
||||||
|
[
|
||||||
|
data.next_wakeup(),
|
||||||
|
data.sources.next_due().map( |due| due.saturating_duration_since( now ) ),
|
||||||
|
data.platform.frame_requested.get().then_some( FRAME ),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.min()
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! Persisting [`App::save_state`] on Windows: every [`SAVE_INTERVAL`], on a clean exit, and when Windows ends the user's session, after which it terminates the process.
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::event_loop::{ AppData, RunError };
|
||||||
|
use crate::session_state::SAVE_INTERVAL;
|
||||||
|
|
||||||
|
impl<A: App> AppData<A>
|
||||||
|
{
|
||||||
|
pub( super ) fn install_save_timer( &mut self ) -> Result<(), RunError>
|
||||||
|
{
|
||||||
|
if self.platform.session.is_none() { return Ok( () ); }
|
||||||
|
self.sources.insert_timer( SAVE_INTERVAL, |data: &mut Self|
|
||||||
|
{
|
||||||
|
let state = data.app.save_state();
|
||||||
|
let store = data.platform.session.as_mut()?;
|
||||||
|
store.save_state_if_changed( state );
|
||||||
|
Some( SAVE_INTERVAL )
|
||||||
|
} )
|
||||||
|
.map( |_| () )
|
||||||
|
.map_err( |e| RunError::EventLoop( format!( "save timer: {e}" ) ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the state and mark the run as ended cleanly, once.
|
||||||
|
pub( super ) fn finish_session( &mut self )
|
||||||
|
{
|
||||||
|
let Some( mut store ) = self.platform.session.take() else { return };
|
||||||
|
store.mark_clean_exit( self.app.save_state() );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
use std::cell::{ Cell, RefCell };
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::HWND;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::egl_context::EglContext;
|
||||||
|
use crate::session_state::StateStore;
|
||||||
|
|
||||||
|
use super::window::Restore;
|
||||||
|
|
||||||
|
/// What the runtime's requests go through on Windows: the window, the EGL context and what the loop must carry out or report back to the runtime.
|
||||||
|
pub( crate ) struct WindowsPlatform<A: App>
|
||||||
|
{
|
||||||
|
pub hwnd: HWND,
|
||||||
|
/// `None` when ANGLE was not found, failed to initialise or `LTK_FORCE_SOFTWARE=1`; the window is then drawn through GDI.
|
||||||
|
pub egl_context: Option<Arc<EglContext>>,
|
||||||
|
/// Client pixels per logical pixel on each axis, which input positions are divided by. At a fractional scale the canvas may be a pixel off the client area, and is stretched to it.
|
||||||
|
pub px_per_logical: ( f64, f64 ),
|
||||||
|
pub text_input: bool,
|
||||||
|
pub pointer_inside: bool,
|
||||||
|
/// A frame reached the screen; the swap or `DwmFlush` has already waited for the display, so the loop reports it at once.
|
||||||
|
pub presented: Cell<bool>,
|
||||||
|
/// The runtime asked for a frame without drawing one; the loop reports it a refresh later.
|
||||||
|
pub frame_requested: Cell<bool>,
|
||||||
|
/// A move or resize the runtime asked for, as the hit-test code of the frame's part that does it. Windows runs it as a modal loop, so the loop starts it once the runtime's step is over.
|
||||||
|
pub frame_drag: Cell<Option<u32>>,
|
||||||
|
/// What leaving full screen goes back to.
|
||||||
|
pub restore: RefCell<Option<Restore>>,
|
||||||
|
/// `None` when session persistence is off, and once the run has ended.
|
||||||
|
pub session: Option<StateStore>,
|
||||||
|
_app: PhantomData<fn() -> A>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A: App> WindowsPlatform<A>
|
||||||
|
{
|
||||||
|
pub( crate ) fn new( hwnd: HWND, egl_context: Option<Arc<EglContext>>, session: Option<StateStore> ) -> Self
|
||||||
|
{
|
||||||
|
Self
|
||||||
|
{
|
||||||
|
hwnd,
|
||||||
|
egl_context,
|
||||||
|
session,
|
||||||
|
px_per_logical: ( 1.0, 1.0 ),
|
||||||
|
text_input: false,
|
||||||
|
pointer_inside: false,
|
||||||
|
presented: Cell::new( false ),
|
||||||
|
frame_requested: Cell::new( false ),
|
||||||
|
frame_drag: Cell::new( None ),
|
||||||
|
restore: RefCell::new( None ),
|
||||||
|
_app: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::HWND;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::egl_context::EglSurface;
|
||||||
|
use crate::event_loop::AppData;
|
||||||
|
use crate::event_loop::perf::RasterTrace;
|
||||||
|
|
||||||
|
use super::window;
|
||||||
|
|
||||||
|
/// The main surface's Windows half: the window and, when ANGLE is there, its EGL surface.
|
||||||
|
pub( crate ) struct WindowsSurfaceState
|
||||||
|
{
|
||||||
|
pub egl_surface: Option<EglSurface>,
|
||||||
|
pub hwnd: HWND,
|
||||||
|
pub raster_trace: Option<RasterTrace>,
|
||||||
|
/// The software renderer's frame in GDI's byte order, BGRA.
|
||||||
|
pub pixels: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowsSurfaceState
|
||||||
|
{
|
||||||
|
pub( crate ) fn new( hwnd: HWND ) -> Self
|
||||||
|
{
|
||||||
|
Self { egl_surface: None, hwnd, raster_trace: None, pixels: Vec::new() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A: App> AppData<A>
|
||||||
|
{
|
||||||
|
/// Draw into the window through EGL when there is a context, in software otherwise, and size the main surface to it.
|
||||||
|
pub( super ) fn attach_window( &mut self )
|
||||||
|
{
|
||||||
|
if let Some( ref ctx ) = self.platform.egl_context
|
||||||
|
{
|
||||||
|
// SAFETY: the window is destroyed only after the runtime, and with it this surface, is dropped.
|
||||||
|
match unsafe { ctx.create_native_surface( self.main.platform.hwnd ) }
|
||||||
|
{
|
||||||
|
Ok( es ) => self.main.platform.egl_surface = Some( es ),
|
||||||
|
Err( e ) => eprintln!( "[ltk] {e} — drawing the window in software" ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.fit_window();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Size the main surface to the client area at the display's scale; nothing while the window is minimised.
|
||||||
|
pub( super ) fn fit_window( &mut self )
|
||||||
|
{
|
||||||
|
let hwnd = self.main.platform.hwnd;
|
||||||
|
let ( cw, ch ) = window::client_size( hwnd );
|
||||||
|
if cw == 0 || ch == 0 { return; }
|
||||||
|
let scale = window::dpi( hwnd ) as f32 / window::BASE_DPI as f32;
|
||||||
|
let w = ( ( cw as f32 / scale ).round() as u32 ).max( 1 );
|
||||||
|
let h = ( ( ch as f32 / scale ).round() as u32 ).max( 1 );
|
||||||
|
self.platform.px_per_logical = ( cw as f64 / w as f64, ch as f64 / h as f64 );
|
||||||
|
if self.main.configured && ( w, h ) == ( self.main.width, self.main.height ) && scale == self.main.scale_factor { return; }
|
||||||
|
|
||||||
|
if scale != self.main.scale_factor
|
||||||
|
{
|
||||||
|
self.main.scale_factor = scale;
|
||||||
|
if let Some( ref mut canvas ) = self.main.canvas { canvas.set_dpi_scale( scale ); }
|
||||||
|
}
|
||||||
|
self.main.set_size( w, h );
|
||||||
|
self.on_main_resized( w, h );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! Follow the text size of Windows's accessibility settings, `TextScaleFactor` under `HKCU\Software\Microsoft\Accessibility`, in percent; unset means 100.
|
||||||
|
|
||||||
|
use std::ptr::{ null, null_mut };
|
||||||
|
|
||||||
|
use windows_sys::core::w;
|
||||||
|
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||||
|
use windows_sys::Win32::System::Registry::
|
||||||
|
{
|
||||||
|
RegCloseKey, RegNotifyChangeKeyValue, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_NOTIFY,
|
||||||
|
KEY_READ, REG_DWORD, REG_NOTIFY_CHANGE_LAST_SET,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::ChannelSender;
|
||||||
|
|
||||||
|
/// Send the current factor to `tx` at once and again whenever the user changes it, from a thread of its own.
|
||||||
|
pub( crate ) fn spawn_watcher( tx: ChannelSender<f32> )
|
||||||
|
{
|
||||||
|
let _ = std::thread::Builder::new()
|
||||||
|
.name( "ltk-text-scale".into() )
|
||||||
|
.spawn( move ||
|
||||||
|
{
|
||||||
|
let mut key: HKEY = null_mut();
|
||||||
|
// SAFETY: `key` receives the opened key, closed below.
|
||||||
|
if unsafe { RegOpenKeyExW( HKEY_CURRENT_USER, w!( "Software\\Microsoft\\Accessibility" ), 0, KEY_READ | KEY_NOTIFY, &mut key ) } != ERROR_SUCCESS
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loop
|
||||||
|
{
|
||||||
|
if tx.send( factor( key ) ).is_err() { break; }
|
||||||
|
// SAFETY: `key` is open; without an event the call blocks until a value under it changes.
|
||||||
|
if unsafe { RegNotifyChangeKeyValue( key, 0, REG_NOTIFY_CHANGE_LAST_SET, null_mut(), 0 ) } != ERROR_SUCCESS { break; }
|
||||||
|
}
|
||||||
|
// SAFETY: `key` was opened above and is not used again.
|
||||||
|
unsafe { RegCloseKey( key ); }
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
fn factor( key: HKEY ) -> f32
|
||||||
|
{
|
||||||
|
let mut percent: u32 = 100;
|
||||||
|
let mut size = std::mem::size_of::<u32>() as u32;
|
||||||
|
let mut kind = 0;
|
||||||
|
// SAFETY: `percent` has room for the `size` bytes of a `REG_DWORD`.
|
||||||
|
let read = unsafe { RegQueryValueExW( key, w!( "TextScaleFactor" ), null(), &mut kind, ( &mut percent as *mut u32 ).cast(), &mut size ) };
|
||||||
|
if read != ERROR_SUCCESS || kind != REG_DWORD { return 1.0; }
|
||||||
|
percent.clamp( 100, 225 ) as f32 / 100.0
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! The main window: its class, creating it, its size and scale, full screen, and a move or resize begun from the app's own chrome.
|
||||||
|
|
||||||
|
use std::ptr::{ null, null_mut };
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use windows_sys::core::{ w, PCWSTR };
|
||||||
|
use windows_sys::Win32::Foundation::{ GetLastError, HINSTANCE, HWND, POINT, RECT };
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{ GetMonitorInfoW, MonitorFromWindow, ScreenToClient, MONITORINFO, MONITOR_DEFAULTTONEAREST };
|
||||||
|
use windows_sys::Win32::System::DataExchange::AddClipboardFormatListener;
|
||||||
|
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
|
use windows_sys::Win32::UI::HiDpi::{ AdjustWindowRectExForDpi, GetDpiForWindow, SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 };
|
||||||
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::
|
||||||
|
{
|
||||||
|
CreateWindowExW, GetClientRect, GetCursorPos, GetWindowLongPtrW, GetWindowPlacement, LoadCursorW, LoadIconW,
|
||||||
|
RegisterClassExW, SendMessageW, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, ShowWindow,
|
||||||
|
CS_HREDRAW, CS_OWNDC, CS_VREDRAW, CW_USEDEFAULT, GWL_STYLE, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTCAPTION,
|
||||||
|
HTLEFT, HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, HWND_TOP, IDC_ARROW, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE,
|
||||||
|
SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, SW_SHOWDEFAULT, WINDOWPLACEMENT, WINDOW_STYLE, WM_NCLBUTTONDOWN,
|
||||||
|
WNDCLASSEXW, WS_MAXIMIZEBOX, WS_OVERLAPPEDWINDOW, WS_POPUP, WS_THICKFRAME,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::event_loop::cursor_shape::ResizeEdge;
|
||||||
|
use crate::input::event::PointerButton;
|
||||||
|
|
||||||
|
use super::wndproc;
|
||||||
|
|
||||||
|
const CLASS: PCWSTR = w!( "ltk" );
|
||||||
|
|
||||||
|
/// The icon an executable carries as resource 1, as `embed-resource` and `winres` put it.
|
||||||
|
const APP_ICON: PCWSTR = 1 as PCWSTR;
|
||||||
|
|
||||||
|
/// Windows's DPI for a scale of 1.
|
||||||
|
pub( super ) const BASE_DPI: u32 = 96;
|
||||||
|
|
||||||
|
/// The window's style and placement before it went full screen.
|
||||||
|
pub( crate ) struct Restore
|
||||||
|
{
|
||||||
|
style: isize,
|
||||||
|
placement: WINDOWPLACEMENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide( text: &str ) -> Vec<u16>
|
||||||
|
{
|
||||||
|
text.encode_utf16().chain( std::iter::once( 0 ) ).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_class( instance: HINSTANCE ) -> Result<(), String>
|
||||||
|
{
|
||||||
|
static ATOM: OnceLock<u16> = OnceLock::new();
|
||||||
|
let atom = *ATOM.get_or_init( ||
|
||||||
|
{
|
||||||
|
let class = WNDCLASSEXW
|
||||||
|
{
|
||||||
|
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
||||||
|
style: CS_HREDRAW | CS_VREDRAW | CS_OWNDC,
|
||||||
|
lpfnWndProc: Some( wndproc::window_proc ),
|
||||||
|
hInstance: instance,
|
||||||
|
// SAFETY: a null instance with a predefined id loads a system cursor; a missing icon resource gives null, the default icon.
|
||||||
|
hIcon: unsafe { LoadIconW( instance, APP_ICON ) },
|
||||||
|
hCursor: unsafe { LoadCursorW( null_mut(), IDC_ARROW ) },
|
||||||
|
lpszClassName: CLASS,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: `class` is fully initialised and its strings are static.
|
||||||
|
unsafe { RegisterClassExW( &class ) }
|
||||||
|
} );
|
||||||
|
if atom == 0 { Err( "RegisterClassExW failed".to_string() ) } else { Ok( () ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the main window, hidden, titled `title`, with a client area of `size` logical pixels at its display's scale, and make it the one the loop is woken through.
|
||||||
|
pub( super ) fn create( title: &str, size: ( u32, u32 ), resizable: bool ) -> Result<HWND, String>
|
||||||
|
{
|
||||||
|
// SAFETY: plain Win32 calls on handles this function creates; `title` outlives `CreateWindowExW`, which copies it.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
// Fails when the executable's manifest already chose an awareness, which then stands.
|
||||||
|
SetProcessDpiAwarenessContext( DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 );
|
||||||
|
let instance = GetModuleHandleW( null() );
|
||||||
|
register_class( instance )?;
|
||||||
|
let style = if resizable { WS_OVERLAPPEDWINDOW } else { WS_OVERLAPPEDWINDOW & !( WS_THICKFRAME | WS_MAXIMIZEBOX ) };
|
||||||
|
let title = wide( title );
|
||||||
|
let hwnd = CreateWindowExW
|
||||||
|
(
|
||||||
|
0, CLASS, title.as_ptr(), style,
|
||||||
|
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
|
||||||
|
null_mut(), null_mut(), instance, null(),
|
||||||
|
);
|
||||||
|
if hwnd.is_null() { return Err( format!( "CreateWindowExW failed with error {}", GetLastError() ) ); }
|
||||||
|
set_client_size( hwnd, size, style );
|
||||||
|
AddClipboardFormatListener( hwnd );
|
||||||
|
wndproc::set_window( hwnd );
|
||||||
|
Ok( hwnd )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the window as the shortcut that started the program asks, normal unless it says minimised or maximised.
|
||||||
|
pub( super ) fn show( hwnd: HWND )
|
||||||
|
{
|
||||||
|
// SAFETY: `hwnd` is the live main window.
|
||||||
|
unsafe { ShowWindow( hwnd, SW_SHOWDEFAULT ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_client_size( hwnd: HWND, ( w, h ): ( u32, u32 ), style: WINDOW_STYLE )
|
||||||
|
{
|
||||||
|
let dpi = dpi( hwnd );
|
||||||
|
let mut rect = RECT { left: 0, top: 0, right: to_pixels( w, dpi ), bottom: to_pixels( h, dpi ) };
|
||||||
|
// SAFETY: `hwnd` is the live main window and `rect` a valid rectangle to adjust.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
AdjustWindowRectExForDpi( &mut rect, style, 0, 0, dpi );
|
||||||
|
SetWindowPos( hwnd, null_mut(), 0, 0, rect.right - rect.left, rect.bottom - rect.top, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_pixels( logical: u32, dpi: u32 ) -> i32
|
||||||
|
{
|
||||||
|
( ( logical as u64 * dpi as u64 + BASE_DPI as u64 / 2 ) / BASE_DPI as u64 ) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The DPI of the display the window is on.
|
||||||
|
pub( super ) fn dpi( hwnd: HWND ) -> u32
|
||||||
|
{
|
||||||
|
// SAFETY: `hwnd` is the live main window.
|
||||||
|
match unsafe { GetDpiForWindow( hwnd ) }
|
||||||
|
{
|
||||||
|
0 => BASE_DPI,
|
||||||
|
dpi => dpi,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client area in physical pixels; zero while the window is minimised.
|
||||||
|
pub( super ) fn client_size( hwnd: HWND ) -> ( u32, u32 )
|
||||||
|
{
|
||||||
|
let mut rect = RECT::default();
|
||||||
|
// SAFETY: `hwnd` is the live main window and `rect` a place for the call to write.
|
||||||
|
unsafe { GetClientRect( hwnd, &mut rect ); }
|
||||||
|
( ( rect.right - rect.left ).max( 0 ) as u32, ( rect.bottom - rect.top ).max( 0 ) as u32 )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the pointer is, in client pixels.
|
||||||
|
pub( super ) fn cursor_position( hwnd: HWND ) -> ( i32, i32 )
|
||||||
|
{
|
||||||
|
let mut point = POINT::default();
|
||||||
|
// SAFETY: `hwnd` is the live main window and `point` a place for the calls to write.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
GetCursorPos( &mut point );
|
||||||
|
ScreenToClient( hwnd, &mut point );
|
||||||
|
}
|
||||||
|
( point.x, point.y )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill the display the window is on, without its frame, or go back to how it was before.
|
||||||
|
pub( super ) fn set_fullscreen( hwnd: HWND, restore: &mut Option<Restore>, fullscreen: bool )
|
||||||
|
{
|
||||||
|
// SAFETY: `hwnd` is the live main window; the structures are zeroed with their sizes set, as the calls require.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
if fullscreen && restore.is_none()
|
||||||
|
{
|
||||||
|
let mut placement: WINDOWPLACEMENT = std::mem::zeroed();
|
||||||
|
placement.length = std::mem::size_of::<WINDOWPLACEMENT>() as u32;
|
||||||
|
GetWindowPlacement( hwnd, &mut placement );
|
||||||
|
let style = GetWindowLongPtrW( hwnd, GWL_STYLE );
|
||||||
|
let mut info: MONITORINFO = std::mem::zeroed();
|
||||||
|
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
|
||||||
|
GetMonitorInfoW( MonitorFromWindow( hwnd, MONITOR_DEFAULTTONEAREST ), &mut info );
|
||||||
|
SetWindowLongPtrW( hwnd, GWL_STYLE, ( style & !( WS_OVERLAPPEDWINDOW as isize ) ) | WS_POPUP as isize );
|
||||||
|
let r = info.rcMonitor;
|
||||||
|
SetWindowPos( hwnd, HWND_TOP, r.left, r.top, r.right - r.left, r.bottom - r.top, SWP_NOOWNERZORDER | SWP_FRAMECHANGED );
|
||||||
|
*restore = Some( Restore { style, placement } );
|
||||||
|
} else if !fullscreen {
|
||||||
|
let Some( back ) = restore.take() else { return };
|
||||||
|
SetWindowLongPtrW( hwnd, GWL_STYLE, back.style );
|
||||||
|
SetWindowPlacement( hwnd, &back.placement );
|
||||||
|
SetWindowPos( hwnd, null_mut(), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hit-test code of the caption, which moves the window.
|
||||||
|
pub( super ) fn move_hit() -> u32
|
||||||
|
{
|
||||||
|
HTCAPTION
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hit-test code of the frame's part that resizes the window from `edge`.
|
||||||
|
pub( super ) fn resize_hit( edge: ResizeEdge ) -> u32
|
||||||
|
{
|
||||||
|
match edge
|
||||||
|
{
|
||||||
|
ResizeEdge::Top => HTTOP,
|
||||||
|
ResizeEdge::Bottom => HTBOTTOM,
|
||||||
|
ResizeEdge::Left => HTLEFT,
|
||||||
|
ResizeEdge::Right => HTRIGHT,
|
||||||
|
ResizeEdge::TopLeft => HTTOPLEFT,
|
||||||
|
ResizeEdge::TopRight => HTTOPRIGHT,
|
||||||
|
ResizeEdge::BottomLeft => HTBOTTOMLEFT,
|
||||||
|
ResizeEdge::BottomRight => HTBOTTOMRIGHT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move or resize the window with the held left button as a press on its frame's part `hit` would. Returns once the button is released, which Windows's modal loop consumes, so the release is queued here. Must not be called while the runtime is borrowed: the window procedure runs its steps during the modal loop.
|
||||||
|
pub( super ) fn drag_frame( hwnd: HWND, hit: u32 )
|
||||||
|
{
|
||||||
|
let mut point = POINT::default();
|
||||||
|
// SAFETY: `hwnd` is the live main window; the message carries the screen position as `WM_NCLBUTTONDOWN` does.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
GetCursorPos( &mut point );
|
||||||
|
ReleaseCapture();
|
||||||
|
let position = ( ( point.y as u16 as isize ) << 16 ) | point.x as u16 as isize;
|
||||||
|
SendMessageW( hwnd, WM_NCLBUTTONDOWN, hit as usize, position );
|
||||||
|
}
|
||||||
|
let ( x, y ) = cursor_position( hwnd );
|
||||||
|
wndproc::push( wndproc::Event::Mouse { x, y, kind: wndproc::MouseKind::Release( PointerButton::Left ) } );
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! The window procedure and the queue it fills.
|
||||||
|
//!
|
||||||
|
//! The procedure never touches the runtime: it turns what Windows sends into [`Event`]s, read when the message comes, since the modifiers held or a finger's position are only right then, and queues them for the loop, which hands them to the runtime after each pump. While Windows runs a modal loop of its own, to move or resize the window, the loop is stalled inside `DispatchMessageW`; the procedure then runs one of the loop's steps itself on every resize, wake-up and tick of a timer, unless a step is already running, which is when one of the runtime's own requests made Windows send the message.
|
||||||
|
|
||||||
|
use std::cell::{ Cell, RefCell };
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::ptr::{ null, null_mut };
|
||||||
|
use std::sync::atomic::{ AtomicBool, AtomicPtr, Ordering };
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::{ HWND, LPARAM, LRESULT, POINT, RECT, WPARAM };
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{ ScreenToClient, ValidateRect };
|
||||||
|
use windows_sys::Win32::System::SystemServices::{ MK_LBUTTON, MK_MBUTTON, MK_RBUTTON, MK_XBUTTON1, MK_XBUTTON2 };
|
||||||
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ ReleaseCapture, SetCapture, TrackMouseEvent, TME_LEAVE, TRACKMOUSEEVENT, VK_F4, VK_SPACE };
|
||||||
|
use windows_sys::Win32::UI::Input::Pointer::GetPointerType;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::
|
||||||
|
{
|
||||||
|
DefWindowProcW, DispatchMessageW, GetMessageTime, KillTimer, MsgWaitForMultipleObjectsEx, PeekMessageW, PostMessageW,
|
||||||
|
SetCursor, SetTimer, SetWindowPos, TranslateMessage, HTCLIENT, MSG, MWMO_INPUTAVAILABLE, PM_REMOVE, PT_TOUCH,
|
||||||
|
QS_ALLINPUT, SIZE_MINIMIZED, SWP_NOACTIVATE, SWP_NOZORDER, WM_APP, WM_CHAR, WM_CLIPBOARDUPDATE,
|
||||||
|
WM_CLOSE, WM_DEADCHAR, WM_ENDSESSION, WM_DPICHANGED, WM_ENTERSIZEMOVE, WM_ERASEBKGND, WM_EXITSIZEMOVE, WM_KEYDOWN, WM_KEYUP,
|
||||||
|
WM_KILLFOCUS, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_PAINT, WM_POINTERCAPTURECHANGED, WM_POINTERDOWN, WM_POINTERUP, WM_POINTERUPDATE,
|
||||||
|
WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SETCURSOR, WM_SETFOCUS, WM_SIZE, WM_SYSCHAR, WM_SYSDEADCHAR, WM_SYSKEYDOWN,
|
||||||
|
WM_SYSKEYUP, WM_TIMER, WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::input::event::PointerButton;
|
||||||
|
|
||||||
|
use super::{ cursor, keys };
|
||||||
|
use super::keys::Modifiers;
|
||||||
|
|
||||||
|
/// What the window procedure queues for the runtime. Positions are in client pixels.
|
||||||
|
pub( super ) enum Event
|
||||||
|
{
|
||||||
|
/// The client area or the display's DPI changed.
|
||||||
|
Resized,
|
||||||
|
/// The window was minimised, which leaves it no client area to draw.
|
||||||
|
Minimized,
|
||||||
|
/// Windows asked for the window to be painted again.
|
||||||
|
Exposed,
|
||||||
|
Close,
|
||||||
|
/// The user's session is ending; Windows terminates the process once the message is answered.
|
||||||
|
EndSession,
|
||||||
|
Focus( bool ),
|
||||||
|
ClipboardChanged,
|
||||||
|
Mouse
|
||||||
|
{
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
kind: MouseKind,
|
||||||
|
},
|
||||||
|
/// `delta` in Windows's units, 120 to a detent, positive away from the user or to the right.
|
||||||
|
Wheel
|
||||||
|
{
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
delta: i16,
|
||||||
|
horizontal: bool,
|
||||||
|
},
|
||||||
|
Touch
|
||||||
|
{
|
||||||
|
id: u32,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
time: u32,
|
||||||
|
phase: TouchKind,
|
||||||
|
},
|
||||||
|
TouchCancel,
|
||||||
|
/// A key pressed or released, with what it typed once dead keys were applied.
|
||||||
|
Key
|
||||||
|
{
|
||||||
|
vk: u16,
|
||||||
|
/// The scan code, with `0xE000` added for an extended key.
|
||||||
|
scan: u32,
|
||||||
|
pressed: bool,
|
||||||
|
repeat: bool,
|
||||||
|
text: String,
|
||||||
|
modifiers: Modifiers,
|
||||||
|
},
|
||||||
|
/// Text that came with no key of its own, as the input method's does.
|
||||||
|
Text( String ),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( super ) enum MouseKind
|
||||||
|
{
|
||||||
|
Enter,
|
||||||
|
Leave,
|
||||||
|
Move,
|
||||||
|
Press( PointerButton ),
|
||||||
|
Release( PointerButton ),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( super ) enum TouchKind
|
||||||
|
{
|
||||||
|
Down,
|
||||||
|
Move,
|
||||||
|
Up,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posted to wake the loop when a message reaches one of the runtime's channels.
|
||||||
|
const WM_WAKE: u32 = WM_APP + 1;
|
||||||
|
|
||||||
|
/// Ticks a step of the loop while Windows's modal loop runs, once a refresh at 60 Hz.
|
||||||
|
const MODAL_TIMER: usize = 1;
|
||||||
|
const MODAL_TICK_MS: u32 = 16;
|
||||||
|
|
||||||
|
/// `IS_POINTER_CANCELED_WPARAM`: the touch ended without the finger lifting.
|
||||||
|
const POINTER_CANCELED: usize = 0x8000;
|
||||||
|
|
||||||
|
const INFINITE: u32 = u32::MAX;
|
||||||
|
|
||||||
|
/// windows-sys puts it in `Win32::UI::Controls`, which nothing else here needs.
|
||||||
|
const WM_MOUSELEAVE: u32 = 0x02A3;
|
||||||
|
|
||||||
|
static WINDOW: AtomicPtr<c_void> = AtomicPtr::new( null_mut() );
|
||||||
|
static WAKE_POSTED: AtomicBool = AtomicBool::new( false );
|
||||||
|
|
||||||
|
/// A step of the loop the procedure can run: the runtime and the function that steps it.
|
||||||
|
type Step = ( *mut c_void, unsafe fn( *mut c_void ) );
|
||||||
|
|
||||||
|
thread_local!
|
||||||
|
{
|
||||||
|
static EVENTS: RefCell<VecDeque<Event>> = const { RefCell::new( VecDeque::new() ) };
|
||||||
|
static STEP: Cell<Option<Step>> = const { Cell::new( None ) };
|
||||||
|
static STEPPING: Cell<bool> = const { Cell::new( false ) };
|
||||||
|
static MODAL: Cell<bool> = const { Cell::new( false ) };
|
||||||
|
static TRACKING: Cell<bool> = const { Cell::new( false ) };
|
||||||
|
static SURROGATE: Cell<Option<u16>> = const { Cell::new( None ) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window [`wake`] posts to.
|
||||||
|
pub( super ) fn set_window( hwnd: HWND )
|
||||||
|
{
|
||||||
|
WINDOW.store( hwnd, Ordering::Release );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake the loop from any thread; called after every message sent to one of the runtime's channels.
|
||||||
|
pub( super ) fn wake()
|
||||||
|
{
|
||||||
|
let hwnd = WINDOW.load( Ordering::Acquire );
|
||||||
|
if hwnd.is_null() || WAKE_POSTED.swap( true, Ordering::AcqRel ) { return; }
|
||||||
|
// SAFETY: posting to a window that may be gone fails harmlessly.
|
||||||
|
if unsafe { PostMessageW( hwnd, WM_WAKE, 0, 0 ) } == 0
|
||||||
|
{
|
||||||
|
WAKE_POSTED.store( false, Ordering::Release );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The step the procedure runs during a modal loop, until the guard is dropped.
|
||||||
|
pub( super ) struct ModalStep;
|
||||||
|
|
||||||
|
impl ModalStep
|
||||||
|
{
|
||||||
|
/// `runtime` must stay valid, and `step` sound to call on it, until the guard is dropped.
|
||||||
|
pub( super ) fn install( runtime: *mut c_void, step: unsafe fn( *mut c_void ) ) -> Self
|
||||||
|
{
|
||||||
|
STEP.set( Some( ( runtime, step ) ) );
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ModalStep
|
||||||
|
{
|
||||||
|
fn drop( &mut self )
|
||||||
|
{
|
||||||
|
STEP.set( None );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f`, which holds the runtime, so that the procedure only queues what Windows sends meanwhile.
|
||||||
|
pub( super ) fn stepping<R>( f: impl FnOnce() -> R ) -> R
|
||||||
|
{
|
||||||
|
let outer = STEPPING.replace( true );
|
||||||
|
let result = f();
|
||||||
|
STEPPING.set( outer );
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step_in_modal_loop()
|
||||||
|
{
|
||||||
|
if MODAL.get() { step_now(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a step of the loop from the window procedure, unless one is already running.
|
||||||
|
fn step_now()
|
||||||
|
{
|
||||||
|
if STEPPING.get() { return; }
|
||||||
|
let Some( ( runtime, step ) ) = STEP.get() else { return };
|
||||||
|
// SAFETY: `ModalStep::install`'s contract; no step is running, so nothing else holds the runtime.
|
||||||
|
unsafe { step( runtime ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( super ) fn push( event: Event )
|
||||||
|
{
|
||||||
|
EVENTS.with_borrow_mut( |events| events.push_back( event ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( super ) fn take_events() -> VecDeque<Event>
|
||||||
|
{
|
||||||
|
EVENTS.with_borrow_mut( std::mem::take )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( super ) fn has_events() -> bool
|
||||||
|
{
|
||||||
|
EVENTS.with_borrow( |events| !events.is_empty() )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep until a message arrives or `timeout` passes.
|
||||||
|
pub( super ) fn wait( timeout: Option<Duration> )
|
||||||
|
{
|
||||||
|
let ms = timeout.map_or( INFINITE, |t| u32::try_from( t.as_micros().div_ceil( 1000 ) ).unwrap_or( INFINITE - 1 ) );
|
||||||
|
if ms == 0 { return; }
|
||||||
|
// SAFETY: no handles are passed; the call only waits on the thread's message queue.
|
||||||
|
unsafe { MsgWaitForMultipleObjectsEx( 0, null(), ms, QS_ALLINPUT, MWMO_INPUTAVAILABLE ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand every waiting message to the window procedure.
|
||||||
|
pub( super ) fn pump()
|
||||||
|
{
|
||||||
|
let mut msg = MSG::default();
|
||||||
|
// SAFETY: `msg` is filled by `PeekMessageW` before it is translated and dispatched.
|
||||||
|
while unsafe { PeekMessageW( &mut msg, null_mut(), 0, 0, PM_REMOVE ) } != 0
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
TranslateMessage( &msg );
|
||||||
|
DispatchMessageW( &msg );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn x_of( lparam: LPARAM ) -> i32
|
||||||
|
{
|
||||||
|
( lparam & 0xFFFF ) as u16 as i16 as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn y_of( lparam: LPARAM ) -> i32
|
||||||
|
{
|
||||||
|
( ( lparam >> 16 ) & 0xFFFF ) as u16 as i16 as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn high_word( wparam: WPARAM ) -> u16
|
||||||
|
{
|
||||||
|
( wparam >> 16 ) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A position in screen pixels, as wheel and pointer messages carry it, in client pixels.
|
||||||
|
fn client_point( hwnd: HWND, lparam: LPARAM ) -> ( i32, i32 )
|
||||||
|
{
|
||||||
|
let mut point = POINT { x: x_of( lparam ), y: y_of( lparam ) };
|
||||||
|
// SAFETY: `hwnd` is the window the message came for.
|
||||||
|
unsafe { ScreenToClient( hwnd, &mut point ); }
|
||||||
|
( point.x, point.y )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window procedure of every ltk window.
|
||||||
|
pub( super ) unsafe extern "system" fn window_proc( hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM ) -> LRESULT
|
||||||
|
{
|
||||||
|
match msg
|
||||||
|
{
|
||||||
|
WM_CLOSE => push( Event::Close ),
|
||||||
|
WM_ENDSESSION if wparam != 0 =>
|
||||||
|
{
|
||||||
|
push( Event::EndSession );
|
||||||
|
step_now();
|
||||||
|
}
|
||||||
|
WM_SIZE if wparam as u32 == SIZE_MINIMIZED => push( Event::Minimized ),
|
||||||
|
WM_SIZE =>
|
||||||
|
{
|
||||||
|
push( Event::Resized );
|
||||||
|
step_in_modal_loop();
|
||||||
|
}
|
||||||
|
WM_DPICHANGED =>
|
||||||
|
{
|
||||||
|
// SAFETY: `WM_DPICHANGED` carries the rectangle Windows suggests for the new DPI.
|
||||||
|
let r = unsafe { &*( lparam as *const RECT ) };
|
||||||
|
unsafe { SetWindowPos( hwnd, null_mut(), r.left, r.top, r.right - r.left, r.bottom - r.top, SWP_NOZORDER | SWP_NOACTIVATE ); }
|
||||||
|
push( Event::Resized );
|
||||||
|
}
|
||||||
|
WM_PAINT =>
|
||||||
|
{
|
||||||
|
unsafe { ValidateRect( hwnd, null() ); }
|
||||||
|
push( Event::Exposed );
|
||||||
|
step_in_modal_loop();
|
||||||
|
}
|
||||||
|
WM_ERASEBKGND => return 1,
|
||||||
|
WM_ENTERSIZEMOVE =>
|
||||||
|
{
|
||||||
|
MODAL.set( true );
|
||||||
|
unsafe { SetTimer( hwnd, MODAL_TIMER, MODAL_TICK_MS, None ); }
|
||||||
|
}
|
||||||
|
WM_EXITSIZEMOVE =>
|
||||||
|
{
|
||||||
|
MODAL.set( false );
|
||||||
|
unsafe { KillTimer( hwnd, MODAL_TIMER ); }
|
||||||
|
}
|
||||||
|
WM_TIMER if wparam == MODAL_TIMER => step_in_modal_loop(),
|
||||||
|
WM_WAKE =>
|
||||||
|
{
|
||||||
|
WAKE_POSTED.store( false, Ordering::Release );
|
||||||
|
step_in_modal_loop();
|
||||||
|
}
|
||||||
|
WM_SETFOCUS => push( Event::Focus( true ) ),
|
||||||
|
WM_KILLFOCUS => push( Event::Focus( false ) ),
|
||||||
|
WM_CLIPBOARDUPDATE => push( Event::ClipboardChanged ),
|
||||||
|
WM_SETCURSOR if ( lparam & 0xFFFF ) as u32 == HTCLIENT =>
|
||||||
|
{
|
||||||
|
unsafe { SetCursor( cursor::current() ); }
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
WM_MOUSEMOVE =>
|
||||||
|
{
|
||||||
|
if !TRACKING.replace( true )
|
||||||
|
{
|
||||||
|
let mut track = TRACKMOUSEEVENT { cbSize: std::mem::size_of::<TRACKMOUSEEVENT>() as u32, dwFlags: TME_LEAVE, hwndTrack: hwnd, dwHoverTime: 0 };
|
||||||
|
unsafe { TrackMouseEvent( &mut track ); }
|
||||||
|
push( Event::Mouse { x: x_of( lparam ), y: y_of( lparam ), kind: MouseKind::Enter } );
|
||||||
|
}
|
||||||
|
push( Event::Mouse { x: x_of( lparam ), y: y_of( lparam ), kind: MouseKind::Move } );
|
||||||
|
}
|
||||||
|
WM_MOUSELEAVE =>
|
||||||
|
{
|
||||||
|
TRACKING.set( false );
|
||||||
|
push( Event::Mouse { x: 0, y: 0, kind: MouseKind::Leave } );
|
||||||
|
}
|
||||||
|
WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN | WM_XBUTTONDOWN =>
|
||||||
|
{
|
||||||
|
unsafe { SetCapture( hwnd ); }
|
||||||
|
push( Event::Mouse { x: x_of( lparam ), y: y_of( lparam ), kind: MouseKind::Press( button( msg, wparam ) ) } );
|
||||||
|
if msg == WM_XBUTTONDOWN { return 1; }
|
||||||
|
}
|
||||||
|
WM_LBUTTONUP | WM_RBUTTONUP | WM_MBUTTONUP | WM_XBUTTONUP =>
|
||||||
|
{
|
||||||
|
if wparam as u32 & ( MK_LBUTTON | MK_RBUTTON | MK_MBUTTON | MK_XBUTTON1 | MK_XBUTTON2 ) == 0
|
||||||
|
{
|
||||||
|
unsafe { ReleaseCapture(); }
|
||||||
|
}
|
||||||
|
push( Event::Mouse { x: x_of( lparam ), y: y_of( lparam ), kind: MouseKind::Release( button( msg, wparam ) ) } );
|
||||||
|
if msg == WM_XBUTTONUP { return 1; }
|
||||||
|
}
|
||||||
|
WM_MOUSEWHEEL | WM_MOUSEHWHEEL =>
|
||||||
|
{
|
||||||
|
let ( x, y ) = client_point( hwnd, lparam );
|
||||||
|
push( Event::Wheel { x, y, delta: high_word( wparam ) as i16, horizontal: msg == WM_MOUSEHWHEEL } );
|
||||||
|
}
|
||||||
|
WM_POINTERDOWN | WM_POINTERUPDATE | WM_POINTERUP =>
|
||||||
|
{
|
||||||
|
let id = ( wparam & 0xFFFF ) as u32;
|
||||||
|
let mut kind = 0;
|
||||||
|
// SAFETY: `id` is the pointer the message is about.
|
||||||
|
if unsafe { GetPointerType( id, &mut kind ) } == 0 || kind != PT_TOUCH
|
||||||
|
{
|
||||||
|
return unsafe { DefWindowProcW( hwnd, msg, wparam, lparam ) };
|
||||||
|
}
|
||||||
|
if msg == WM_POINTERUP && ( wparam >> 16 ) & POINTER_CANCELED != 0
|
||||||
|
{
|
||||||
|
push( Event::TouchCancel );
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let ( x, y ) = client_point( hwnd, lparam );
|
||||||
|
let phase = match msg
|
||||||
|
{
|
||||||
|
WM_POINTERDOWN => TouchKind::Down,
|
||||||
|
WM_POINTERUPDATE => TouchKind::Move,
|
||||||
|
_ => TouchKind::Up,
|
||||||
|
};
|
||||||
|
push( Event::Touch { id, x, y, time: unsafe { GetMessageTime() } as u32, phase } );
|
||||||
|
}
|
||||||
|
WM_POINTERCAPTURECHANGED => push( Event::TouchCancel ),
|
||||||
|
WM_KEYDOWN | WM_SYSKEYDOWN =>
|
||||||
|
{
|
||||||
|
let vk = wparam as u16;
|
||||||
|
// Alt+F4 closes the window and Alt+Space, through its `WM_SYSCHAR`, opens its menu, as in any other.
|
||||||
|
if msg == WM_SYSKEYDOWN && ( vk == VK_F4 || vk == VK_SPACE )
|
||||||
|
{
|
||||||
|
return unsafe { DefWindowProcW( hwnd, msg, wparam, lparam ) };
|
||||||
|
}
|
||||||
|
let text = typed_text( hwnd );
|
||||||
|
push( key( vk, lparam, true, text ) );
|
||||||
|
}
|
||||||
|
WM_KEYUP | WM_SYSKEYUP => push( key( wparam as u16, lparam, false, String::new() ) ),
|
||||||
|
WM_CHAR =>
|
||||||
|
{
|
||||||
|
if let Some( text ) = utf16_unit( wparam as u16 ) { push( Event::Text( text ) ); }
|
||||||
|
}
|
||||||
|
WM_SYSCHAR if wparam == VK_SPACE as usize => return unsafe { DefWindowProcW( hwnd, msg, wparam, lparam ) },
|
||||||
|
WM_SYSCHAR | WM_DEADCHAR | WM_SYSDEADCHAR => {}
|
||||||
|
_ => return unsafe { DefWindowProcW( hwnd, msg, wparam, lparam ) },
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button( msg: u32, wparam: WPARAM ) -> PointerButton
|
||||||
|
{
|
||||||
|
match msg
|
||||||
|
{
|
||||||
|
WM_LBUTTONDOWN | WM_LBUTTONUP => PointerButton::Left,
|
||||||
|
WM_RBUTTONDOWN | WM_RBUTTONUP => PointerButton::Right,
|
||||||
|
WM_MBUTTONDOWN | WM_MBUTTONUP => PointerButton::Middle,
|
||||||
|
_ if high_word( wparam ) == XBUTTON1 => PointerButton::Back,
|
||||||
|
_ => PointerButton::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key( vk: u16, lparam: LPARAM, pressed: bool, text: String ) -> Event
|
||||||
|
{
|
||||||
|
let scan = ( ( lparam >> 16 ) & 0xFF ) as u32;
|
||||||
|
let extended = ( lparam >> 24 ) & 1 != 0;
|
||||||
|
Event::Key
|
||||||
|
{
|
||||||
|
vk,
|
||||||
|
scan: if extended { scan | 0xE000 } else { scan },
|
||||||
|
pressed,
|
||||||
|
repeat: pressed && ( lparam >> 30 ) & 1 != 0,
|
||||||
|
text,
|
||||||
|
modifiers: keys::held(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the key just pressed typed: `TranslateMessage` has already posted its characters, which come before any other key's. A dead key's own character, and what Alt with a key types, are dropped.
|
||||||
|
fn typed_text( hwnd: HWND ) -> String
|
||||||
|
{
|
||||||
|
let mut units = Vec::new();
|
||||||
|
let mut msg = MSG::default();
|
||||||
|
// SAFETY: only this window's character messages are taken out of the queue.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
while PeekMessageW( &mut msg, hwnd, WM_CHAR, WM_DEADCHAR, PM_REMOVE ) != 0
|
||||||
|
{
|
||||||
|
if msg.message == WM_CHAR { units.push( msg.wParam as u16 ); }
|
||||||
|
}
|
||||||
|
while PeekMessageW( &mut msg, hwnd, WM_SYSCHAR, WM_SYSDEADCHAR, PM_REMOVE ) != 0 {}
|
||||||
|
}
|
||||||
|
String::from_utf16_lossy( &units )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A character sent alone, pairing a high surrogate with the low one after it.
|
||||||
|
fn utf16_unit( unit: u16 ) -> Option<String>
|
||||||
|
{
|
||||||
|
match unit
|
||||||
|
{
|
||||||
|
0xD800..=0xDBFF =>
|
||||||
|
{
|
||||||
|
SURROGATE.set( Some( unit ) );
|
||||||
|
None
|
||||||
|
}
|
||||||
|
0xDC00..=0xDFFF =>
|
||||||
|
{
|
||||||
|
let high = SURROGATE.take()?;
|
||||||
|
Some( String::from_utf16_lossy( &[ high, unit ] ) )
|
||||||
|
}
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
SURROGATE.set( None );
|
||||||
|
Some( String::from_utf16_lossy( &[ unit ] ) )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! libcups, opened at run time so that an app without CUPS still runs and only saves to PDF. Every call blocks.
|
||||||
|
|
||||||
|
use std::ffi::{ c_char, c_int, c_uint, c_void, CStr, CString };
|
||||||
|
use std::ptr;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use super::{ Job, JobState, Printer, PrinterState, PrintOptions };
|
||||||
|
|
||||||
|
type Http = c_void;
|
||||||
|
type DestInfo = c_void;
|
||||||
|
|
||||||
|
#[ repr( C ) ]
|
||||||
|
struct CupsOption
|
||||||
|
{
|
||||||
|
name: *mut c_char,
|
||||||
|
value: *mut c_char,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ repr( C ) ]
|
||||||
|
struct CupsDest
|
||||||
|
{
|
||||||
|
name: *mut c_char,
|
||||||
|
instance: *mut c_char,
|
||||||
|
is_default: c_int,
|
||||||
|
num_options: c_int,
|
||||||
|
options: *mut CupsOption,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ repr( C ) ]
|
||||||
|
struct CupsJob
|
||||||
|
{
|
||||||
|
id: c_int,
|
||||||
|
dest: *mut c_char,
|
||||||
|
title: *mut c_char,
|
||||||
|
user: *mut c_char,
|
||||||
|
format: *mut c_char,
|
||||||
|
state: c_int,
|
||||||
|
size: c_int,
|
||||||
|
priority: c_int,
|
||||||
|
completed_time: libc::time_t,
|
||||||
|
creation_time: libc::time_t,
|
||||||
|
processing_time: libc::time_t,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lib
|
||||||
|
{
|
||||||
|
get_dests: unsafe extern "C" fn( *mut Http, *mut *mut CupsDest ) -> c_int,
|
||||||
|
set_dests: unsafe extern "C" fn( *mut Http, c_int, *mut CupsDest ) -> c_int,
|
||||||
|
free_dests: unsafe extern "C" fn( c_int, *mut CupsDest ),
|
||||||
|
get_named_dest: unsafe extern "C" fn( *mut Http, *const c_char, *const c_char ) -> *mut CupsDest,
|
||||||
|
get_option: unsafe extern "C" fn( *const c_char, c_int, *mut CupsOption ) -> *const c_char,
|
||||||
|
add_option: unsafe extern "C" fn( *const c_char, *const c_char, c_int, *mut *mut CupsOption ) -> c_int,
|
||||||
|
free_options: unsafe extern "C" fn( c_int, *mut CupsOption ),
|
||||||
|
connect_dest: unsafe extern "C" fn( *mut CupsDest, c_uint, c_int, *mut c_int, *mut c_char, usize, *mut c_void, *mut c_void ) -> *mut Http,
|
||||||
|
copy_dest_info: unsafe extern "C" fn( *mut Http, *mut CupsDest ) -> *mut DestInfo,
|
||||||
|
free_dest_info: unsafe extern "C" fn( *mut DestInfo ),
|
||||||
|
create_dest_job: unsafe extern "C" fn( *mut Http, *mut CupsDest, *mut DestInfo, *mut c_int, *const c_char, c_int, *mut CupsOption ) -> c_int,
|
||||||
|
start_dest_document: unsafe extern "C" fn( *mut Http, *mut CupsDest, *mut DestInfo, c_int, *const c_char, *const c_char, c_int, *mut CupsOption, c_int ) -> c_int,
|
||||||
|
write_request_data: unsafe extern "C" fn( *mut Http, *const c_char, usize ) -> c_int,
|
||||||
|
finish_dest_document: unsafe extern "C" fn( *mut Http, *mut CupsDest, *mut DestInfo ) -> c_int,
|
||||||
|
cancel_dest_job: unsafe extern "C" fn( *mut Http, *mut CupsDest, c_int ) -> c_int,
|
||||||
|
get_jobs: unsafe extern "C" fn( *mut Http, *mut *mut CupsJob, *const c_char, c_int, c_int ) -> c_int,
|
||||||
|
free_jobs: unsafe extern "C" fn( c_int, *mut CupsJob ),
|
||||||
|
cancel_job: unsafe extern "C" fn( *mut Http, *const c_char, c_int, c_int ) -> c_int,
|
||||||
|
http_close: unsafe extern "C" fn( *mut Http ),
|
||||||
|
last_error_string: unsafe extern "C" fn() -> *const c_char,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handle is never closed, so the symbols stay valid.
|
||||||
|
unsafe impl Send for Lib {}
|
||||||
|
unsafe impl Sync for Lib {}
|
||||||
|
|
||||||
|
const HTTP_STATUS_CONTINUE: c_int = 100;
|
||||||
|
const CHUNK: usize = 64 * 1024;
|
||||||
|
const CONNECT_TIMEOUT_MS: c_int = 30_000;
|
||||||
|
|
||||||
|
fn lib() -> Result<&'static Lib, String>
|
||||||
|
{
|
||||||
|
static LIB: OnceLock<Option<Lib>> = OnceLock::new();
|
||||||
|
LIB.get_or_init( || unsafe { open() } ).as_ref().ok_or_else( || rust_i18n::t!( "print.no_cups" ).to_string() )
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn open() -> Option<Lib>
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let handle = libc::dlopen( c"libcups.so.2".as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL );
|
||||||
|
if handle.is_null() { return None; }
|
||||||
|
macro_rules! sym
|
||||||
|
{
|
||||||
|
( $name:literal ) =>
|
||||||
|
{{
|
||||||
|
let symbol = libc::dlsym( handle, $name.as_ptr() );
|
||||||
|
if symbol.is_null() { return None; }
|
||||||
|
std::mem::transmute::<*mut c_void, _>( symbol )
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
Some( Lib
|
||||||
|
{
|
||||||
|
get_dests: sym!( c"cupsGetDests2" ),
|
||||||
|
set_dests: sym!( c"cupsSetDests2" ),
|
||||||
|
free_dests: sym!( c"cupsFreeDests" ),
|
||||||
|
get_named_dest: sym!( c"cupsGetNamedDest" ),
|
||||||
|
get_option: sym!( c"cupsGetOption" ),
|
||||||
|
add_option: sym!( c"cupsAddOption" ),
|
||||||
|
free_options: sym!( c"cupsFreeOptions" ),
|
||||||
|
connect_dest: sym!( c"cupsConnectDest" ),
|
||||||
|
copy_dest_info: sym!( c"cupsCopyDestInfo" ),
|
||||||
|
free_dest_info: sym!( c"cupsFreeDestInfo" ),
|
||||||
|
create_dest_job: sym!( c"cupsCreateDestJob" ),
|
||||||
|
start_dest_document: sym!( c"cupsStartDestDocument" ),
|
||||||
|
write_request_data: sym!( c"cupsWriteRequestData" ),
|
||||||
|
finish_dest_document: sym!( c"cupsFinishDestDocument" ),
|
||||||
|
cancel_dest_job: sym!( c"cupsCancelDestJob" ),
|
||||||
|
get_jobs: sym!( c"cupsGetJobs2" ),
|
||||||
|
free_jobs: sym!( c"cupsFreeJobs" ),
|
||||||
|
cancel_job: sym!( c"cupsCancelJob2" ),
|
||||||
|
http_close: sym!( c"httpClose" ),
|
||||||
|
last_error_string: sym!( c"cupsLastErrorString" ),
|
||||||
|
} )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ipp_ok( status: c_int ) -> bool
|
||||||
|
{
|
||||||
|
( 0..0x100 ).contains( &status )
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn string( s: *const c_char ) -> String
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
if s.is_null() { String::new() } else { CStr::from_ptr( s ).to_string_lossy().into_owned() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn last_error( lib: &Lib ) -> String
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let message = string( ( lib.last_error_string )() );
|
||||||
|
if message.is_empty() { rust_i18n::t!( "print.failed" ).to_string() } else { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn c( s: &str ) -> CString
|
||||||
|
{
|
||||||
|
CString::new( s.replace( '\0', "" ) ).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn option( lib: &Lib, dest: &CupsDest, name: &CStr ) -> String
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
string( ( lib.get_option )( name.as_ptr(), dest.num_options, dest.options ) )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn printer( lib: &Lib, dest: &CupsDest ) -> Printer
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let name = string( dest.name );
|
||||||
|
let instance = ( !dest.instance.is_null() ).then( || string( dest.instance ) );
|
||||||
|
let description = option( lib, dest, c"printer-info" );
|
||||||
|
let state = match option( lib, dest, c"printer-state" ).as_str()
|
||||||
|
{
|
||||||
|
"3" => PrinterState::Idle,
|
||||||
|
"4" => PrinterState::Printing,
|
||||||
|
"5" => PrinterState::Stopped,
|
||||||
|
_ => PrinterState::Unknown,
|
||||||
|
};
|
||||||
|
let reasons = option( lib, dest, c"printer-state-reasons" );
|
||||||
|
Printer
|
||||||
|
{
|
||||||
|
description: if description.is_empty() { name.clone() } else { description },
|
||||||
|
location: option( lib, dest, c"printer-location" ),
|
||||||
|
model: option( lib, dest, c"printer-make-and-model" ),
|
||||||
|
accepting: option( lib, dest, c"printer-is-accepting-jobs" ) != "false",
|
||||||
|
reasons: if reasons == "none" { String::new() } else { reasons },
|
||||||
|
default: dest.is_default != 0,
|
||||||
|
name,
|
||||||
|
instance,
|
||||||
|
state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The queues and the printers found on the network, the default first.
|
||||||
|
pub fn printers() -> Result<Vec<Printer>, String>
|
||||||
|
{
|
||||||
|
let lib = lib()?;
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let mut dests = ptr::null_mut();
|
||||||
|
let count = ( lib.get_dests )( ptr::null_mut(), &mut dests );
|
||||||
|
let mut list: Vec<Printer> = ( 0..count.max( 0 ) as usize ).map( |i| printer( lib, &*dests.add( i ) ) ).collect();
|
||||||
|
( lib.free_dests )( count, dests );
|
||||||
|
list.sort_by( |a, b| b.default.cmp( &a.default ).then_with( || a.description.to_lowercase().cmp( &b.description.to_lowercase() ) ) );
|
||||||
|
Ok( list )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's default printer, kept by CUPS in their `lpoptions`.
|
||||||
|
pub fn set_default( name: &str, instance: Option<&str> ) -> Result<(), String>
|
||||||
|
{
|
||||||
|
let lib = lib()?;
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let mut dests = ptr::null_mut();
|
||||||
|
let count = ( lib.get_dests )( ptr::null_mut(), &mut dests );
|
||||||
|
let mut found = false;
|
||||||
|
for i in 0..count.max( 0 ) as usize
|
||||||
|
{
|
||||||
|
let dest = &mut *dests.add( i );
|
||||||
|
let matches = string( dest.name ) == name && ( !dest.instance.is_null() ).then( || string( dest.instance ) ).as_deref() == instance;
|
||||||
|
dest.is_default = matches as c_int;
|
||||||
|
found |= matches;
|
||||||
|
}
|
||||||
|
let written = found && ( lib.set_dests )( ptr::null_mut(), count, dests ) == 0;
|
||||||
|
let error = if written { None } else { Some( last_error( lib ) ) };
|
||||||
|
( lib.free_dests )( count, dests );
|
||||||
|
error.map_or( Ok( () ), Err )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends `pdf` as one job; the job's id.
|
||||||
|
pub fn print( printer: &Printer, title: &str, options: &PrintOptions, pdf: &[ u8 ] ) -> Result<i32, String>
|
||||||
|
{
|
||||||
|
let lib = lib()?;
|
||||||
|
let name = c( &printer.name );
|
||||||
|
let instance = printer.instance.as_deref().map( c );
|
||||||
|
let title = c( title );
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let dest = ( lib.get_named_dest )( ptr::null_mut(), name.as_ptr(), instance.as_ref().map_or( ptr::null(), |i| i.as_ptr() ) );
|
||||||
|
if dest.is_null() { return Err( last_error( lib ) ); }
|
||||||
|
let mut resource = [ 0 as c_char; 1024 ];
|
||||||
|
let http = ( lib.connect_dest )( dest, 0, CONNECT_TIMEOUT_MS, ptr::null_mut(), resource.as_mut_ptr(), resource.len(), ptr::null_mut(), ptr::null_mut() );
|
||||||
|
if http.is_null()
|
||||||
|
{
|
||||||
|
let error = last_error( lib );
|
||||||
|
( lib.free_dests )( 1, dest );
|
||||||
|
return Err( error );
|
||||||
|
}
|
||||||
|
let info = ( lib.copy_dest_info )( http, dest );
|
||||||
|
let result = send( lib, http, dest, info, &title, options, pdf );
|
||||||
|
if !info.is_null() { ( lib.free_dest_info )( info ); }
|
||||||
|
( lib.http_close )( http );
|
||||||
|
( lib.free_dests )( 1, dest );
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn send( lib: &Lib, http: *mut Http, dest: *mut CupsDest, info: *mut DestInfo, title: &CStr, options: &PrintOptions, pdf: &[ u8 ] ) -> Result<i32, String>
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let mut count = 0;
|
||||||
|
let mut list = ptr::null_mut();
|
||||||
|
for ( key, value ) in ipp_options( options )
|
||||||
|
{
|
||||||
|
let ( key, value ) = ( c( key ), c( &value ) );
|
||||||
|
count = ( lib.add_option )( key.as_ptr(), value.as_ptr(), count, &mut list );
|
||||||
|
}
|
||||||
|
let mut job = 0;
|
||||||
|
let result = if !ipp_ok( ( lib.create_dest_job )( http, dest, info, &mut job, title.as_ptr(), count, list ) )
|
||||||
|
{
|
||||||
|
Err( last_error( lib ) )
|
||||||
|
} else if ( lib.start_dest_document )( http, dest, info, job, title.as_ptr(), c"application/pdf".as_ptr(), 0, ptr::null_mut(), 1 ) != HTTP_STATUS_CONTINUE {
|
||||||
|
let error = last_error( lib );
|
||||||
|
( lib.cancel_dest_job )( http, dest, job );
|
||||||
|
Err( error )
|
||||||
|
} else {
|
||||||
|
let written = pdf.chunks( CHUNK ).all( |chunk| ( lib.write_request_data )( http, chunk.as_ptr().cast(), chunk.len() ) == HTTP_STATUS_CONTINUE );
|
||||||
|
let status = ( lib.finish_dest_document )( http, dest, info );
|
||||||
|
if written && ipp_ok( status ) { Ok( job ) } else { Err( last_error( lib ) ) }
|
||||||
|
};
|
||||||
|
( lib.free_options )( count, list );
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ipp_options( options: &PrintOptions ) -> Vec<( &'static str, String )>
|
||||||
|
{
|
||||||
|
vec![
|
||||||
|
( "copies", options.copies.clamp( 1, PrintOptions::MAX_COPIES ).to_string() ),
|
||||||
|
( "media", options.paper.media.to_string() ),
|
||||||
|
( "sides", options.sides.ipp().to_string() ),
|
||||||
|
( "print-color-mode", if options.color { "color" } else { "monochrome" }.to_string() ),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's jobs not yet finished, on every printer or on `printer`.
|
||||||
|
pub fn jobs( printer: Option<&str> ) -> Result<Vec<Job>, String>
|
||||||
|
{
|
||||||
|
let lib = lib()?;
|
||||||
|
let name = printer.map( c );
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let mut list = ptr::null_mut();
|
||||||
|
let count = ( lib.get_jobs )( ptr::null_mut(), &mut list, name.as_ref().map_or( ptr::null(), |n| n.as_ptr() ), 1, 0 );
|
||||||
|
if count < 0 { return Err( last_error( lib ) ); }
|
||||||
|
let jobs = ( 0..count as usize ).map( |i|
|
||||||
|
{
|
||||||
|
let job = &*list.add( i );
|
||||||
|
Job
|
||||||
|
{
|
||||||
|
id: job.id,
|
||||||
|
printer: string( job.dest ),
|
||||||
|
title: string( job.title ),
|
||||||
|
state: JobState::from_ipp( job.state ),
|
||||||
|
size_kb: job.size.max( 0 ) as u32,
|
||||||
|
created: job.creation_time as i64,
|
||||||
|
}
|
||||||
|
} ).collect();
|
||||||
|
( lib.free_jobs )( count, list );
|
||||||
|
Ok( jobs )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel( printer: &str, job: i32 ) -> Result<(), String>
|
||||||
|
{
|
||||||
|
let lib = lib()?;
|
||||||
|
let name = c( printer );
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
if ipp_ok( ( lib.cancel_job )( ptr::null_mut(), name.as_ptr(), job, 0 ) ) { Ok( () ) } else { Err( last_error( lib ) ) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! [`PrintOptions`] as the print portal carries them: GTK's print settings and page setup.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::{ Margins, Orientation, Paper, PrintOptions, Sides, PAPERS };
|
||||||
|
|
||||||
|
const MM_PER_POINT: f64 = 25.4 / 72.0;
|
||||||
|
|
||||||
|
/// A value of the page setup: names are strings and lengths are numbers, in millimetres.
|
||||||
|
#[ derive( Clone, Debug, PartialEq ) ]
|
||||||
|
pub enum PageValue
|
||||||
|
{
|
||||||
|
Text( String ),
|
||||||
|
Number( f64 ),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GTK's print settings for `options`, on `printer` when there is one.
|
||||||
|
pub fn settings( options: &PrintOptions, printer: Option<&str> ) -> Vec<( &'static str, String )>
|
||||||
|
{
|
||||||
|
let mut out = vec![
|
||||||
|
( "n-copies", options.copies.to_string() ),
|
||||||
|
( "orientation", orientation( options.orientation ).to_string() ),
|
||||||
|
( "scale", format!( "{}", ( options.scale * 100.0 ).round() ) ),
|
||||||
|
( "use-color", options.color.to_string() ),
|
||||||
|
( "duplex", match options.sides { Sides::One => "simplex", Sides::LongEdge => "horizontal", Sides::ShortEdge => "vertical" }.to_string() ),
|
||||||
|
( "print-pages", if options.pages.is_empty() { "all" } else { "ranges" }.to_string() ),
|
||||||
|
];
|
||||||
|
if !options.pages.is_empty()
|
||||||
|
{
|
||||||
|
let ranges = options.pages.iter()
|
||||||
|
.map( |&( start, end )| if end == u32::MAX { format!( "{}-", start - 1 ) } else if start == end { ( start - 1 ).to_string() } else { format!( "{}-{}", start - 1, end - 1 ) } )
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join( "," );
|
||||||
|
out.push( ( "page-ranges", ranges ) );
|
||||||
|
}
|
||||||
|
if let Some( printer ) = printer { out.push( ( "printer", printer.to_string() ) ); }
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GTK's page setup for `options`.
|
||||||
|
pub fn page_setup( options: &PrintOptions ) -> Vec<( &'static str, PageValue )>
|
||||||
|
{
|
||||||
|
let paper = options.paper;
|
||||||
|
let margin = options.margins.points() as f64 * MM_PER_POINT;
|
||||||
|
let mm = |points: f32| PageValue::Number( points as f64 * MM_PER_POINT );
|
||||||
|
vec![
|
||||||
|
( "PPDName", PageValue::Text( paper.name.to_string() ) ),
|
||||||
|
( "Name", PageValue::Text( gtk_name( &paper ) ) ),
|
||||||
|
( "DisplayName", PageValue::Text( paper.name.to_string() ) ),
|
||||||
|
( "Width", mm( paper.width ) ),
|
||||||
|
( "Height", mm( paper.height ) ),
|
||||||
|
( "MarginTop", PageValue::Number( margin ) ),
|
||||||
|
( "MarginBottom", PageValue::Number( margin ) ),
|
||||||
|
( "MarginLeft", PageValue::Number( margin ) ),
|
||||||
|
( "MarginRight", PageValue::Number( margin ) ),
|
||||||
|
( "Orientation", PageValue::Text( orientation( options.orientation ).to_string() ) ),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The options GTK's settings and page setup describe, from `base` for what they leave out.
|
||||||
|
pub fn options( settings: &HashMap<String, String>, page: &HashMap<String, PageValue>, base: &PrintOptions ) -> PrintOptions
|
||||||
|
{
|
||||||
|
let mut out = base.clone();
|
||||||
|
let text = |key: &str| settings.get( key ).map( |v| v.trim() );
|
||||||
|
if let Some( copies ) = text( "n-copies" ).and_then( |v| v.parse::<u32>().ok() ) { out.copies = copies.clamp( 1, PrintOptions::MAX_COPIES ); }
|
||||||
|
if let Some( scale ) = text( "scale" ).and_then( |v| v.parse::<f32>().ok() ).filter( |s| s.is_finite() )
|
||||||
|
{
|
||||||
|
out.scale = ( scale / 100.0 ).clamp( PrintOptions::MIN_SCALE, PrintOptions::MAX_SCALE );
|
||||||
|
}
|
||||||
|
if let Some( color ) = text( "use-color" ) { out.color = color != "false"; }
|
||||||
|
match text( "duplex" )
|
||||||
|
{
|
||||||
|
Some( "horizontal" ) => out.sides = Sides::LongEdge,
|
||||||
|
Some( "vertical" ) => out.sides = Sides::ShortEdge,
|
||||||
|
Some( "simplex" ) => out.sides = Sides::One,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match text( "print-pages" )
|
||||||
|
{
|
||||||
|
Some( "ranges" ) => if let Some( ranges ) = text( "page-ranges" ).and_then( ranges ) { out.pages = ranges; },
|
||||||
|
Some( _ ) => out.pages.clear(),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
let page_text = |key: &str| match page.get( key ) { Some( PageValue::Text( t ) ) => Some( t.as_str() ), _ => None };
|
||||||
|
let page_mm = |key: &str| match page.get( key ) { Some( PageValue::Number( n ) ) if n.is_finite() => Some( *n ), _ => None };
|
||||||
|
if let Some( orientation ) = page_text( "Orientation" ).or( text( "orientation" ) )
|
||||||
|
{
|
||||||
|
out.orientation = if orientation.contains( "landscape" ) { Orientation::Landscape } else { Orientation::Portrait };
|
||||||
|
}
|
||||||
|
let named = page_text( "PPDName" ).and_then( |n| PAPERS.iter().find( |p| p.name.eq_ignore_ascii_case( n ) ) )
|
||||||
|
.or_else( || page_text( "Name" ).and_then( |n| PAPERS.iter().find( |p| gtk_name( p ) == n || p.media == n ) ) );
|
||||||
|
let sized = || match ( page_mm( "Width" ), page_mm( "Height" ) )
|
||||||
|
{
|
||||||
|
( Some( w ), Some( h ) ) => PAPERS.iter().find( |p| ( p.width as f64 * MM_PER_POINT - w ).abs() < 1.5 && ( p.height as f64 * MM_PER_POINT - h ).abs() < 1.5 ),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some( paper ) = named.or_else( sized ) { out.paper = *paper; }
|
||||||
|
if let Some( top ) = page_mm( "MarginTop" )
|
||||||
|
{
|
||||||
|
let points = top / MM_PER_POINT;
|
||||||
|
out.margins = *Margins::ALL.iter()
|
||||||
|
.min_by( |a, b| ( a.points() as f64 - points ).abs().total_cmp( &( b.points() as f64 - points ).abs() ) )
|
||||||
|
.unwrap_or( &Margins::Default );
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GTK's `page-ranges`, from 0, as the inclusive ranges from 1 that [`PrintOptions::pages`] holds.
|
||||||
|
fn ranges( text: &str ) -> Option<Vec<( u32, u32 )>>
|
||||||
|
{
|
||||||
|
let shifted = text.split( ',' )
|
||||||
|
.map( str::trim )
|
||||||
|
.filter( |part| !part.is_empty() )
|
||||||
|
.map( |part|
|
||||||
|
{
|
||||||
|
let one = |s: &str| s.trim().parse::<u32>().ok().map( |n| ( n + 1 ).to_string() );
|
||||||
|
match part.split_once( '-' )
|
||||||
|
{
|
||||||
|
Some( ( start, end ) ) if end.trim().is_empty() => Some( format!( "{}-", one( start )? ) ),
|
||||||
|
Some( ( start, end ) ) => Some( format!( "{}-{}", one( start )?, one( end )? ) ),
|
||||||
|
None => one( part ),
|
||||||
|
}
|
||||||
|
} )
|
||||||
|
.collect::<Option<Vec<_>>>()?
|
||||||
|
.join( "," );
|
||||||
|
super::parse_pages( &shifted )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn orientation( orientation: Orientation ) -> &'static str
|
||||||
|
{
|
||||||
|
match orientation
|
||||||
|
{
|
||||||
|
Orientation::Portrait => "portrait",
|
||||||
|
Orientation::Landscape => "landscape",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GTK's name for the paper: the PWG name without its size.
|
||||||
|
fn gtk_name( paper: &Paper ) -> String
|
||||||
|
{
|
||||||
|
paper.media.rsplit_once( '_' ).map_or( paper.media, |( name, _ )| name ).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests
|
||||||
|
{
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn round_trip( options: &PrintOptions ) -> PrintOptions
|
||||||
|
{
|
||||||
|
let settings: HashMap<String, String> = settings( options, Some( "office" ) ).into_iter().map( |( k, v )| ( k.to_string(), v ) ).collect();
|
||||||
|
let page: HashMap<String, PageValue> = page_setup( options ).into_iter().map( |( k, v )| ( k.to_string(), v ) ).collect();
|
||||||
|
super::options( &settings, &page, &PrintOptions::default() )
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn options_survive_the_portal()
|
||||||
|
{
|
||||||
|
let options = PrintOptions
|
||||||
|
{
|
||||||
|
copies: 3,
|
||||||
|
pages: vec![ ( 1, 3 ), ( 5, 5 ), ( 8, u32::MAX ) ],
|
||||||
|
paper: PAPERS[ 1 ],
|
||||||
|
orientation: Orientation::Landscape,
|
||||||
|
margins: Margins::Wide,
|
||||||
|
scale: 0.8,
|
||||||
|
color: false,
|
||||||
|
sides: Sides::ShortEdge,
|
||||||
|
backgrounds: false,
|
||||||
|
};
|
||||||
|
assert_eq!( round_trip( &options ), options );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn page_ranges_count_from_zero()
|
||||||
|
{
|
||||||
|
let settings = settings( &PrintOptions { pages: vec![ ( 1, 3 ), ( 5, 5 ) ], ..PrintOptions::default() }, None );
|
||||||
|
assert!( settings.contains( &( "page-ranges", "0-2,4".to_string() ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn gtk_names_a4_without_its_size()
|
||||||
|
{
|
||||||
|
assert_eq!( gtk_name( &PAPERS[ 0 ] ), "iso_a4" );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! A print dialog with a preview of the pages, and the system's printers through CUPS. The app renders the
|
||||||
|
//! document to PDF itself: the dialog asks it for a preview whenever the layout changes, and says where to
|
||||||
|
//! print and how once the user accepts.
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! # use ltk::{ ChannelSender, Element };
|
||||||
|
//! # use ltk::print::{ Destination, PrintDialog, PrintDialogMsg, PrintEvent };
|
||||||
|
//! # #[ derive( Clone ) ] enum Msg { Print( PrintDialogMsg ) }
|
||||||
|
//! # struct App { print: Option<PrintDialog<Msg>>, sender: ChannelSender<Msg> }
|
||||||
|
//! # impl App {
|
||||||
|
//! fn open( &mut self )
|
||||||
|
//! {
|
||||||
|
//! self.print = Some( PrintDialog::new( &self.sender, Msg::Print, "Report" ).preview( true ) );
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! fn update( &mut self, msg: Msg )
|
||||||
|
//! {
|
||||||
|
//! let Msg::Print( msg ) = msg;
|
||||||
|
//! let Some( dialog ) = &mut self.print else { return };
|
||||||
|
//! match dialog.update( msg )
|
||||||
|
//! {
|
||||||
|
//! Some( PrintEvent::Preview( options ) ) => { /* render, then send PrintDialogMsg::previewed( .. ) */ }
|
||||||
|
//! Some( PrintEvent::Print( job ) ) => { self.print = None; /* render job.options, then ltk::print::send or save */ }
|
||||||
|
//! Some( PrintEvent::Cancelled ) => self.print = None,
|
||||||
|
//! None => {}
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! # fn view( &self ) -> Element<Msg> { self.print.as_ref().unwrap().view() }
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod options;
|
||||||
|
mod view;
|
||||||
|
pub mod gtk;
|
||||||
|
#[ cfg( all( feature = "wayland", target_os = "linux" ) ) ]
|
||||||
|
mod portal;
|
||||||
|
#[ cfg( all( unix, not( target_os = "android" ), not( target_arch = "wasm32" ) ) ) ]
|
||||||
|
mod cups;
|
||||||
|
#[ cfg( not( all( unix, not( target_os = "android" ), not( target_arch = "wasm32" ) ) ) ) ]
|
||||||
|
#[ path = "none.rs" ]
|
||||||
|
mod cups;
|
||||||
|
|
||||||
|
pub use options::{ format_pages, parse_pages, Margins, Orientation, Paper, PrintOptions, Sides, PAPERS };
|
||||||
|
|
||||||
|
/// CUPS's calls themselves, for a program without ltk's loop; each blocks.
|
||||||
|
pub mod blocking
|
||||||
|
{
|
||||||
|
pub use super::cups::{ cancel, jobs, print, printers, set_default };
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::app::ChannelSender;
|
||||||
|
use crate::types::WidgetId;
|
||||||
|
use crate::wallpaper::ImageData;
|
||||||
|
use crate::widget::Element;
|
||||||
|
|
||||||
|
/// The field with the pages to print.
|
||||||
|
pub const PAGES: WidgetId = WidgetId( "ltk.print.pages" );
|
||||||
|
/// How wide, in pixels, the pages of a preview are best rendered.
|
||||||
|
pub const PREVIEW_WIDTH: u32 = 480;
|
||||||
|
|
||||||
|
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
|
||||||
|
pub enum PrinterState
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Printing,
|
||||||
|
Stopped,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ derive( Clone, Debug, PartialEq ) ]
|
||||||
|
pub struct Printer
|
||||||
|
{
|
||||||
|
/// The queue's name, which CUPS knows it by.
|
||||||
|
pub name: String,
|
||||||
|
pub instance: Option<String>,
|
||||||
|
/// What to call it: its description, or else its name.
|
||||||
|
pub description: String,
|
||||||
|
pub location: String,
|
||||||
|
pub model: String,
|
||||||
|
pub state: PrinterState,
|
||||||
|
/// IPP's `printer-state-reasons`, empty when there are none.
|
||||||
|
pub reasons: String,
|
||||||
|
pub accepting: bool,
|
||||||
|
pub default: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
|
||||||
|
pub enum JobState
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Held,
|
||||||
|
Printing,
|
||||||
|
Stopped,
|
||||||
|
Cancelled,
|
||||||
|
Aborted,
|
||||||
|
Completed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobState
|
||||||
|
{
|
||||||
|
pub( crate ) fn from_ipp( state: i32 ) -> Self
|
||||||
|
{
|
||||||
|
match state
|
||||||
|
{
|
||||||
|
3 => JobState::Pending,
|
||||||
|
4 => JobState::Held,
|
||||||
|
5 => JobState::Printing,
|
||||||
|
6 => JobState::Stopped,
|
||||||
|
7 => JobState::Cancelled,
|
||||||
|
8 => JobState::Aborted,
|
||||||
|
_ => JobState::Completed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ derive( Clone, Debug, PartialEq ) ]
|
||||||
|
pub struct Job
|
||||||
|
{
|
||||||
|
pub id: i32,
|
||||||
|
pub printer: String,
|
||||||
|
pub title: String,
|
||||||
|
pub state: JobState,
|
||||||
|
pub size_kb: u32,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
pub created: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the print portal's dialog chose, which it prints with.
|
||||||
|
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||||||
|
pub struct PortalToken( pub( crate ) u32 );
|
||||||
|
|
||||||
|
#[ derive( Clone, Debug, PartialEq ) ]
|
||||||
|
pub enum Destination
|
||||||
|
{
|
||||||
|
Printer( Printer ),
|
||||||
|
Pdf,
|
||||||
|
/// The desktop prints for an app in a sandbox, where the user chose in its own dialog.
|
||||||
|
Portal( PortalToken ),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to print, where and how, once the user accepted.
|
||||||
|
#[ derive( Clone, Debug, PartialEq ) ]
|
||||||
|
pub struct PrintJob
|
||||||
|
{
|
||||||
|
pub destination: Destination,
|
||||||
|
pub options: PrintOptions,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ derive( Clone, Debug ) ]
|
||||||
|
pub struct PreviewPage
|
||||||
|
{
|
||||||
|
/// In the document, from 0.
|
||||||
|
pub index: usize,
|
||||||
|
pub image: ImageData,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pages chosen, rendered with the options the dialog asked for.
|
||||||
|
#[ derive( Clone, Debug ) ]
|
||||||
|
pub struct Preview
|
||||||
|
{
|
||||||
|
/// The pages of the whole document.
|
||||||
|
pub total: usize,
|
||||||
|
pub pages: Vec<PreviewPage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the app has to do for the dialog.
|
||||||
|
#[ derive( Clone, Debug, PartialEq ) ]
|
||||||
|
pub enum PrintEvent
|
||||||
|
{
|
||||||
|
/// Render a preview with these options and answer with [`PrintDialogMsg::previewed`].
|
||||||
|
Preview( PrintOptions ),
|
||||||
|
Print( PrintJob ),
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A setting whose choices the dialog shows under it.
|
||||||
|
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
|
||||||
|
pub enum Section
|
||||||
|
{
|
||||||
|
Destination,
|
||||||
|
Paper,
|
||||||
|
Orientation,
|
||||||
|
Margins,
|
||||||
|
Sides,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ derive( Clone, Debug ) ]
|
||||||
|
pub enum PrintDialogMsg
|
||||||
|
{
|
||||||
|
Start,
|
||||||
|
Printers( Result<Vec<Printer>, String> ),
|
||||||
|
/// The portal's dialog closed: the options and token, `None` when cancelled.
|
||||||
|
Prepared( Result<Option<( PrintOptions, u32 )>, String> ),
|
||||||
|
Previewed( Result<Preview, String> ),
|
||||||
|
Toggle( Section ),
|
||||||
|
/// A printer by its place in the list, or `None` for a PDF.
|
||||||
|
Destination( Option<usize> ),
|
||||||
|
Copies( i32 ),
|
||||||
|
AllPages,
|
||||||
|
SomePages,
|
||||||
|
Pages( String ),
|
||||||
|
Paper( usize ),
|
||||||
|
Orientation( Orientation ),
|
||||||
|
Margins( Margins ),
|
||||||
|
Scale( i32 ),
|
||||||
|
Color,
|
||||||
|
Sides( Sides ),
|
||||||
|
Backgrounds,
|
||||||
|
Page( i32 ),
|
||||||
|
Print,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrintDialogMsg
|
||||||
|
{
|
||||||
|
/// The app's answer to [`PrintEvent::Preview`].
|
||||||
|
pub fn previewed( result: Result<Preview, String> ) -> Self
|
||||||
|
{
|
||||||
|
PrintDialogMsg::Previewed( result )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCALE_STEP: i32 = 10;
|
||||||
|
|
||||||
|
pub struct PrintDialog<Msg>
|
||||||
|
{
|
||||||
|
pub( crate ) title: String,
|
||||||
|
pub( crate ) printers: Vec<Printer>,
|
||||||
|
pub( crate ) listing: bool,
|
||||||
|
pub( crate ) list_error: Option<String>,
|
||||||
|
/// A printer of `printers`, or `None` for a PDF.
|
||||||
|
pub( crate ) destination: Option<usize>,
|
||||||
|
pub( crate ) wanted: Option<String>,
|
||||||
|
pub( crate ) prefer_pdf: bool,
|
||||||
|
pub( crate ) options: PrintOptions,
|
||||||
|
pub( crate ) some_pages: bool,
|
||||||
|
pub( crate ) pages_text: String,
|
||||||
|
pub( crate ) pages_bad: bool,
|
||||||
|
pub( crate ) open: Option<Section>,
|
||||||
|
pub( crate ) previews: bool,
|
||||||
|
pub( crate ) preview: Option<Preview>,
|
||||||
|
/// The preview's images as shown, in grey when printing without colour.
|
||||||
|
pub( crate ) images: Vec<ImageData>,
|
||||||
|
pub( crate ) preview_error: Option<String>,
|
||||||
|
/// The options a preview is being rendered with.
|
||||||
|
pub( crate ) rendering: Option<PrintOptions>,
|
||||||
|
pub( crate ) shown: usize,
|
||||||
|
pub( crate ) compact: bool,
|
||||||
|
/// The portal shows the dialog instead.
|
||||||
|
pub( crate ) portal: bool,
|
||||||
|
focus: Option<WidgetId>,
|
||||||
|
sender: ChannelSender<Msg>,
|
||||||
|
pub( crate ) wrap: Arc<dyn Fn( PrintDialogMsg ) -> Msg + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Msg: Clone + Send + 'static> PrintDialog<Msg>
|
||||||
|
{
|
||||||
|
/// A dialog for printing the document called `title`. `wrap` makes the app's message of the dialog's own.
|
||||||
|
pub fn new( sender: &ChannelSender<Msg>, wrap: impl Fn( PrintDialogMsg ) -> Msg + Send + Sync + 'static, title: impl Into<String> ) -> Self
|
||||||
|
{
|
||||||
|
let wrap: Arc<dyn Fn( PrintDialogMsg ) -> Msg + Send + Sync> = Arc::new( wrap );
|
||||||
|
let _ = sender.send( wrap( PrintDialogMsg::Start ) );
|
||||||
|
let portal = sandboxed();
|
||||||
|
if !portal
|
||||||
|
{
|
||||||
|
let reply = Arc::clone( &wrap );
|
||||||
|
list_printers( sender, move |result| reply( PrintDialogMsg::Printers( result ) ) );
|
||||||
|
}
|
||||||
|
Self
|
||||||
|
{
|
||||||
|
title: title.into(),
|
||||||
|
printers: Vec::new(),
|
||||||
|
listing: !portal,
|
||||||
|
list_error: None,
|
||||||
|
destination: None,
|
||||||
|
wanted: None,
|
||||||
|
prefer_pdf: false,
|
||||||
|
options: PrintOptions::default(),
|
||||||
|
some_pages: false,
|
||||||
|
pages_text: String::new(),
|
||||||
|
pages_bad: false,
|
||||||
|
open: None,
|
||||||
|
previews: false,
|
||||||
|
preview: None,
|
||||||
|
images: Vec::new(),
|
||||||
|
preview_error: None,
|
||||||
|
rendering: None,
|
||||||
|
shown: 0,
|
||||||
|
compact: false,
|
||||||
|
portal,
|
||||||
|
focus: None,
|
||||||
|
sender: sender.clone(),
|
||||||
|
wrap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the app for a preview of the pages, which it renders on [`PrintEvent::Preview`].
|
||||||
|
pub fn preview( mut self, on: bool ) -> Self
|
||||||
|
{
|
||||||
|
self.previews = on;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start with these options, as the app kept them from a [`PrintJob`].
|
||||||
|
pub fn options( mut self, options: PrintOptions ) -> Self
|
||||||
|
{
|
||||||
|
self.some_pages = !options.pages.is_empty();
|
||||||
|
self.pages_text = format_pages( &options.pages );
|
||||||
|
self.options = options;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choose this printer, by its queue's name, rather than the default one once the printers are known.
|
||||||
|
pub fn printer( mut self, name: impl Into<String> ) -> Self
|
||||||
|
{
|
||||||
|
self.wanted = Some( name.into() );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start with saving to PDF chosen, even when there are printers.
|
||||||
|
pub fn save_as_pdf( mut self ) -> Self
|
||||||
|
{
|
||||||
|
self.prefer_pdf = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laid out for a narrow screen, on the whole of it.
|
||||||
|
pub fn compact( mut self, on: bool ) -> Self
|
||||||
|
{
|
||||||
|
self.compact = on;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The field to focus, once after it changed; an app hands it on from [`crate::App::take_focus_request`].
|
||||||
|
pub fn take_focus_request( &mut self ) -> Option<WidgetId>
|
||||||
|
{
|
||||||
|
self.focus.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing while the portal shows its own dialog.
|
||||||
|
pub fn view( &self ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
if self.portal { return crate::spacer().into(); }
|
||||||
|
view::view( self )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take one of the dialog's messages; `Some` when the app has something to do.
|
||||||
|
pub fn update( &mut self, msg: PrintDialogMsg ) -> Option<PrintEvent>
|
||||||
|
{
|
||||||
|
match msg
|
||||||
|
{
|
||||||
|
PrintDialogMsg::Start if self.portal =>
|
||||||
|
{
|
||||||
|
let ( title, options, wrap ) = ( self.title.clone(), self.options.clone(), Arc::clone( &self.wrap ) );
|
||||||
|
run( &self.sender, move || prepare( &title, &options ), move |result| wrap( PrintDialogMsg::Prepared( result ) ) );
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Start => return self.request_preview(),
|
||||||
|
PrintDialogMsg::Printers( result ) => self.listed( result ),
|
||||||
|
PrintDialogMsg::Prepared( result ) => match result
|
||||||
|
{
|
||||||
|
Ok( Some( ( options, token ) ) ) =>
|
||||||
|
{
|
||||||
|
return Some( PrintEvent::Print( PrintJob { destination: Destination::Portal( PortalToken( token ) ), options, title: self.title.clone() } ) );
|
||||||
|
}
|
||||||
|
Ok( None ) => return Some( PrintEvent::Cancelled ),
|
||||||
|
Err( error ) =>
|
||||||
|
{
|
||||||
|
self.portal = false;
|
||||||
|
self.list_error = Some( error );
|
||||||
|
return self.request_preview();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PrintDialogMsg::Previewed( result ) => return self.previewed( result ),
|
||||||
|
PrintDialogMsg::Toggle( section ) => self.open = if self.open == Some( section ) { None } else { Some( section ) },
|
||||||
|
PrintDialogMsg::Destination( index ) =>
|
||||||
|
{
|
||||||
|
self.destination = index.filter( |i| *i < self.printers.len() );
|
||||||
|
self.open = None;
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Copies( delta ) =>
|
||||||
|
{
|
||||||
|
self.options.copies = ( self.options.copies as i64 + delta as i64 ).clamp( 1, PrintOptions::MAX_COPIES as i64 ) as u32;
|
||||||
|
}
|
||||||
|
PrintDialogMsg::AllPages =>
|
||||||
|
{
|
||||||
|
self.some_pages = false;
|
||||||
|
self.pages_bad = false;
|
||||||
|
return self.set_pages( Vec::new() );
|
||||||
|
}
|
||||||
|
PrintDialogMsg::SomePages =>
|
||||||
|
{
|
||||||
|
self.some_pages = true;
|
||||||
|
self.focus = Some( PAGES );
|
||||||
|
if let Some( pages ) = parse_pages( &self.pages_text ) { return self.set_pages( pages ); }
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Pages( text ) =>
|
||||||
|
{
|
||||||
|
self.pages_text = text;
|
||||||
|
match parse_pages( &self.pages_text )
|
||||||
|
{
|
||||||
|
Some( pages ) =>
|
||||||
|
{
|
||||||
|
self.pages_bad = false;
|
||||||
|
return self.set_pages( pages );
|
||||||
|
}
|
||||||
|
None => self.pages_bad = !self.pages_text.trim().is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Paper( index ) =>
|
||||||
|
{
|
||||||
|
self.open = None;
|
||||||
|
if let Some( paper ) = PAPERS.get( index ) { return self.change( |o| o.paper = *paper ); }
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Orientation( orientation ) =>
|
||||||
|
{
|
||||||
|
self.open = None;
|
||||||
|
return self.change( |o| o.orientation = orientation );
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Margins( margins ) =>
|
||||||
|
{
|
||||||
|
self.open = None;
|
||||||
|
return self.change( |o| o.margins = margins );
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Scale( steps ) =>
|
||||||
|
{
|
||||||
|
let percent = ( self.options.scale * 100.0 ).round() as i32 + steps * SCALE_STEP;
|
||||||
|
let scale = ( percent as f32 / 100.0 ).clamp( PrintOptions::MIN_SCALE, PrintOptions::MAX_SCALE );
|
||||||
|
return self.change( |o| o.scale = scale );
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Color =>
|
||||||
|
{
|
||||||
|
self.options.color = !self.options.color;
|
||||||
|
self.refresh_images();
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Sides( sides ) =>
|
||||||
|
{
|
||||||
|
self.open = None;
|
||||||
|
self.options.sides = sides;
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Backgrounds => return self.change( |o| o.backgrounds = !o.backgrounds ),
|
||||||
|
PrintDialogMsg::Page( delta ) =>
|
||||||
|
{
|
||||||
|
let last = self.images.len().saturating_sub( 1 ) as i64;
|
||||||
|
self.shown = ( self.shown as i64 + delta as i64 ).clamp( 0, last ) as usize;
|
||||||
|
}
|
||||||
|
PrintDialogMsg::Print => return self.job().map( PrintEvent::Print ),
|
||||||
|
PrintDialogMsg::Cancel => return Some( PrintEvent::Cancelled ),
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where it prints to now.
|
||||||
|
pub fn destination( &self ) -> Destination
|
||||||
|
{
|
||||||
|
match self.destination.and_then( |i| self.printers.get( i ) )
|
||||||
|
{
|
||||||
|
Some( printer ) => Destination::Printer( printer.clone() ),
|
||||||
|
None => Destination::Pdf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn msg( &self, msg: PrintDialogMsg ) -> Msg
|
||||||
|
{
|
||||||
|
( self.wrap )( msg )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn can_print( &self ) -> bool
|
||||||
|
{
|
||||||
|
!( self.some_pages && ( self.pages_bad || parse_pages( &self.pages_text ).is_none() ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job( &self ) -> Option<PrintJob>
|
||||||
|
{
|
||||||
|
if !self.can_print() { return None; }
|
||||||
|
let mut options = self.options.clone();
|
||||||
|
if !self.some_pages { options.pages.clear(); }
|
||||||
|
Some( PrintJob { destination: self.destination(), options, title: self.title.clone() } )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listed( &mut self, result: Result<Vec<Printer>, String> )
|
||||||
|
{
|
||||||
|
self.listing = false;
|
||||||
|
match result
|
||||||
|
{
|
||||||
|
Ok( printers ) =>
|
||||||
|
{
|
||||||
|
let wanted = self.wanted.as_deref();
|
||||||
|
self.destination = if self.prefer_pdf
|
||||||
|
{
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
printers.iter().position( |p| Some( p.name.as_str() ) == wanted )
|
||||||
|
.or_else( || printers.iter().position( |p| p.default ) )
|
||||||
|
.or( ( !printers.is_empty() ).then_some( 0 ) )
|
||||||
|
};
|
||||||
|
self.printers = printers;
|
||||||
|
}
|
||||||
|
Err( error ) => self.list_error = Some( error ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_pages( &mut self, pages: Vec<( u32, u32 )> ) -> Option<PrintEvent>
|
||||||
|
{
|
||||||
|
self.change( |o| o.pages = pages )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change( &mut self, apply: impl FnOnce( &mut PrintOptions ) ) -> Option<PrintEvent>
|
||||||
|
{
|
||||||
|
let before = self.options.clone();
|
||||||
|
apply( &mut self.options );
|
||||||
|
if before.same_layout( &self.options ) { return None; }
|
||||||
|
self.request_preview()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_preview( &mut self ) -> Option<PrintEvent>
|
||||||
|
{
|
||||||
|
if !self.previews || self.rendering.is_some() { return None; }
|
||||||
|
self.rendering = Some( self.options.clone() );
|
||||||
|
Some( PrintEvent::Preview( self.options.clone() ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previewed( &mut self, result: Result<Preview, String> ) -> Option<PrintEvent>
|
||||||
|
{
|
||||||
|
let rendered = self.rendering.take();
|
||||||
|
match result
|
||||||
|
{
|
||||||
|
Ok( preview ) =>
|
||||||
|
{
|
||||||
|
self.preview_error = None;
|
||||||
|
self.shown = self.shown.min( preview.pages.len().saturating_sub( 1 ) );
|
||||||
|
self.preview = Some( preview );
|
||||||
|
self.refresh_images();
|
||||||
|
}
|
||||||
|
Err( error ) => self.preview_error = Some( error ),
|
||||||
|
}
|
||||||
|
match rendered
|
||||||
|
{
|
||||||
|
Some( rendered ) if !rendered.same_layout( &self.options ) => self.request_preview(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_images( &mut self )
|
||||||
|
{
|
||||||
|
let color = self.options.color;
|
||||||
|
self.images = self.preview.iter()
|
||||||
|
.flat_map( |preview| preview.pages.iter() )
|
||||||
|
.map( |page| if color { page.image.clone() } else { grey( &page.image ) } )
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grey( ( rgba, width, height ): &ImageData ) -> ImageData
|
||||||
|
{
|
||||||
|
let mut out = rgba.as_ref().clone();
|
||||||
|
for px in out.chunks_exact_mut( 4 )
|
||||||
|
{
|
||||||
|
let y = ( 0.299 * px[ 0 ] as f32 + 0.587 * px[ 1 ] as f32 + 0.114 * px[ 2 ] as f32 ).round() as u8;
|
||||||
|
px[ 0 ] = y;
|
||||||
|
px[ 1 ] = y;
|
||||||
|
px[ 2 ] = y;
|
||||||
|
}
|
||||||
|
( Arc::new( out ), *width, *height )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run<M: Send + 'static, T: Send + 'static>( sender: &ChannelSender<M>, work: impl FnOnce() -> T + Send + 'static, done: impl FnOnce( T ) -> M + Send + 'static )
|
||||||
|
{
|
||||||
|
let sender = sender.clone();
|
||||||
|
#[ cfg( target_arch = "wasm32" ) ]
|
||||||
|
let _ = sender.send( done( work() ) );
|
||||||
|
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
||||||
|
std::thread::spawn( move || { let _ = sender.send( done( work() ) ); } );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The system's printers, the default first; empty without CUPS's server, an error without CUPS.
|
||||||
|
pub fn list_printers<M: Send + 'static>( sender: &ChannelSender<M>, done: impl FnOnce( Result<Vec<Printer>, String> ) -> M + Send + 'static )
|
||||||
|
{
|
||||||
|
run( sender, cups::printers, done );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print `pdf` at `destination` with `options`; done once the printer's queue, or the desktop, took it.
|
||||||
|
pub fn send<M: Send + 'static>( destination: &Destination, title: &str, options: &PrintOptions, pdf: Vec<u8>, sender: &ChannelSender<M>, done: impl FnOnce( Result<(), String> ) -> M + Send + 'static )
|
||||||
|
{
|
||||||
|
let ( destination, title, options ) = ( destination.clone(), title.to_string(), options.clone() );
|
||||||
|
run( sender, move || match destination
|
||||||
|
{
|
||||||
|
Destination::Printer( printer ) => cups::print( &printer, &title, &options, &pdf ).map( |_| () ),
|
||||||
|
Destination::Portal( token ) => portal_print( token.0, &title, &pdf ),
|
||||||
|
Destination::Pdf => Err( rust_i18n::t!( "print.failed" ).to_string() ),
|
||||||
|
}, done );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( all( feature = "wayland", target_os = "linux" ) ) ]
|
||||||
|
use portal::{ prepare, print as portal_print, sandboxed };
|
||||||
|
|
||||||
|
#[ cfg( not( all( feature = "wayland", target_os = "linux" ) ) ) ]
|
||||||
|
fn sandboxed() -> bool
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( not( all( feature = "wayland", target_os = "linux" ) ) ) ]
|
||||||
|
fn prepare( _title: &str, _options: &PrintOptions ) -> Result<Option<( PrintOptions, u32 )>, String>
|
||||||
|
{
|
||||||
|
Err( rust_i18n::t!( "print.no_cups" ).to_string() )
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( not( all( feature = "wayland", target_os = "linux" ) ) ) ]
|
||||||
|
fn portal_print( _token: u32, _title: &str, _pdf: &[ u8 ] ) -> Result<(), String>
|
||||||
|
{
|
||||||
|
Err( rust_i18n::t!( "print.no_cups" ).to_string() )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's jobs not yet finished, on every printer or on one of them.
|
||||||
|
pub fn jobs<M: Send + 'static>( printer: Option<String>, sender: &ChannelSender<M>, done: impl FnOnce( Result<Vec<Job>, String> ) -> M + Send + 'static )
|
||||||
|
{
|
||||||
|
run( sender, move || cups::jobs( printer.as_deref() ), done );
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_job<M: Send + 'static>( printer: &str, job: i32, sender: &ChannelSender<M>, done: impl FnOnce( Result<(), String> ) -> M + Send + 'static )
|
||||||
|
{
|
||||||
|
let printer = printer.to_string();
|
||||||
|
run( sender, move || cups::cancel( &printer, job ), done );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `printer` the user's default.
|
||||||
|
pub fn set_default<M: Send + 'static>( printer: &Printer, sender: &ChannelSender<M>, done: impl FnOnce( Result<(), String> ) -> M + Send + 'static )
|
||||||
|
{
|
||||||
|
let ( name, instance ) = ( printer.name.clone(), printer.instance.clone() );
|
||||||
|
run( sender, move || cups::set_default( &name, instance.as_deref() ), done );
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
use super::{ Job, Printer, PrintOptions };
|
||||||
|
|
||||||
|
fn unsupported<T>() -> Result<T, String>
|
||||||
|
{
|
||||||
|
Err( rust_i18n::t!( "print.no_cups" ).to_string() )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn printers() -> Result<Vec<Printer>, String>
|
||||||
|
{
|
||||||
|
Ok( Vec::new() )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_default( _name: &str, _instance: Option<&str> ) -> Result<(), String>
|
||||||
|
{
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print( _printer: &Printer, _title: &str, _options: &PrintOptions, _pdf: &[ u8 ] ) -> Result<i32, String>
|
||||||
|
{
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn jobs( _printer: Option<&str> ) -> Result<Vec<Job>, String>
|
||||||
|
{
|
||||||
|
Ok( Vec::new() )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel( _printer: &str, _job: i32 ) -> Result<(), String>
|
||||||
|
{
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
/// A sheet size, in PDF points, with its PWG media name for the printer.
|
||||||
|
#[ derive( Clone, Copy, Debug, PartialEq ) ]
|
||||||
|
pub struct Paper
|
||||||
|
{
|
||||||
|
pub media: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const PAPERS: &[ Paper ] = &[
|
||||||
|
Paper { media: "iso_a4_210x297mm", name: "A4", width: 595.28, height: 841.89 },
|
||||||
|
Paper { media: "na_letter_8.5x11in", name: "Letter", width: 612.0, height: 792.0 },
|
||||||
|
Paper { media: "na_legal_8.5x14in", name: "Legal", width: 612.0, height: 1008.0 },
|
||||||
|
Paper { media: "iso_a3_297x420mm", name: "A3", width: 841.89, height: 1190.55 },
|
||||||
|
Paper { media: "iso_a5_148x210mm", name: "A5", width: 419.53, height: 595.28 },
|
||||||
|
];
|
||||||
|
|
||||||
|
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
|
||||||
|
pub enum Orientation
|
||||||
|
{
|
||||||
|
Portrait,
|
||||||
|
Landscape,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
|
||||||
|
pub enum Margins
|
||||||
|
{
|
||||||
|
/// One centimetre.
|
||||||
|
Default,
|
||||||
|
None,
|
||||||
|
/// Half a centimetre.
|
||||||
|
Narrow,
|
||||||
|
/// Two centimetres.
|
||||||
|
Wide,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Margins
|
||||||
|
{
|
||||||
|
pub const ALL: [ Margins; 4 ] = [ Margins::Default, Margins::Narrow, Margins::Wide, Margins::None ];
|
||||||
|
|
||||||
|
pub fn points( self ) -> f32
|
||||||
|
{
|
||||||
|
const CM: f32 = 72.0 / 2.54;
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Margins::Default => CM,
|
||||||
|
Margins::None => 0.0,
|
||||||
|
Margins::Narrow => CM / 2.0,
|
||||||
|
Margins::Wide => CM * 2.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
|
||||||
|
pub enum Sides
|
||||||
|
{
|
||||||
|
One,
|
||||||
|
LongEdge,
|
||||||
|
ShortEdge,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sides
|
||||||
|
{
|
||||||
|
pub const ALL: [ Sides; 3 ] = [ Sides::One, Sides::LongEdge, Sides::ShortEdge ];
|
||||||
|
|
||||||
|
/// The IPP `sides` value.
|
||||||
|
pub fn ipp( self ) -> &'static str
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Sides::One => "one-sided",
|
||||||
|
Sides::LongEdge => "two-sided-long-edge",
|
||||||
|
Sides::ShortEdge => "two-sided-short-edge",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the user chose in a print dialog.
|
||||||
|
#[ derive( Clone, Debug, PartialEq ) ]
|
||||||
|
pub struct PrintOptions
|
||||||
|
{
|
||||||
|
pub copies: u32,
|
||||||
|
/// Inclusive, from 1, in the user's order; empty for every page. An open end is `u32::MAX`.
|
||||||
|
pub pages: Vec<( u32, u32 )>,
|
||||||
|
pub paper: Paper,
|
||||||
|
pub orientation: Orientation,
|
||||||
|
pub margins: Margins,
|
||||||
|
pub scale: f32,
|
||||||
|
pub color: bool,
|
||||||
|
pub sides: Sides,
|
||||||
|
pub backgrounds: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PrintOptions
|
||||||
|
{
|
||||||
|
fn default() -> Self
|
||||||
|
{
|
||||||
|
Self
|
||||||
|
{
|
||||||
|
copies: 1,
|
||||||
|
pages: Vec::new(),
|
||||||
|
paper: PAPERS[ 0 ],
|
||||||
|
orientation: Orientation::Portrait,
|
||||||
|
margins: Margins::Default,
|
||||||
|
scale: 1.0,
|
||||||
|
color: true,
|
||||||
|
sides: Sides::One,
|
||||||
|
backgrounds: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrintOptions
|
||||||
|
{
|
||||||
|
pub const MIN_SCALE: f32 = 0.1;
|
||||||
|
pub const MAX_SCALE: f32 = 2.0;
|
||||||
|
pub const MAX_COPIES: u32 = 99;
|
||||||
|
|
||||||
|
/// The sheet as it is printed, turned for landscape.
|
||||||
|
pub fn paper_size( &self ) -> ( f32, f32 )
|
||||||
|
{
|
||||||
|
let ( w, h ) = ( self.paper.width, self.paper.height );
|
||||||
|
match self.orientation
|
||||||
|
{
|
||||||
|
Orientation::Portrait => ( w, h ),
|
||||||
|
Orientation::Landscape => ( h, w ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top, right, bottom and left, in points.
|
||||||
|
pub fn margin_points( &self ) -> [ f32; 4 ]
|
||||||
|
{
|
||||||
|
[ self.margins.points(); 4 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `other` lays the document out the same way, so it has the same pages.
|
||||||
|
pub fn same_layout( &self, other: &PrintOptions ) -> bool
|
||||||
|
{
|
||||||
|
self.paper == other.paper && self.orientation == other.orientation && self.margins == other.margins
|
||||||
|
&& self.scale == other.scale && self.backgrounds == other.backgrounds && self.pages == other.pages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pages such as `1-3, 5, 8-`, inclusive and from 1; `None` when the text is not such a list.
|
||||||
|
pub fn parse_pages( text: &str ) -> Option<Vec<( u32, u32 )>>
|
||||||
|
{
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
for part in text.split( [ ',', ';' ] ).map( str::trim ).filter( |part| !part.is_empty() )
|
||||||
|
{
|
||||||
|
let number = |s: &str| s.trim().parse::<u32>().ok().filter( |n| *n > 0 );
|
||||||
|
let range = match part.split_once( '-' )
|
||||||
|
{
|
||||||
|
Some( ( start, end ) ) =>
|
||||||
|
{
|
||||||
|
let start = if start.trim().is_empty() { 1 } else { number( start )? };
|
||||||
|
let end = if end.trim().is_empty() { u32::MAX } else { number( end )? };
|
||||||
|
( start, end )
|
||||||
|
}
|
||||||
|
None =>
|
||||||
|
{
|
||||||
|
let page = number( part )?;
|
||||||
|
( page, page )
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if range.0 > range.1 { return None; }
|
||||||
|
ranges.push( range );
|
||||||
|
}
|
||||||
|
( !ranges.is_empty() ).then_some( ranges )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_pages( ranges: &[ ( u32, u32 ) ] ) -> String
|
||||||
|
{
|
||||||
|
ranges.iter()
|
||||||
|
.map( |&( start, end )| match ( start, end )
|
||||||
|
{
|
||||||
|
( s, e ) if s == e => s.to_string(),
|
||||||
|
( s, u32::MAX ) => format!( "{s}-" ),
|
||||||
|
( s, e ) => format!( "{s}-{e}" ),
|
||||||
|
} )
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join( ", " )
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests
|
||||||
|
{
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn page_lists_parse()
|
||||||
|
{
|
||||||
|
assert_eq!( parse_pages( "1-3, 5,8-" ), Some( vec![ ( 1, 3 ), ( 5, 5 ), ( 8, u32::MAX ) ] ) );
|
||||||
|
assert_eq!( parse_pages( " -2 " ), Some( vec![ ( 1, 2 ) ] ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn bad_page_lists_do_not_parse()
|
||||||
|
{
|
||||||
|
for text in [ "", "0", "3-1", "a", "1-b", "1--2", " , " ]
|
||||||
|
{
|
||||||
|
assert_eq!( parse_pages( text ), None, "{text}" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn page_lists_format_back()
|
||||||
|
{
|
||||||
|
let ranges = vec![ ( 1, 3 ), ( 5, 5 ), ( 8, u32::MAX ) ];
|
||||||
|
assert_eq!( parse_pages( &format_pages( &ranges ) ), Some( ranges ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn landscape_turns_the_sheet()
|
||||||
|
{
|
||||||
|
let options = PrintOptions { orientation: Orientation::Landscape, ..PrintOptions::default() };
|
||||||
|
assert_eq!( options.paper_size(), ( PAPERS[ 0 ].height, PAPERS[ 0 ].width ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn copies_and_colour_keep_the_layout()
|
||||||
|
{
|
||||||
|
let base = PrintOptions::default();
|
||||||
|
let other = PrintOptions { copies: 3, color: false, sides: Sides::LongEdge, ..base.clone() };
|
||||||
|
assert!( base.same_layout( &other ) );
|
||||||
|
assert!( !base.same_layout( &PrintOptions { scale: 0.8, ..base.clone() } ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! The print portal, for an app in a sandbox that cannot reach CUPS: the desktop shows the dialog and prints. Every call blocks.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{ Seek, SeekFrom, Write };
|
||||||
|
use std::os::fd::{ FromRawFd, OwnedFd };
|
||||||
|
use std::sync::atomic::{ AtomicU32, Ordering };
|
||||||
|
|
||||||
|
use zbus::blocking::{ Connection, Proxy };
|
||||||
|
use zbus::zvariant::{ Fd, OwnedObjectPath, OwnedValue, Value };
|
||||||
|
|
||||||
|
use super::gtk::{ self, PageValue };
|
||||||
|
use super::PrintOptions;
|
||||||
|
|
||||||
|
const DESTINATION: &str = "org.freedesktop.portal.Desktop";
|
||||||
|
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||||
|
const INTERFACE: &str = "org.freedesktop.portal.Print";
|
||||||
|
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||||
|
|
||||||
|
static NEXT_TOKEN: AtomicU32 = AtomicU32::new( 0 );
|
||||||
|
|
||||||
|
pub fn sandboxed() -> bool
|
||||||
|
{
|
||||||
|
std::path::Path::new( "/.flatpak-info" ).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The options the user chose in the desktop's dialog and the token that prints with them; `None` when they cancelled.
|
||||||
|
pub fn prepare( title: &str, options: &PrintOptions ) -> Result<Option<( PrintOptions, u32 )>, String>
|
||||||
|
{
|
||||||
|
let connection = Connection::session().map_err( |e| e.to_string() )?;
|
||||||
|
let settings: HashMap<&str, Value> = gtk::settings( options, None ).into_iter().map( |( k, v )| ( k, Value::from( v ) ) ).collect();
|
||||||
|
let page: HashMap<&str, Value> = gtk::page_setup( options ).into_iter().map( |( k, v )| ( k, page_value( v ) ) ).collect();
|
||||||
|
let ( response, mut results ) = request( &connection, |token|
|
||||||
|
{
|
||||||
|
let extra = HashMap::from( [ ( "handle_token", Value::from( token ) ), ( "modal", Value::from( true ) ) ] );
|
||||||
|
portal( &connection )?.call::<_, _, OwnedObjectPath>( "PreparePrint", &( "", title, settings, page, extra ) )
|
||||||
|
} )?;
|
||||||
|
match response
|
||||||
|
{
|
||||||
|
0 => {}
|
||||||
|
1 => return Ok( None ),
|
||||||
|
_ => return Err( rust_i18n::t!( "print.failed" ).to_string() ),
|
||||||
|
}
|
||||||
|
let token = results.remove( "token" ).and_then( |v| u32::try_from( v ).ok() ).ok_or( "the portal gave no token" )?;
|
||||||
|
let settings = results.remove( "settings" ).map( strings ).unwrap_or_default();
|
||||||
|
let page = results.remove( "page-setup" ).map( page_values ).unwrap_or_default();
|
||||||
|
Ok( Some( ( gtk::options( &settings, &page, options ), token ) ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand `pdf` to the desktop to print as `token` says.
|
||||||
|
pub fn print( token: u32, title: &str, pdf: &[ u8 ] ) -> Result<(), String>
|
||||||
|
{
|
||||||
|
let connection = Connection::session().map_err( |e| e.to_string() )?;
|
||||||
|
let fd = memfd( pdf ).map_err( |e| e.to_string() )?;
|
||||||
|
let ( response, _ ) = request( &connection, |handle|
|
||||||
|
{
|
||||||
|
let extra = HashMap::from( [ ( "handle_token", Value::from( handle ) ), ( "token", Value::from( token ) ), ( "modal", Value::from( true ) ) ] );
|
||||||
|
portal( &connection )?.call::<_, _, OwnedObjectPath>( "Print", &( "", title, Fd::from( &fd ), extra ) )
|
||||||
|
} )?;
|
||||||
|
if response == 0 { Ok( () ) } else { Err( rust_i18n::t!( "print.failed" ).to_string() ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn portal( connection: &Connection ) -> zbus::Result<Proxy<'static>>
|
||||||
|
{
|
||||||
|
Proxy::new( connection, DESTINATION, PATH, INTERFACE )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listens on the request object before `call` makes it, so the answer cannot come first and be missed.
|
||||||
|
fn request( connection: &Connection, call: impl FnOnce( &str ) -> zbus::Result<OwnedObjectPath> ) -> Result<( u32, HashMap<String, OwnedValue> ), String>
|
||||||
|
{
|
||||||
|
let unique = connection.unique_name().ok_or( "no name on the bus" )?.as_str().trim_start_matches( ':' ).replace( '.', "_" );
|
||||||
|
let token = format!( "ltk{}_{}", std::process::id(), NEXT_TOKEN.fetch_add( 1, Ordering::Relaxed ) );
|
||||||
|
let path = format!( "{PATH}/request/{unique}/{token}" );
|
||||||
|
let proxy = Proxy::new( connection, DESTINATION, path.as_str(), REQUEST ).map_err( |e| e.to_string() )?;
|
||||||
|
let mut answers = proxy.receive_signal( "Response" ).map_err( |e| e.to_string() )?;
|
||||||
|
call( &token ).map_err( |e| e.to_string() )?;
|
||||||
|
let answer = answers.next().ok_or( "the portal did not answer" )?;
|
||||||
|
answer.body().deserialize::<( u32, HashMap<String, OwnedValue> )>().map_err( |e| e.to_string() )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_value( value: PageValue ) -> Value<'static>
|
||||||
|
{
|
||||||
|
match value
|
||||||
|
{
|
||||||
|
PageValue::Text( text ) => Value::from( text ),
|
||||||
|
PageValue::Number( number ) => Value::from( number ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dict( value: OwnedValue ) -> HashMap<String, OwnedValue>
|
||||||
|
{
|
||||||
|
HashMap::<String, OwnedValue>::try_from( value ).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strings( value: OwnedValue ) -> HashMap<String, String>
|
||||||
|
{
|
||||||
|
dict( value ).into_iter().filter_map( |( k, v )| String::try_from( v ).ok().map( |v| ( k, v ) ) ).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_values( value: OwnedValue ) -> HashMap<String, PageValue>
|
||||||
|
{
|
||||||
|
dict( value ).into_iter()
|
||||||
|
.filter_map( |( k, v )|
|
||||||
|
{
|
||||||
|
let value = match &*v
|
||||||
|
{
|
||||||
|
Value::Str( s ) => PageValue::Text( s.to_string() ),
|
||||||
|
Value::F64( n ) => PageValue::Number( *n ),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some( ( k, value ) )
|
||||||
|
} )
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn memfd( data: &[ u8 ] ) -> std::io::Result<OwnedFd>
|
||||||
|
{
|
||||||
|
// SAFETY: a new descriptor this function owns from here on.
|
||||||
|
let raw = unsafe { libc::memfd_create( c"ltk-print".as_ptr(), libc::MFD_CLOEXEC ) };
|
||||||
|
if raw < 0 { return Err( std::io::Error::last_os_error() ); }
|
||||||
|
let mut file = unsafe { File::from_raw_fd( raw ) };
|
||||||
|
file.write_all( data )?;
|
||||||
|
file.seek( SeekFrom::Start( 0 ) )?;
|
||||||
|
Ok( OwnedFd::from( file ) )
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rust_i18n::t;
|
||||||
|
|
||||||
|
use crate::file_chooser::view::icon;
|
||||||
|
use crate::{ button, column, container, dialog, flex, icon_button, img_widget, list_item, row, scroll, separator, spacer, text, text_edit, toggle };
|
||||||
|
use crate::{ ButtonVariant, Element, Length };
|
||||||
|
|
||||||
|
use super::{ Margins, Orientation, PrintDialog, PrintDialogMsg, Printer, PrinterState, Section, Sides, PAGES, PAPERS };
|
||||||
|
|
||||||
|
const ROW_H: f32 = 44.0;
|
||||||
|
const TOOL: f32 = 40.0;
|
||||||
|
const TOOL_ICON: f32 = 22.0;
|
||||||
|
const INDENT: f32 = 32.0;
|
||||||
|
const CARD_W: f32 = 880.0;
|
||||||
|
/// The box a page of the preview is fitted in.
|
||||||
|
const SHEET: ( f32, f32 ) = ( 320.0, 420.0 );
|
||||||
|
const SHEET_COMPACT: ( f32, f32 ) = ( 240.0, 260.0 );
|
||||||
|
|
||||||
|
const CLOSE: &str = "navigation/close";
|
||||||
|
const PREVIOUS: &str = "general/left-simple";
|
||||||
|
const NEXT: &str = "general/right-simple";
|
||||||
|
const LESS: &str = "actions/remove";
|
||||||
|
const MORE: &str = "actions/add";
|
||||||
|
const PRINTER: &str = "hardware/printer";
|
||||||
|
const PDF: &str = "archives/pdf";
|
||||||
|
|
||||||
|
pub( super ) fn view<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg> ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
if d.compact { return compact( d ); }
|
||||||
|
let mut body = row().spacing( Length::widget( 16.0 ) ).align_top();
|
||||||
|
if let Some( preview ) = preview( d, SHEET ) { body = body.push( preview ); }
|
||||||
|
dialog()
|
||||||
|
.title( t!( "print.title" ).to_string() )
|
||||||
|
.subtitle( d.title.clone() )
|
||||||
|
.body( body.push( flex( settings( d ) ) ) )
|
||||||
|
.cancel( d.msg( PrintDialogMsg::Cancel ) )
|
||||||
|
.action( cancel_button( d ) )
|
||||||
|
.action( print_button( d ) )
|
||||||
|
.max_width( Length::widget( CARD_W ) )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg> ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
let titles = column()
|
||||||
|
.padding( 0.0 )
|
||||||
|
.spacing( 0.0 )
|
||||||
|
.align_center_x( false )
|
||||||
|
.push( text( t!( "print.title" ).to_string() ).size( 15.0 ).weight( 600 ).color( palette.text_primary ) )
|
||||||
|
.push( text( d.title.clone() ).size( 12.0 ).color( palette.text_secondary ) );
|
||||||
|
let header = row()
|
||||||
|
.padding( Length::widget( 4.0 ) )
|
||||||
|
.spacing( Length::widget( 4.0 ) )
|
||||||
|
.push( tool( d, CLOSE, t!( "print.cancel" ).to_string(), Some( PrintDialogMsg::Cancel ) ) )
|
||||||
|
.push( flex( titles ) );
|
||||||
|
let mut content = column().padding( Length::widget( 8.0 ) ).spacing( Length::widget( 8.0 ) );
|
||||||
|
if let Some( preview ) = preview( d, SHEET_COMPACT )
|
||||||
|
{
|
||||||
|
content = content.push( row().spacing( 0.0 ).push( flex( spacer() ) ).push( preview ).push( flex( spacer() ) ) );
|
||||||
|
}
|
||||||
|
let actions = row()
|
||||||
|
.padding( Length::widget( 8.0 ) )
|
||||||
|
.spacing( Length::widget( 8.0 ) )
|
||||||
|
.align_right()
|
||||||
|
.push( cancel_button( d ) )
|
||||||
|
.push( print_button( d ) );
|
||||||
|
let page = column()
|
||||||
|
.padding( 0.0 )
|
||||||
|
.spacing( 0.0 )
|
||||||
|
.push( container( header ).background( palette.surface_alt ) )
|
||||||
|
.push( separator() )
|
||||||
|
.push( flex( scroll( content.push( settings( d ) ) ) ) )
|
||||||
|
.push( separator() )
|
||||||
|
.push( actions );
|
||||||
|
container( page ).background( palette.bg ).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page shown, fitted in `sheet`, and how to move between the pages.
|
||||||
|
fn preview<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg>, ( box_w, box_h ): ( f32, f32 ) ) -> Option<Element<Msg>>
|
||||||
|
{
|
||||||
|
if !d.previews { return None; }
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
let mut pane = column().fit_content().align_center_x( true ).padding( 0.0 ).spacing( Length::widget( 8.0 ) );
|
||||||
|
match d.images.get( d.shown )
|
||||||
|
{
|
||||||
|
Some( ( rgba, width, height ) ) =>
|
||||||
|
{
|
||||||
|
let scale = ( box_w / *width as f32 ).min( box_h / *height as f32 );
|
||||||
|
let page = img_widget( Arc::clone( rgba ), *width, *height )
|
||||||
|
.size( Length::widget( *width as f32 * scale ), Length::widget( *height as f32 * scale ) );
|
||||||
|
let sheet = container( page ).border( palette.divider, 1.0 ).opacity( if d.rendering.is_some() { 0.6 } else { 1.0 } );
|
||||||
|
pane = pane.push( sheet ).push( pager( d ) );
|
||||||
|
}
|
||||||
|
None =>
|
||||||
|
{
|
||||||
|
let message = match &d.preview_error
|
||||||
|
{
|
||||||
|
Some( error ) => error.clone(),
|
||||||
|
None => t!( "print.preparing" ).to_string(),
|
||||||
|
};
|
||||||
|
let placeholder = column()
|
||||||
|
.fit_content()
|
||||||
|
.align_center_x( true )
|
||||||
|
.padding( 0.0 )
|
||||||
|
.spacing( 0.0 )
|
||||||
|
.push( spacer().width( Length::widget( box_w ) ).height( Length::widget( box_h / 2.0 - 10.0 ) ) )
|
||||||
|
.push( container( text( message ).size( 13.0 ).color( palette.text_secondary ).wrap( true ).align_center() ).max_width( box_w ) )
|
||||||
|
.push( spacer().height( Length::widget( box_h / 2.0 - 10.0 ) ) );
|
||||||
|
pane = pane.push( container( placeholder ).border( palette.divider, 1.0 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some( pane.into() )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pager<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg> ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
let count = d.images.len();
|
||||||
|
let mut position = t!( "print.page_of", page = d.shown + 1, count = count ).to_string();
|
||||||
|
if let Some( total ) = d.preview.as_ref().map( |p| p.total ).filter( |total| *total != count )
|
||||||
|
{
|
||||||
|
position = format!( "{position} · {}", t!( "print.total", count = total ) );
|
||||||
|
}
|
||||||
|
row()
|
||||||
|
.spacing( Length::widget( 4.0 ) )
|
||||||
|
.push( tool( d, PREVIOUS, t!( "print.previous" ).to_string(), ( d.shown > 0 ).then_some( PrintDialogMsg::Page( -1 ) ) ) )
|
||||||
|
.push( text( position ).size( 13.0 ).color( palette.text_secondary ) )
|
||||||
|
.push( tool( d, NEXT, t!( "print.next" ).to_string(), ( d.shown + 1 < count ).then_some( PrintDialogMsg::Page( 1 ) ) ) )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg> ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
let options = &d.options;
|
||||||
|
let printing = d.destination.is_some();
|
||||||
|
let mut list = column().padding( 0.0 ).spacing( 0.0 ).align_center_x( false );
|
||||||
|
|
||||||
|
let target = match d.destination.and_then( |i| d.printers.get( i ) )
|
||||||
|
{
|
||||||
|
Some( printer ) => printer.description.clone(),
|
||||||
|
None => t!( "print.pdf" ).to_string(),
|
||||||
|
};
|
||||||
|
list = list.push( choice( d, t!( "print.destination" ).to_string(), target, Section::Destination ) );
|
||||||
|
if d.open == Some( Section::Destination )
|
||||||
|
{
|
||||||
|
for ( i, printer ) in d.printers.iter().enumerate()
|
||||||
|
{
|
||||||
|
let status = status( printer );
|
||||||
|
list = list.push( item( d, printer.description.clone(), Some( PRINTER ), ( !status.is_empty() ).then_some( status ), d.destination == Some( i ), PrintDialogMsg::Destination( Some( i ) ) ) );
|
||||||
|
}
|
||||||
|
list = list.push( item( d, t!( "print.pdf" ).to_string(), Some( PDF ), None, !printing, PrintDialogMsg::Destination( None ) ) );
|
||||||
|
if d.listing
|
||||||
|
{
|
||||||
|
list = list.push( note( t!( "print.searching" ).to_string() ) );
|
||||||
|
} else if let Some( error ) = &d.list_error {
|
||||||
|
list = list.push( note( error.clone() ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if printing
|
||||||
|
{
|
||||||
|
let copies = options.copies;
|
||||||
|
list = list.push( stepper( d, t!( "print.copies" ).to_string(), copies.to_string(),
|
||||||
|
( copies > 1 ).then_some( PrintDialogMsg::Copies( -1 ) ),
|
||||||
|
( copies < super::PrintOptions::MAX_COPIES ).then_some( PrintDialogMsg::Copies( 1 ) ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
list = list
|
||||||
|
.push( heading( t!( "print.pages" ).to_string() ) )
|
||||||
|
.push( item( d, t!( "print.all_pages" ).to_string(), None, None, !d.some_pages, PrintDialogMsg::AllPages ) )
|
||||||
|
.push( item( d, t!( "print.some_pages" ).to_string(), None, None, d.some_pages, PrintDialogMsg::SomePages ) );
|
||||||
|
if d.some_pages
|
||||||
|
{
|
||||||
|
let wrap = Arc::clone( &d.wrap );
|
||||||
|
let field = text_edit( t!( "print.pages_hint" ).to_string(), d.pages_text.clone() )
|
||||||
|
.id( PAGES )
|
||||||
|
.on_change( move |text| wrap( PrintDialogMsg::Pages( text ) ) )
|
||||||
|
.on_submit( d.msg( PrintDialogMsg::Print ) );
|
||||||
|
list = list.push( container( field ).padding_left( Length::widget( INDENT ) ).padding_right( Length::widget( 8.0 ) ) );
|
||||||
|
if d.pages_bad
|
||||||
|
{
|
||||||
|
let hint = text( t!( "print.pages_bad" ).to_string() ).size( 12.0 ).color( palette.danger ).wrap( true );
|
||||||
|
list = list.push( container( hint ).padding_left( Length::widget( INDENT ) ).padding_v( Length::widget( 4.0 ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list = list.push( choice( d, t!( "print.paper" ).to_string(), options.paper.name.to_string(), Section::Paper ) );
|
||||||
|
if d.open == Some( Section::Paper )
|
||||||
|
{
|
||||||
|
for ( i, paper ) in PAPERS.iter().enumerate()
|
||||||
|
{
|
||||||
|
list = list.push( item( d, paper.name.to_string(), None, None, options.paper == *paper, PrintDialogMsg::Paper( i ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list = list.push( choice( d, t!( "print.orientation" ).to_string(), orientation_label( options.orientation ), Section::Orientation ) );
|
||||||
|
if d.open == Some( Section::Orientation )
|
||||||
|
{
|
||||||
|
for orientation in [ Orientation::Portrait, Orientation::Landscape ]
|
||||||
|
{
|
||||||
|
list = list.push( item( d, orientation_label( orientation ), None, None, options.orientation == orientation, PrintDialogMsg::Orientation( orientation ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list = list.push( choice( d, t!( "print.margins" ).to_string(), margins_label( options.margins ), Section::Margins ) );
|
||||||
|
if d.open == Some( Section::Margins )
|
||||||
|
{
|
||||||
|
for margins in Margins::ALL
|
||||||
|
{
|
||||||
|
list = list.push( item( d, margins_label( margins ), None, None, options.margins == margins, PrintDialogMsg::Margins( margins ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let percent = ( options.scale * 100.0 ).round() as i32;
|
||||||
|
list = list.push( stepper( d, t!( "print.scale" ).to_string(), format!( "{percent} %" ),
|
||||||
|
( options.scale > super::PrintOptions::MIN_SCALE + f32::EPSILON ).then_some( PrintDialogMsg::Scale( -1 ) ),
|
||||||
|
( options.scale < super::PrintOptions::MAX_SCALE - f32::EPSILON ).then_some( PrintDialogMsg::Scale( 1 ) ) ) );
|
||||||
|
|
||||||
|
if printing
|
||||||
|
{
|
||||||
|
list = list.push( choice( d, t!( "print.sides" ).to_string(), sides_label( options.sides ), Section::Sides ) );
|
||||||
|
if d.open == Some( Section::Sides )
|
||||||
|
{
|
||||||
|
for sides in Sides::ALL
|
||||||
|
{
|
||||||
|
list = list.push( item( d, sides_label( sides ), None, None, options.sides == sides, PrintDialogMsg::Sides( sides ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list = list.push( switch( d, t!( "print.color" ).to_string(), options.color, PrintDialogMsg::Color ) );
|
||||||
|
}
|
||||||
|
list.push( switch( d, t!( "print.backgrounds" ).to_string(), options.backgrounds, PrintDialogMsg::Backgrounds ) ).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A setting and its value, which shows its choices under it when pressed.
|
||||||
|
fn choice<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg>, label: String, value: String, section: Section ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
list_item( label )
|
||||||
|
.height( Length::widget( ROW_H ) )
|
||||||
|
.ink( palette.text_primary, palette.text_secondary )
|
||||||
|
.trailing( value )
|
||||||
|
.selected( d.open == Some( section ) )
|
||||||
|
.on_press( d.msg( PrintDialogMsg::Toggle( section ) ) )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg>, label: String, glyph: Option<&'static str>, below: Option<String>, selected: bool, msg: PrintDialogMsg ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
let mut entry = list_item( label )
|
||||||
|
.height( Length::widget( if below.is_some() { 56.0 } else { ROW_H } ) )
|
||||||
|
.pad_h( Length::widget( INDENT ) )
|
||||||
|
.ink( palette.text_primary, palette.text_secondary )
|
||||||
|
.selected( selected )
|
||||||
|
.on_press( d.msg( msg ) );
|
||||||
|
if let Some( below ) = below { entry = entry.subtitle( below ); }
|
||||||
|
if let Some( ( rgba, w, h ) ) = glyph.and_then( |glyph| icon( glyph, palette.icon ) ) { entry = entry.icon( rgba, w, h ); }
|
||||||
|
entry.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stepper<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg>, label: String, value: String, less: Option<PrintDialogMsg>, more: Option<PrintDialogMsg> ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
row()
|
||||||
|
.spacing( 0.0 )
|
||||||
|
.push( flex( container( text( label ).size( 15.0 ).color( palette.text_primary ) ).padding_h( Length::widget( 16.0 ) ) ) )
|
||||||
|
.push( tool( d, LESS, t!( "print.less" ).to_string(), less ) )
|
||||||
|
.push( text( value ).size( 14.0 ).color( palette.text_primary ) )
|
||||||
|
.push( tool( d, MORE, t!( "print.more" ).to_string(), more ) )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg>, label: String, on: bool, msg: PrintDialogMsg ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
container( toggle( on ).label( label ).on_toggle( d.msg( msg ) ) )
|
||||||
|
.padding_h( Length::widget( 16.0 ) )
|
||||||
|
.padding_v( Length::widget( 6.0 ) )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heading<Msg: Clone + 'static>( label: String ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
container( text( label ).size( 15.0 ).color( palette.text_primary ) )
|
||||||
|
.padding_h( Length::widget( 16.0 ) )
|
||||||
|
.padding_v( Length::widget( 12.0 ) )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note<Msg: Clone + 'static>( message: String ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
container( text( message ).size( 13.0 ).color( crate::theme::palette().text_secondary ).wrap( true ) )
|
||||||
|
.padding_left( Length::widget( INDENT ) )
|
||||||
|
.padding_v( Length::widget( 8.0 ) )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg>, glyph: &'static str, label: String, msg: Option<PrintDialogMsg> ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
let tint = if msg.is_some() { palette.icon } else { palette.divider };
|
||||||
|
let base = match icon( glyph, tint )
|
||||||
|
{
|
||||||
|
Some( ( rgba, w, h ) ) => icon_button( rgba, w, h ).icon_size( Length::widget( TOOL_ICON ) ),
|
||||||
|
None => button( label.clone() ),
|
||||||
|
};
|
||||||
|
base.variant( ButtonVariant::Tertiary )
|
||||||
|
.tooltip( label )
|
||||||
|
.width( Length::widget( TOOL ) )
|
||||||
|
.height( Length::widget( TOOL ) )
|
||||||
|
.on_press_maybe( msg.map( |msg| d.msg( msg ) ) )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_button<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg> ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
button( t!( "print.cancel" ).to_string() ).variant( ButtonVariant::Secondary ).on_press( d.msg( PrintDialogMsg::Cancel ) ).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_button<Msg: Clone + Send + 'static>( d: &PrintDialog<Msg> ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
let label = if d.destination.is_some() { t!( "print.print" ) } else { t!( "print.save" ) };
|
||||||
|
button( label.to_string() )
|
||||||
|
.variant( ButtonVariant::Primary )
|
||||||
|
.on_press_maybe( d.can_print().then( || d.msg( PrintDialogMsg::Print ) ) )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status( printer: &Printer ) -> String
|
||||||
|
{
|
||||||
|
let state = match ( printer.state, printer.accepting )
|
||||||
|
{
|
||||||
|
( _, false ) => t!( "print.rejecting" ).to_string(),
|
||||||
|
( PrinterState::Idle, _ ) => t!( "print.ready" ).to_string(),
|
||||||
|
( PrinterState::Printing, _ ) => t!( "print.busy" ).to_string(),
|
||||||
|
( PrinterState::Stopped, _ ) => t!( "print.stopped" ).to_string(),
|
||||||
|
( PrinterState::Unknown, _ ) => String::new(),
|
||||||
|
};
|
||||||
|
[ state, printer.location.clone() ].into_iter().filter( |s| !s.is_empty() ).collect::<Vec<_>>().join( " · " )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn orientation_label( orientation: Orientation ) -> String
|
||||||
|
{
|
||||||
|
match orientation
|
||||||
|
{
|
||||||
|
Orientation::Portrait => t!( "print.portrait" ),
|
||||||
|
Orientation::Landscape => t!( "print.landscape" ),
|
||||||
|
}.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn margins_label( margins: Margins ) -> String
|
||||||
|
{
|
||||||
|
match margins
|
||||||
|
{
|
||||||
|
Margins::Default => t!( "print.margins_default" ),
|
||||||
|
Margins::Narrow => t!( "print.margins_narrow" ),
|
||||||
|
Margins::Wide => t!( "print.margins_wide" ),
|
||||||
|
Margins::None => t!( "print.margins_none" ),
|
||||||
|
}.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sides_label( sides: Sides ) -> String
|
||||||
|
{
|
||||||
|
match sides
|
||||||
|
{
|
||||||
|
Sides::One => t!( "print.one_side" ),
|
||||||
|
Sides::LongEdge => t!( "print.long_edge" ),
|
||||||
|
Sides::ShortEdge => t!( "print.short_edge" ),
|
||||||
|
}.to_string()
|
||||||
|
}
|
||||||
@@ -1031,6 +1031,16 @@ impl Canvas
|
|||||||
Canvas::Gles( c ) => c.present(),
|
Canvas::Gles( c ) => c.present(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Self::present`] onto a window framebuffer of `width × height` physical pixels, the frame stretched to cover it where a fractional scale left the canvas a pixel smaller or larger.
|
||||||
|
pub fn present_to( &mut self, width: u32, height: u32 )
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Canvas::Software( _ ) => {}
|
||||||
|
Canvas::Gles( c ) => c.present_to( width, height ),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( test ) ]
|
#[ cfg( test ) ]
|
||||||
|
|||||||
+19
-2
@@ -17,10 +17,27 @@ pub fn set_resource_dir( dir: impl Into<PathBuf> )
|
|||||||
let _ = RESOURCE_DIR.set( dir.into() );
|
let _ = RESOURCE_DIR.set( dir.into() );
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The app's resource bundle, when [`set_resource_dir`] gave one; on Android, the resources unpacked from the APK. An app finds its own bundled files there.
|
/// The app's resource bundle, when [`set_resource_dir`] gave one; on Android, the resources unpacked from the APK; on Windows, otherwise, `ltk\` beside the executable when it exists. An app finds its own bundled files there.
|
||||||
pub fn resource_dir() -> Option<&'static Path>
|
pub fn resource_dir() -> Option<&'static Path>
|
||||||
{
|
{
|
||||||
RESOURCE_DIR.get().map( PathBuf::as_path )
|
RESOURCE_DIR.get().map( PathBuf::as_path ).or_else( beside_executable )
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( windows ) ]
|
||||||
|
fn beside_executable() -> Option<&'static Path>
|
||||||
|
{
|
||||||
|
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||||
|
DIR.get_or_init( ||
|
||||||
|
{
|
||||||
|
let dir = std::env::current_exe().ok()?.parent()?.join( "ltk" );
|
||||||
|
dir.is_dir().then_some( dir )
|
||||||
|
} ).as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( not( windows ) ) ]
|
||||||
|
fn beside_executable() -> Option<&'static Path>
|
||||||
|
{
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The font at `path`, or when there is none, the bundled font of the same file name.
|
/// The font at `path`, or when there is none, the bundled font of the same file name.
|
||||||
|
|||||||
+87
-36
@@ -1,20 +1,28 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
//! On-disk session state: `$XDG_STATE_HOME/<app_id>/{session.json,state.bin}`.
|
//! On-disk session state: `$XDG_STATE_HOME/<app_id>/{session.json,state.bin}`, on Windows `%LOCALAPPDATA%\<app_id>\` when `XDG_STATE_HOME` is unset.
|
||||||
//!
|
//!
|
||||||
//! `session.json` carries the compositor session id, a clean-exit marker
|
//! `session.json` carries the compositor session id, a clean-exit marker
|
||||||
//! and the pid of the run that wrote it; `state.bin` holds the bytes the
|
//! and the pid of the run that wrote it; `state.bin` holds the bytes the
|
||||||
//! application returned from `App::save_state`. Everything is best effort:
|
//! application returned from `App::save_state`. Everything is best effort:
|
||||||
//! I/O failures are logged and never abort the application.
|
//! I/O failures are logged and never abort the application.
|
||||||
|
|
||||||
use std::ffi::OsStr;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{ Path, PathBuf };
|
use std::path::{ Path, PathBuf };
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::{ Deserialize, Serialize };
|
use serde::{ Deserialize, Serialize };
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
/// How often a running app's state is saved, besides on a clean exit.
|
||||||
|
pub( crate ) const SAVE_INTERVAL: Duration = Duration::from_secs( 30 );
|
||||||
|
|
||||||
|
/// Set to `1` by a shell that relaunches the app as part of a session restore.
|
||||||
|
pub( crate ) const RESTORE_ENV: &str = "LTK_SESSION_RESTORE";
|
||||||
|
|
||||||
const SESSION_FILE: &str = "session.json";
|
const SESSION_FILE: &str = "session.json";
|
||||||
const STATE_FILE: &str = "state.bin";
|
const STATE_FILE: &str = "state.bin";
|
||||||
const FORMAT_VERSION: u32 = 1;
|
const FORMAT_VERSION: u32 = 1;
|
||||||
@@ -57,33 +65,23 @@ pub( crate ) struct StateStore
|
|||||||
last_saved: Option<Vec<u8>>,
|
last_saved: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the state directory for `app_id`, or `None` when the id is unusable
|
/// The state directory for `app_id` under `base`, or `None` when the id is unusable as a path component or there is no base.
|
||||||
/// as a path component or no base directory can be determined.
|
pub( crate ) fn state_dir( app_id: &str, base: Option<PathBuf> ) -> Option<PathBuf>
|
||||||
pub( crate ) fn state_dir(
|
|
||||||
app_id: &str,
|
|
||||||
xdg_state_home: Option<&OsStr>,
|
|
||||||
home: Option<&OsStr>,
|
|
||||||
) -> Option<PathBuf>
|
|
||||||
{
|
{
|
||||||
if app_id.is_empty() || app_id.contains( '/' ) || app_id == "." || app_id == ".."
|
if app_id.is_empty() || app_id.contains( '/' ) || app_id == "." || app_id == ".."
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let base = match xdg_state_home.filter( |v| !v.is_empty() )
|
#[ cfg( windows ) ]
|
||||||
{
|
if app_id.contains( [ '\\', ':' ] ) { return None; }
|
||||||
Some( v ) => PathBuf::from( v ),
|
Some( base?.join( app_id ) )
|
||||||
None => PathBuf::from( home.filter( |v| !v.is_empty() )? ).join( ".local" ).join( "state" ),
|
|
||||||
};
|
|
||||||
Some( base.join( app_id ) )
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StateStore
|
impl StateStore
|
||||||
{
|
{
|
||||||
pub fn open( app_id: &str ) -> Option<Self>
|
pub fn open( app_id: &str ) -> Option<Self>
|
||||||
{
|
{
|
||||||
let xdg = std::env::var_os( "XDG_STATE_HOME" );
|
let dir = state_dir( app_id, crate::dirs::state_home() );
|
||||||
let home = std::env::var_os( "HOME" );
|
|
||||||
let dir = state_dir( app_id, xdg.as_deref(), home.as_deref() );
|
|
||||||
if dir.is_none()
|
if dir.is_none()
|
||||||
{
|
{
|
||||||
eprintln!( "ltk: session state disabled: cannot derive a state directory for app_id {app_id:?}" );
|
eprintln!( "ltk: session state disabled: cannot derive a state directory for app_id {app_id:?}" );
|
||||||
@@ -95,6 +93,7 @@ impl StateStore
|
|||||||
{
|
{
|
||||||
let mut builder = fs::DirBuilder::new();
|
let mut builder = fs::DirBuilder::new();
|
||||||
builder.recursive( true );
|
builder.recursive( true );
|
||||||
|
#[ cfg( unix ) ]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::DirBuilderExt;
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
builder.mode( 0o700 );
|
builder.mode( 0o700 );
|
||||||
@@ -223,28 +222,82 @@ impl StateStore
|
|||||||
fn write_atomic( path: &Path, bytes: &[u8] ) -> io::Result<()>
|
fn write_atomic( path: &Path, bytes: &[u8] ) -> io::Result<()>
|
||||||
{
|
{
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
|
|
||||||
let mut tmp = path.as_os_str().to_owned();
|
let mut tmp = path.as_os_str().to_owned();
|
||||||
tmp.push( ".tmp" );
|
tmp.push( ".tmp" );
|
||||||
let tmp = PathBuf::from( tmp );
|
let tmp = PathBuf::from( tmp );
|
||||||
{
|
{
|
||||||
let mut f = fs::OpenOptions::new()
|
let mut options = fs::OpenOptions::new();
|
||||||
.write( true )
|
options.write( true ).create( true ).truncate( true );
|
||||||
.create( true )
|
#[ cfg( unix ) ]
|
||||||
.truncate( true )
|
{
|
||||||
.mode( 0o600 )
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
.open( &tmp )?;
|
options.mode( 0o600 );
|
||||||
|
}
|
||||||
|
let mut f = options.open( &tmp )?;
|
||||||
f.write_all( bytes )?;
|
f.write_all( bytes )?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
}
|
}
|
||||||
fs::rename( &tmp, path )
|
fs::rename( &tmp, path )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( unix ) ]
|
||||||
fn pid_alive( pid: u32 ) -> bool
|
fn pid_alive( pid: u32 ) -> bool
|
||||||
{
|
{
|
||||||
Path::new( "/proc" ).join( pid.to_string() ).exists()
|
Path::new( "/proc" ).join( pid.to_string() ).exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( windows ) ]
|
||||||
|
fn pid_alive( pid: u32 ) -> bool
|
||||||
|
{
|
||||||
|
use windows_sys::Win32::Foundation::{ CloseHandle, STILL_ACTIVE };
|
||||||
|
use windows_sys::Win32::System::Threading::{ GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION };
|
||||||
|
|
||||||
|
// SAFETY: the handle is checked for null and closed before returning; `code` is a place for the call to write.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let process = OpenProcess( PROCESS_QUERY_LIMITED_INFORMATION, 0, pid );
|
||||||
|
if process.is_null() { return false; }
|
||||||
|
let mut code = 0;
|
||||||
|
let alive = GetExitCodeProcess( process, &mut code ) != 0 && code == STILL_ACTIVE as u32;
|
||||||
|
CloseHandle( process );
|
||||||
|
alive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before any window exists: read the restore hint from the environment, decide why the app is starting, hand it its saved bytes when that calls for them, and mark the run as live. `None` for the store when persistence is off: an unusable `app_id`, no state directory, or another instance running.
|
||||||
|
pub( crate ) fn bootstrap<A: App>( app: &mut A ) -> ( Option<StateStore>, RestoreReason )
|
||||||
|
{
|
||||||
|
let env_restore = std::env::var_os( RESTORE_ENV ).is_some_and( |v| v == "1" );
|
||||||
|
if std::env::var_os( RESTORE_ENV ).is_some()
|
||||||
|
{
|
||||||
|
// SAFETY: removing an env var is sound only when no other thread
|
||||||
|
// is reading the environment concurrently. We are still in the
|
||||||
|
// init phase before `set_channel_sender`, so the app has had
|
||||||
|
// no opportunity to spawn worker threads yet.
|
||||||
|
unsafe { std::env::remove_var( RESTORE_ENV ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some( mut store ) = StateStore::open( app.app_id() ) else { return ( None, RestoreReason::Launch ) };
|
||||||
|
let reason = match store.decide( env_restore )
|
||||||
|
{
|
||||||
|
Startup::Concurrent =>
|
||||||
|
{
|
||||||
|
eprintln!( "ltk: another instance of {} is running; session persistence disabled", app.app_id() );
|
||||||
|
return ( None, RestoreReason::Launch );
|
||||||
|
}
|
||||||
|
Startup::Reason( reason ) => reason,
|
||||||
|
};
|
||||||
|
if reason != RestoreReason::Launch || app.restore_on_launch()
|
||||||
|
{
|
||||||
|
if let Some( bytes ) = store.load_state()
|
||||||
|
{
|
||||||
|
app.restore_state( bytes );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.mark_running();
|
||||||
|
( Some( store ), reason )
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( test ) ]
|
#[ cfg( test ) ]
|
||||||
@@ -264,23 +317,20 @@ mod tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
fn state_dir_prefers_xdg_then_home()
|
fn state_dir_is_the_id_under_the_base()
|
||||||
{
|
{
|
||||||
let xdg = OsStr::new( "/tmp/xdg" );
|
let base = || Some( PathBuf::from( "/home/u/.local/state" ) );
|
||||||
let home = OsStr::new( "/home/u" );
|
assert_eq!( state_dir( "net.example.App", base() ), Some( PathBuf::from( "/home/u/.local/state/net.example.App" ) ) );
|
||||||
assert_eq!( state_dir( "net.example.App", Some( xdg ), Some( home ) ), Some( PathBuf::from( "/tmp/xdg/net.example.App" ) ) );
|
assert_eq!( state_dir( "net.example.App", None ), None );
|
||||||
assert_eq!( state_dir( "net.example.App", None, Some( home ) ), Some( PathBuf::from( "/home/u/.local/state/net.example.App" ) ) );
|
|
||||||
assert_eq!( state_dir( "net.example.App", Some( OsStr::new( "" ) ), Some( home ) ), Some( PathBuf::from( "/home/u/.local/state/net.example.App" ) ) );
|
|
||||||
assert_eq!( state_dir( "net.example.App", None, None ), None );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
fn state_dir_rejects_bad_ids()
|
fn state_dir_rejects_bad_ids()
|
||||||
{
|
{
|
||||||
let home = OsStr::new( "/home/u" );
|
let base = || Some( PathBuf::from( "/home/u/.local/state" ) );
|
||||||
assert_eq!( state_dir( "", None, Some( home ) ), None );
|
assert_eq!( state_dir( "", base() ), None );
|
||||||
assert_eq!( state_dir( "a/b", None, Some( home ) ), None );
|
assert_eq!( state_dir( "a/b", base() ), None );
|
||||||
assert_eq!( state_dir( "..", None, Some( home ) ), None );
|
assert_eq!( state_dir( "..", base() ), None );
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
@@ -331,6 +381,7 @@ mod tests
|
|||||||
assert!( !store.save_state_if_changed( None ) );
|
assert!( !store.save_state_if_changed( None ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( unix ) ]
|
||||||
#[ test ]
|
#[ test ]
|
||||||
fn file_modes_are_private()
|
fn file_modes_are_private()
|
||||||
{
|
{
|
||||||
|
|||||||
+26
-5
@@ -74,7 +74,7 @@ struct FallbackFontSpec
|
|||||||
/// and CJK packs trail. DejaVu goes ahead of the CJK collection: it owns
|
/// and CJK packs trail. DejaVu goes ahead of the CJK collection: it owns
|
||||||
/// the geometric shapes, arrows and box drawing that would otherwise
|
/// the geometric shapes, arrows and box drawing that would otherwise
|
||||||
/// pull a 19 MB collection in for a single bullet.
|
/// pull a 19 MB collection in for a single bullet.
|
||||||
#[ cfg( not( target_os = "android" ) ) ]
|
#[ cfg( not( any( target_os = "android", windows ) ) ) ]
|
||||||
const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
|
const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
|
||||||
&[
|
&[
|
||||||
// Noto Sans — Cyrillic, Greek, extended Latin.
|
// Noto Sans — Cyrillic, Greek, extended Latin.
|
||||||
@@ -129,6 +129,20 @@ const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
|
|||||||
FallbackFontSpec { path: "/system/fonts/NotoSansCJK-Regular.ttc", face: 2 },
|
FallbackFontSpec { path: "/system/fonts/NotoSansCJK-Regular.ttc", face: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// The same chain over the fonts every Windows 10 and 11 installs; Nirmala UI is a collection since Windows 11.
|
||||||
|
#[ cfg( windows ) ]
|
||||||
|
const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
|
||||||
|
&[
|
||||||
|
FallbackFontSpec { path: "C:\\Windows\\Fonts\\segoeui.ttf", face: 0 },
|
||||||
|
FallbackFontSpec { path: "C:\\Windows\\Fonts\\Nirmala.ttc", face: 0 },
|
||||||
|
FallbackFontSpec { path: "C:\\Windows\\Fonts\\Nirmala.ttf", face: 0 },
|
||||||
|
FallbackFontSpec { path: "C:\\Windows\\Fonts\\LeelawUI.ttf", face: 0 },
|
||||||
|
FallbackFontSpec { path: "C:\\Windows\\Fonts\\seguisym.ttf", face: 0 },
|
||||||
|
FallbackFontSpec { path: "C:\\Windows\\Fonts\\seguiemj.ttf", face: 0 },
|
||||||
|
FallbackFontSpec { path: "C:\\Windows\\Fonts\\malgun.ttf", face: 0 },
|
||||||
|
FallbackFontSpec { path: "C:\\Windows\\Fonts\\msyh.ttc", face: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
/// Per-slot lazy state, two stages so probing a slot for coverage never
|
/// Per-slot lazy state, two stages so probing a slot for coverage never
|
||||||
/// builds a face: `bytes` is the file read once, `font` is the face
|
/// builds a face: `bytes` is the file read once, `font` is the face
|
||||||
/// built only when a glyph in this slot is actually needed. `None` in a
|
/// built only when a glyph in this slot is actually needed. `None` in a
|
||||||
@@ -199,7 +213,7 @@ pub fn lookup( ch: char ) -> Option<Arc<FontFace>>
|
|||||||
|
|
||||||
/// System-font search chain for the primary UI font, ordered by preference.
|
/// System-font search chain for the primary UI font, ordered by preference.
|
||||||
/// Shared by [`find_font_opt`] and [`load_default_font_bytes`].
|
/// Shared by [`find_font_opt`] and [`load_default_font_bytes`].
|
||||||
#[ cfg( not( target_os = "android" ) ) ]
|
#[ cfg( not( any( target_os = "android", windows ) ) ) ]
|
||||||
const SYSTEM_FONT_CANDIDATES: &[&str] =
|
const SYSTEM_FONT_CANDIDATES: &[&str] =
|
||||||
&[
|
&[
|
||||||
// Debian `fonts-sora` — the canonical path `ltk-theme-default`
|
// Debian `fonts-sora` — the canonical path `ltk-theme-default`
|
||||||
@@ -217,8 +231,8 @@ const SYSTEM_FONT_CANDIDATES: &[&str] =
|
|||||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// None on Android: the embedded Sora is ltk's face there, not the system's Roboto.
|
/// None on Android and Windows: the embedded Sora is ltk's face there, not the system's Roboto or Segoe UI.
|
||||||
#[ cfg( target_os = "android" ) ]
|
#[ cfg( any( target_os = "android", windows ) ) ]
|
||||||
const SYSTEM_FONT_CANDIDATES: &[&str] = &[];
|
const SYSTEM_FONT_CANDIDATES: &[&str] = &[];
|
||||||
|
|
||||||
/// Resolve the first system font available from [`SYSTEM_FONT_CANDIDATES`], or
|
/// Resolve the first system font available from [`SYSTEM_FONT_CANDIDATES`], or
|
||||||
@@ -286,7 +300,7 @@ pub fn lookup_handle( ch: char ) -> Option<FontHandle>
|
|||||||
/// The monospace face, as found among the app's resources, which a page and an APK carry, and then among the system's fonts.
|
/// The monospace face, as found among the app's resources, which a page and an APK carry, and then among the system's fonts.
|
||||||
const MONOSPACE_BUNDLED: &str = "DejaVuSansMono.ttf";
|
const MONOSPACE_BUNDLED: &str = "DejaVuSansMono.ttf";
|
||||||
|
|
||||||
#[ cfg( not( target_os = "android" ) ) ]
|
#[ cfg( not( any( target_os = "android", windows ) ) ) ]
|
||||||
const MONOSPACE_CANDIDATES: &[&str] =
|
const MONOSPACE_CANDIDATES: &[&str] =
|
||||||
&[
|
&[
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||||
@@ -303,6 +317,13 @@ const MONOSPACE_CANDIDATES: &[&str] =
|
|||||||
"/system/fonts/CutiveMono.ttf",
|
"/system/fonts/CutiveMono.ttf",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[ cfg( windows ) ]
|
||||||
|
const MONOSPACE_CANDIDATES: &[&str] =
|
||||||
|
&[
|
||||||
|
"C:\\Windows\\Fonts\\consola.ttf",
|
||||||
|
"C:\\Windows\\Fonts\\cour.ttf",
|
||||||
|
];
|
||||||
|
|
||||||
/// A monospace face for text that lines up in columns, such as line numbers or a terminal; `None` when none is installed.
|
/// A monospace face for text that lines up in columns, such as line numbers or a terminal; `None` when none is installed.
|
||||||
pub fn monospace() -> Option<Arc<FontFace>>
|
pub fn monospace() -> Option<Arc<FontFace>>
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-6
@@ -26,13 +26,9 @@ pub fn search_paths() -> Vec<PathBuf>
|
|||||||
{
|
{
|
||||||
out.push( PathBuf::from( dev ) );
|
out.push( PathBuf::from( dev ) );
|
||||||
}
|
}
|
||||||
if let Some( home ) = std::env::var_os( "XDG_DATA_HOME" )
|
if let Some( data ) = crate::dirs::data_home()
|
||||||
{
|
{
|
||||||
out.push( PathBuf::from( home ).join( "ltk/themes" ) );
|
out.push( data.join( "ltk" ).join( "themes" ) );
|
||||||
}
|
|
||||||
else if let Some( home ) = std::env::var_os( "HOME" )
|
|
||||||
{
|
|
||||||
out.push( PathBuf::from( home ).join( ".local/share/ltk/themes" ) );
|
|
||||||
}
|
}
|
||||||
if let Some( bundle ) = crate::resources::resource_dir()
|
if let Some( bundle ) = crate::resources::resource_dir()
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
//! - [`Corners`] — per-corner radius for the
|
//! - [`Corners`] — per-corner radius for the
|
||||||
//! [`Container`](crate::container()) widget and any other rounded
|
//! [`Container`](crate::container()) widget and any other rounded
|
||||||
//! surface; coerces from `f32` for the uniform case.
|
//! surface; coerces from `f32` for the uniform case.
|
||||||
//! - [`WidgetId`] — a stable identifier, a `&'static str` name and a key,
|
//! - [`WidgetId`](struct@WidgetId) — a stable identifier, a `&'static str` name and a key,
|
||||||
//! for focus management, paired with [`crate::App::take_focus_request`].
|
//! for focus management, paired with [`crate::App::take_focus_request`].
|
||||||
//!
|
//!
|
||||||
//! Every type is `Copy` (or `Clone`) so passing them by value is the
|
//! Every type is `Copy` (or `Clone`) so passing them by value is the
|
||||||
|
|||||||
Reference in New Issue
Block a user