Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ecfd080416 Color Range: test the dialog in the full app without a GPU; show preview failures (#145)
The dialog opens from Select › Color Range… in the whole app on the CPU
canvas (no wgpu render state, as on a Linux adapter that can't run the
GPU canvas) at 1024×600, on screen, with both previews drawn, and OK
applies. v0.1.1 and earlier had no Color Range dialog (it shipped in
0.2.0 with #80): the menu item ran select.colorRange directly with the
foreground colour, which matches the report (a selection appears, no
window).

A preview that can't be computed used to stay silently blank; it now
logs the reason once and shows "Preview unavailable: <reason>" in the
preview (also in its accessibility label).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 06:08:15 -04:00
Brandon ThomasandClaude Opus 5.5 56080328ed Menus on small displays: scroll arrows, keyboard navigation, reachability tests (#138)
Builds on #160 (scrollable menu levels). Each menu level is now bounded
from the menu bar down (so a top-level menu no longer slides over the
bar), shows native-style ▲/▼ scroll arrows when it overflows (resting on
one scrolls; the wheel works over them too), and the keyboard drives an
open menu: ↑/↓ move a highlight that is kept in view, → opens a submenu
(highlighting its first row) or the next menu, ← closes it or opens the
previous menu, ↩/Space run the item, Esc closes. Keyboard shortcuts
wait while a menu is open; a resting pointer no longer undoes ←/→.

Tests: every enabled row of Filter, Layer, Image and Edit is reachable
and visible at 1280×720 and 1024×600 at 1× and 1.5×, by keyboard, wheel
and the scroll arrows; submenus open/close by keyboard; ↩ runs a command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 06:08:15 -04:00
Brandon ThomasandClaude Opus 5.5 10203e7a60 AGENTS.md: no Tauri, Electron or webview shells (#184)
The desktop app is native egui/eframe on wgpu and the web build is the same Rust compiled to
WebAssembly; state explicitly that Tauri (or any webview/JS UI framework) must never be added.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 05:41:36 -04:00
Brandon ThomasandClaude Opus 5.5 20783fdc70 Roadmap: honest parity assessment (2026-10-05) (#182)
parity.md's 625/625 measures menu wiring, not behaviour. Record a candid assessment after
0.2.0 and the first user feedback (feature surface ~55-70%, pro-switchable ~20-35%, area by area),
how to make parity measurable (workflow acceptance tests, real-file corpus, visual QA, user
reports), mark parity-estimate.md's ~82% as an upper bound, and update the current focus.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 05:20:57 -04:00
Brandon ThomasandClaude Opus 5.5 231bf5fe81 Flatpak single-file bundles as release artifacts (#163)
* Flatpak single-file bundle as a release artifact (#140)

packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml repackages the
release's Linux tarball (no Rust build, no network) on the freedesktop 26.08
runtime; packaging/linux/flatpak-bundle.sh builds it with flatpak-builder and
flatpak build-bundle and smoke-tests it. release.yml gains a Flatpak job per
arch that feeds the draft release and SHA256SUMS. The Flathub manifest moves to
26.08 with the same documented finish-args (portals + Pictures/Documents), and
packaging-lint checks the two manifests agree. Font scanning also looks in
/run/host/fonts and /run/host/user-fonts so host fonts show up in the sandbox.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Docs: installing the Flatpak bundle (#140)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Flatpak job: install the SVG pixbuf loader for appstream compose

Without librsvg2-common (absent on the x86_64 runner image) the host's
appstreamcli compose fails with file-read-error on the scalable icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* release.yml: the Flatpak job runs without the release environment

It only repackages the Linux job's tarball and signs nothing, so it doesn't need (and
shouldn't hold) the signing secrets while running third-party tooling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 04:15:21 -04:00
16 changed files with 968 additions and 49 deletions

No files matched your search

+10 -1
View File
@@ -50,4 +50,13 @@ jobs:
desktop-file-validate packaging/linux/ai.storyteller.photocraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.photocraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.photocraft.yml')); assert m['id'] == 'ai.storyteller.photocraft' and m['command'] == 'photocraft'"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.photocraft" and m["command"] == "photocraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
+39 -1
View File
@@ -201,6 +201,44 @@ jobs:
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/photocraft-$PHOTOCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
web:
name: Web (wasm)
needs: version
@@ -236,7 +274,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
needs: [version, macos, windows, linux, flatpak, web]
runs-on: ubuntu-latest
environment: release
permissions:
+1 -1
View File
@@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
## 1. Orientation (5 minutes)
+8 -1
View File
@@ -257,7 +257,14 @@ cargo test --workspace # the test suite
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
```sh
flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-aarch64
flatpak run ai.storyteller.photocraft
```
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
+22
View File
@@ -340,6 +340,10 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
} else {
v.push(PathBuf::from("/usr/share/fonts"));
v.push(PathBuf::from("/usr/local/share/fonts"));
// Inside a Flatpak sandbox the host's system and per-user fonts are mounted here
// (`/usr/share/fonts` is the runtime's own small set). Missing dirs are skipped.
v.push(PathBuf::from("/run/host/fonts"));
v.push(PathBuf::from("/run/host/user-fonts"));
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
v.push(PathBuf::from(d).join("fonts"));
}
@@ -350,3 +354,21 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
}
v
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
#[test]
#[cfg(target_os = "linux")]
fn scans_flatpak_host_fonts() {
let dirs = super::system_font_dirs();
assert!(dirs.iter().any(|d| d.ends_with("run/host/fonts")));
assert!(dirs.iter().any(|d| d.ends_with("run/host/user-fonts")));
}
#[test]
fn missing_font_dirs_are_skipped() {
let mut files = Vec::new();
super::collect_font_files(std::path::Path::new("/nonexistent/photocraft/fonts"), 0, &mut files);
assert!(files.is_empty());
}
}
+94 -9
View File
@@ -162,6 +162,8 @@ pub struct Preview {
/// Hash of the params the mask texture shows.
mask_key: u64,
mask: Option<egui::TextureHandle>,
/// Why the preview couldn't be drawn (shown in the dialog and logged, never silent: #145).
error: Option<String>,
}
fn hash(text: &str) -> u64 {
@@ -170,13 +172,13 @@ fn hash(text: &str) -> u64 {
/// The selection mask `params` would make, on the proxy: the engine command run on a scratch
/// session (Out Of Gamut uses the document's own proof setup).
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Option<Vec<f32>> {
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Result<Vec<f32>, String> {
let area = proxy.bounds();
if params.get("select").and_then(Value::as_str) == Some("outOfGamut") {
let pv = app.session.color.proof(proxy.id);
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).ok()?;
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).map_err(|e| e.to_string())?;
let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false);
return Some(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
return Ok(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
}
let mut p = params.clone();
// Eyedropper points are in document pixels; the proxy is 1/k the size.
@@ -203,9 +205,20 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) ->
if let Some(id) = app.session.active().and_then(|d| d.active_layer) {
let _ = s.select_layer(id);
}
s.execute(COMMAND, p).ok()?;
let d = s.active()?;
Some(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
s.execute(COMMAND, p).map_err(|e| e.to_string())?;
let d = s.active().ok_or("no preview document")?;
Ok(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
}
/// Record why the preview failed (logged once per new reason), or clear it.
fn report(app: &mut PhotocraftApp, error: Option<String>) {
let Some(p) = app.color_range.as_mut() else { return };
if p.error != error {
if let Some(e) = &error {
log::warn!("Color Range preview: {e}");
}
p.error = error;
}
}
/// Refresh the cached proxy / textures for the active document and the dialog's params. Returns
@@ -219,7 +232,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
let side = doc.size.width.max(doc.size.height).max(1);
let k = side.div_ceil(PREVIEW).max(1);
let proxy = Arc::new(crate::proxy::proxy_document(&doc, k));
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None });
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None, error: None });
}
let image_view = s(f, "__view", "selection") == "image";
let params = params(f);
@@ -234,6 +247,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
let thumb = photocraft_compose::thumbnail(&proxy, proxy.size.width.max(proxy.size.height));
let size = [thumb.width as usize, thumb.height as usize];
if size != [w, h] || thumb.pixels.len() != w * h * 4 {
report(app, Some(format!("the image thumbnail is {}×{}, expected {w}×{h}", thumb.width, thumb.height)));
return None;
}
let img = egui::ColorImage::from_rgba_unmultiplied(size, &thumb.pixels);
@@ -245,8 +259,18 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
return app.color_range.as_ref()?.image.as_ref().map(|t| (t.id(), [w, h], k));
}
if app.color_range.as_ref().is_some_and(|p| p.mask_key != key || p.mask.is_none()) {
let mask = proxy_mask(app, &proxy, k, &params).unwrap_or_else(|| vec![0.0; w * h]);
let mask = match proxy_mask(app, &proxy, k, &params) {
Ok(m) => {
report(app, None);
m
}
Err(e) => {
report(app, Some(e));
vec![0.0; w * h]
}
};
if mask.len() != w * h {
report(app, Some(format!("the selection preview has {} pixels, expected {}", mask.len(), w * h)));
return None;
}
let px: Vec<Color32> = mask.iter().map(|v| Color32::from_gray((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)).collect();
@@ -368,8 +392,22 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
pick(app, f, at, ui.input(|i| i.modifiers));
}
}
// A preview that can't be drawn says why (#145) instead of staying blank.
if let Some(e) = app.color_range.as_ref().and_then(|p| p.error.clone()) {
ui.painter().rect_filled(frame, 0.0, t.canvas);
let msg = format!(
"Preview unavailable:
{e}"
);
let g = ui.painter().layout(msg, egui::FontId::proportional(11.0), t.text_faint, frame.width() - 16.0);
ui.painter().galley(frame.center() - g.size() / 2.0, g, t.text_faint);
}
ui.painter().rect_stroke(frame, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), "Color Range preview"));
let label = match app.color_range.as_ref().and_then(|p| p.error.as_deref()) {
Some(e) => format!("Color Range preview unavailable: {e}"),
None => "Color Range preview".to_string(),
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), &label));
ui.add_space(10.0);
ui.vertical(|ui| {
ui.add_enabled_ui(c.sampling, |ui| {
@@ -704,4 +742,51 @@ mod tests {
app.run(COMMAND, params(&f)).unwrap();
}
}
/// #145: the whole app without a GPU (no wgpu render state: the CPU canvas, as on a Linux
/// machine whose adapter can't run the GPU canvas), on a small window. Select › Color Range…
/// from the menu opens the dialog on screen and draws both previews.
#[test]
fn opens_and_previews_in_the_full_app_without_a_gpu() {
let mut h = Harness::builder().with_size(egui::vec2(1024.0, 600.0)).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
assert!(cc.wgpu_render_state.is_none(), "this test covers the no-GPU path");
app_with_doc()
});
h.run_steps(6);
assert!(h.state().gpu.is_none(), "the CPU canvas");
h.get_all_by_label("Select").next().expect("the Select menu").click();
h.run_steps(3);
h.get_by_label("Color Range…").click();
h.run_steps(6);
let id = dialog_id(h.state());
let area = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).expect("the dialog is drawn");
assert!(h.ctx.content_rect().contains_rect(area), "the dialog is on screen: {area:?}");
assert!(h.query_by_label("Color Range preview").is_some());
let p = h.state().color_range.as_ref().expect("the preview is cached");
assert!(p.mask.is_some() && p.error.is_none(), "the selection preview is drawn: {:?}", p.error);
set(&mut h, "__view", json!("image"));
let p = h.state().color_range.as_ref().unwrap();
assert!(p.image.is_some() && p.error.is_none(), "the image preview is drawn: {:?}", p.error);
set(&mut h, "select", json!("reds"));
ok(&mut h);
h.run_steps(3);
assert_eq!(coverage(h.state(), 5, 5), 1.0);
}
#[test]
fn a_preview_failure_is_shown_not_silent() {
let mut h = harness(app_with_doc());
open(h.state_mut());
h.run_steps(3);
// The engine refuses these params: the preview says so instead of staying blank.
let id = dialog_id(h.state());
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("select".into(), json!("noSuchMode"));
h.run_steps(3);
let err = h.state().color_range.as_ref().and_then(|p| p.error.clone());
assert!(err.is_some(), "the failure is recorded");
assert!(h.query_by_label_contains("Color Range preview unavailable").is_some(), "and shown");
set(&mut h, "select", json!("reds"));
assert!(h.state().color_range.as_ref().unwrap().error.is_none(), "and cleared once the preview works again");
}
}
+1
View File
@@ -50,6 +50,7 @@ pub mod layer_tree_ui;
pub mod links;
pub mod liquify_ui;
pub mod menu_catalog;
pub mod menu_nav;
pub mod menus;
pub mod new_doc_ui;
pub mod notices;
+327
View File
@@ -0,0 +1,327 @@
//! Native-menu behaviour for the menu bar's menus (#138): a menu or submenu taller than the
//! window scrolls (mouse wheel / trackpad, and the ▲ / ▼ arrows at its top and bottom that scroll
//! while the pointer rests on them, like macOS and Windows menus on a small display), and the
//! keyboard drives an open menu: ↑ / ↓ move the highlight (and keep it in view), → opens a
//! submenu or the next menu, ← closes the submenu or opens the previous menu, ↩ / Space run the
//! highlighted item, Esc closes.
//!
//! The highlight is view state only (egui memory), per top-level menu; it follows the pointer too,
//! so the mouse and the keyboard share one highlighted row like a native menu.
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
use egui::{Id, Key, Modifiers, Rect, Sense, Ui, pos2, vec2};
/// Height of a scroll-arrow strip.
pub const ARROW: f32 = 16.0;
/// Gap kept between a menu and the window edge.
const EDGE: f32 = 6.0;
/// How fast resting on a scroll arrow scrolls, in points per second.
const SPEED: f32 = 600.0;
/// One actionable row of a menu level: a command or a submenu.
#[derive(Clone, Debug)]
pub struct Row {
/// The row's widget id (a submenu's popup id derives from it).
pub widget: Id,
pub enabled: bool,
/// The command it runs; `None` for a submenu.
pub command: Option<String>,
/// Where it was drawn (scrolled out of view when outside its level's `views` rect).
pub rect: Rect,
}
/// Keyboard / highlight state of the open menu.
#[derive(Clone, Debug, Default)]
pub struct Nav {
/// The open top-level menu (index into the menu bar).
top: Option<usize>,
/// Popup ids of the top-level menus, from the last frame.
tops: Vec<Id>,
/// The level the arrow keys move in (0 = the top-level menu).
pub(crate) level: usize,
/// Highlighted row per level.
hi: Vec<Option<usize>>,
/// This frame's rows per level (deeper levels are dropped when their parent redraws).
pub(crate) rows: Vec<Vec<Row>>,
/// Menu-root id per level (the owner of egui's `MenuState` for that level).
roots: Vec<Id>,
/// Bottom of the menu bar: where top-level menus hang from.
pub bar_bottom: Option<f32>,
/// The visible (scrolled) part of each level.
pub(crate) views: Vec<Rect>,
/// Scroll the highlighted row of `level` into view when it is next drawn.
reveal: bool,
/// Highlight the first enabled row of this level once it has been drawn (a keyboard-opened
/// submenu).
first_at: Option<usize>,
}
fn nav_id() -> Id {
Id::new("pc-menu-nav")
}
fn open_id() -> Id {
Id::new("pc-menu-open")
}
/// Is one of the menu bar's menus open? (Shortcuts leave the keys to it meanwhile.)
pub fn is_open(ctx: &egui::Context) -> bool {
// Only while the menu bar is still drawn (Full Screen Mode hides it with a menu open).
let pass = ctx.cumulative_pass_nr();
ctx.data(|d| d.get_temp::<u64>(open_id())).is_some_and(|seen| seen + 2 >= pass)
}
impl Nav {
/// Last frame's state, reset when another top-level menu (or none) is open now.
pub fn load(ctx: &egui::Context) -> Nav {
let mut nav: Nav = ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default();
let top = nav.tops.iter().position(|id| egui::Popup::is_id_open(ctx, *id));
if top != nav.top {
nav = Nav { top, tops: std::mem::take(&mut nav.tops), ..Default::default() };
}
nav
}
/// The state as the last frame left it (tests, inspection).
pub fn current(ctx: &egui::Context) -> Nav {
ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default()
}
/// The highlighted row of `level`, if any.
pub fn highlighted(&self, level: usize) -> Option<usize> {
self.hi.get(level).copied().flatten()
}
fn set_hi(&mut self, level: usize, row: Option<usize>) {
if self.hi.len() <= level {
self.hi.resize(level + 1, None);
}
self.hi[level] = row;
}
/// Start drawing `level`: forget its rows from before, and every deeper level's.
fn begin_level(&mut self, level: usize, root: Id) {
self.rows.truncate(level);
self.rows.resize(level + 1, Vec::new());
self.roots.truncate(level);
self.roots.resize(level + 1, root);
self.views.truncate(level);
self.views.resize(level + 1, Rect::NOTHING);
}
/// Draw a row of `level` with `add`, highlighted like a hovered row when the keyboard (or the
/// pointer) is on it, and register it for the keys. `add` returns the row's response.
pub fn row<R>(&mut self, ui: &mut Ui, level: usize, enabled: bool, command: Option<&str>, add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R)) -> R {
let i = self.rows.get(level).map_or(usize::MAX, Vec::len);
let lit = enabled && self.highlighted(level) == Some(i);
let saved = ui.visuals().widgets.inactive;
if lit {
ui.visuals_mut().widgets.inactive = ui.visuals().widgets.hovered;
}
let (r, out) = add(ui, self);
ui.visuals_mut().widgets.inactive = saved;
if let Some(rows) = self.rows.get_mut(level) {
rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect });
self.after_row(level, i, lit, &r);
}
out
}
/// After a row is drawn: the pointer moving onto it highlights it; a keyboard move scrolls
/// the highlighted row into view.
fn after_row(&mut self, level: usize, i: usize, lit: bool, r: &egui::Response) {
let moved = r.ctx.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO);
if moved && r.hovered() && !lit {
self.set_hi(level, Some(i));
self.level = level;
r.ctx.request_repaint();
}
if lit && self.reveal && level == self.level {
r.scroll_to_me(None);
self.reveal = false;
}
}
fn enabled(&self, level: usize) -> Vec<usize> {
self.rows.get(level).map(|rows| rows.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect()).unwrap_or_default()
}
/// ↑ / ↓: the next enabled row (wrapping), or the first / last one.
fn step(&mut self, level: usize, down: bool) {
let on = self.enabled(level);
let (Some(&first), Some(&last)) = (on.first(), on.last()) else { return };
let next = match self.highlighted(level) {
None => {
if down {
first
} else {
last
}
}
Some(cur) if down => on.iter().copied().find(|&i| i > cur).unwrap_or(first),
Some(cur) => on.iter().rev().copied().find(|&i| i < cur).unwrap_or(last),
};
self.set_hi(level, Some(next));
self.hi.truncate(level + 1);
self.level = level;
self.reveal = true;
}
/// Open the highlighted submenu of `level` and move the keyboard into it.
fn open_sub(&mut self, ctx: &egui::Context, level: usize, widget: Id) {
let Some(&root) = self.roots.get(level) else { return };
let sub = SubMenu::id_from_widget_id(widget);
// egui drops an open item it has never drawn: mark it shown so it survives to the next frame.
MenuState::mark_shown(ctx, sub);
MenuState::from_id(ctx, root, |s| s.open_item = Some(sub));
self.level = level + 1;
self.hi.truncate(level + 1);
self.first_at = Some(level + 1);
}
/// Handle this frame's navigation keys (after the menus were drawn). `tops` are the menu
/// bar's popup ids; an activated command lands in `clicked`.
pub fn keys(&mut self, ctx: &egui::Context, tops: &[Id], clicked: &mut Option<String>) {
self.tops = tops.to_vec();
let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return };
let level = self.level.min(self.rows.len().saturating_sub(1));
self.level = level;
let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned();
let switch = |by: usize| {
let n = tops.len();
if let Some(&id) = tops.get((top + by) % n) {
egui::Popup::open_id(ctx, id);
}
};
if press(Key::Escape) {
egui::Popup::close_all(ctx);
} else if press(Key::ArrowDown) {
self.step(level, true);
} else if press(Key::ArrowUp) {
self.step(level, false);
} else if press(Key::ArrowRight) {
match current {
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
_ => switch(1),
}
} else if press(Key::ArrowLeft) {
if level > 0 {
if let Some(&root) = self.roots.get(level - 1) {
MenuState::from_id(ctx, root, |s| s.open_item = None);
}
self.hi.truncate(level);
self.level = level - 1;
} else {
switch(tops.len() - 1);
}
} else if press(Key::Enter) || press(Key::Space) {
match current {
Some(Row { enabled: true, command: Some(c), .. }) => {
*clicked = Some(c);
egui::Popup::close_all(ctx);
}
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
_ => {}
}
} else {
return;
}
ctx.request_repaint();
}
pub fn store(self, ctx: &egui::Context) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| {
if self.top.is_some() {
d.insert_temp(open_id(), pass);
} else {
d.remove::<u64>(open_id());
}
d.insert_temp(nav_id(), self);
});
}
}
/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)?
fn pointer_on(ui: &Ui, rect: Rect) -> bool {
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()))
}
/// Draw one level of a menu, bounded by the window: when its rows don't fit they scroll, with
/// scroll arrows at the top and bottom. `depth` is 1 for a top-level menu.
pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui, &mut Nav)) {
let ctx = ui.ctx().clone();
let level = depth.saturating_sub(1);
nav.begin_level(level, find_menu_root(ui).id);
let screen = ctx.content_rect();
// The menu frame's margin and stroke around the rows.
let frame = ui.spacing().menu_margin.sum().y + 2.0;
// A top-level menu hangs below the menu bar; a submenu may slide up to fit the window.
// (From the menu bar, not from where the popup is now: egui slides a popup that doesn't fit.)
let top = nav.bar_bottom.unwrap_or(screen.top()) + EDGE;
let room = if depth <= 1 { screen.bottom() - top - EDGE } else { screen.height() - 2.0 * EDGE } - frame;
let room = room.max(4.0 * ARROW);
let key = ui.id().with(("pc-menu-level", depth));
// Rows' height from the last frame: does this level overflow?
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
let over = content.is_some_and(|h| h > room + 0.5);
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
// Wheel, scroll bar and dragging the rows all scroll (#160).
let out = egui::ScrollArea::vertical()
.id_salt(("menu-level", depth))
.max_height(if over { room - 2.0 * ARROW } else { room })
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
.show(ui, |ui| rows(ui, nav));
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
if let Some(v) = nav.views.get_mut(level) {
*v = out.inner_rect;
}
if nav.first_at == Some(level) {
nav.first_at = None;
nav.step(level, true);
ctx.request_repaint();
}
let (Some(up), Some(down)) = (up, down) else { return };
let t = crate::theme::Tokens::get(&ctx);
let x = ui.min_rect().x_range();
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
let offset = out.state.offset.y;
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
let mut target = offset;
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
let strip = Rect::from_x_y_ranges(x, strip.y_range());
let c = strip.center();
let (h, w) = (4.5, 6.0);
let tri = if dir < 0.0 {
vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)]
} else {
vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)]
};
ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_faint.gamma_multiply(0.5) }, egui::Stroke::NONE));
let id = key.with(dir < 0.0);
let r = ui.interact(strip, id, Sense::hover());
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, live, if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }));
if live && pointer_on(ui, strip) {
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
ctx.request_repaint();
}
// The wheel scrolls over the arrows too.
if pointer_on(ui, strip) {
let wheel = ctx.input(|i| i.smooth_scroll_delta.y);
if wheel != 0.0 {
target = (target - wheel).clamp(0.0, max);
}
}
}
if target != offset {
let mut st = out.state;
st.offset.y = target;
st.store(&ctx, out.id);
}
}
#[cfg(test)]
#[path = "menu_nav_tests.rs"]
mod tests;
+218
View File
@@ -0,0 +1,218 @@
//! Menus on small displays (#138): every row of the longest menus is reachable by keyboard, wheel
//! and the scroll arrows, and the keyboard drives submenus and runs commands.
use super::*;
use crate::PhotocraftApp;
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
/// Window size in pixels and UI scale: the small displays of #138.
const DISPLAYS: [(f32, f32, f32); 4] = [(1280.0, 720.0, 1.0), (1024.0, 600.0, 1.0), (1280.0, 720.0, 1.5), (1024.0, 600.0, 1.5)];
/// The tallest menus.
const LONGEST: [&str; 4] = ["Filter", "Layer", "Image", "Edit"];
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
app.sync_views();
app
}
fn harness((w, h, scale): (f32, f32, f32)) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder()
.with_size(egui::vec2(w / scale, h / scale))
.with_pixels_per_point(scale)
.build_ui_state(|ui, app| crate::menus::menu_bar(app, ui), app());
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.ctx.all_styles_mut(|s| s.scroll_animation = egui::style::ScrollAnimation::none());
h.run_steps(3);
h
}
fn open(h: &mut Harness<'static, PhotocraftApp>, top: &str) {
h.get_by_label(top).click();
h.run_steps(4);
assert!(Nav::current(&h.ctx).rows.first().is_some_and(|r| !r.is_empty()), "{top} did not open");
}
/// Is row `i` of `level` entirely inside its level's visible (scrolled) area, on screen?
fn visible(h: &Harness<'static, PhotocraftApp>, level: usize, i: usize) -> bool {
let nav = Nav::current(&h.ctx);
let (Some(row), Some(view)) = (nav.rows.get(level).and_then(|r| r.get(i)), nav.views.get(level)) else { return false };
view.expand(0.5).contains_rect(row.rect) && h.ctx.content_rect().expand(0.5).contains_rect(*view)
}
fn rows(h: &Harness<'static, PhotocraftApp>, level: usize) -> Vec<Row> {
Nav::current(&h.ctx).rows.get(level).cloned().unwrap_or_default()
}
fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) {
h.key_press(k);
h.run_steps(3);
}
#[test]
fn every_row_of_the_longest_menus_is_reachable_with_the_arrow_keys() {
for display in DISPLAYS {
for top in LONGEST {
let mut h = harness(display);
open(&mut h, top);
let all = rows(&h, 0);
let enabled: Vec<usize> = all.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect();
assert!(enabled.len() > 5, "{top}: {} enabled rows", enabled.len());
let mut seen = Vec::new();
for _ in 0..enabled.len() {
key(&mut h, egui::Key::ArrowDown);
let i = Nav::current(&h.ctx).highlighted(0).expect("a highlighted row");
assert!(
visible(&h, 0, i),
"{display:?} {top}: highlighted row {i} ({:?}) is scrolled out of view",
rows(&h, 0).get(i).map(|r| r.command.clone())
);
seen.push(i);
}
seen.sort_unstable();
assert_eq!(seen, enabled, "{display:?} {top}: ↓ visits every enabled row once");
// ↓ wraps to the first row (scrolling back up); ↑ wraps to the last.
key(&mut h, egui::Key::ArrowDown);
assert_eq!(Nav::current(&h.ctx).highlighted(0), enabled.first().copied());
assert!(visible(&h, 0, enabled[0]));
key(&mut h, egui::Key::ArrowUp);
let last = *enabled.last().unwrap();
assert_eq!(Nav::current(&h.ctx).highlighted(0), Some(last));
assert!(visible(&h, 0, last), "{display:?} {top}: the last row is reachable");
}
}
}
#[test]
fn small_displays_overflow_and_the_menu_stays_in_the_window() {
// 1024 × 600 at 150 %: 683 × 400 points, far shorter than the Filter menu.
let mut h = harness((1024.0, 600.0, 1.5));
open(&mut h, "Filter");
let all = rows(&h, 0);
let last = all.len() - 1;
assert!(!visible(&h, 0, last), "the Filter menu must overflow at 683 × 400 pt for this test to mean anything");
let nav = Nav::current(&h.ctx);
let screen = h.ctx.content_rect();
assert!(nav.views[0].bottom() <= screen.bottom() && nav.views[0].top() >= screen.top());
let bar = h.get_by_label("Filter").rect();
assert!(nav.views[0].top() >= bar.bottom(), "the menu hangs below the menu bar instead of sliding over it: {:?} vs {bar:?}", nav.views[0]);
// Scroll arrows show when the rows overflow.
assert!(h.query_by_label("Scroll menu down").is_some() && h.query_by_label("Scroll menu up").is_some());
}
#[test]
fn the_mouse_wheel_scrolls_a_long_menu() {
for display in DISPLAYS {
let mut h = harness(display);
open(&mut h, "Filter");
let last = rows(&h, 0).len() - 1;
let view = Nav::current(&h.ctx).views[0];
h.hover_at(view.center());
h.run_steps(2);
for _ in 0..60 {
if visible(&h, 0, last) {
break;
}
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.run_steps(2);
}
assert!(visible(&h, 0, last), "{display:?}: the wheel reaches the last Filter row");
assert!(rows(&h, 0).len() == last + 1, "the menu stays open while scrolling");
}
}
#[test]
fn resting_on_the_scroll_arrows_scrolls() {
let mut h = harness((1024.0, 600.0, 1.5));
open(&mut h, "Filter");
let last = rows(&h, 0).len() - 1;
assert!(visible(&h, 0, 0) && !visible(&h, 0, last));
let view = Nav::current(&h.ctx).views[0];
h.hover_at(egui::pos2(view.center().x, view.bottom() + ARROW / 2.0));
for _ in 0..240 {
h.run_steps(1);
if visible(&h, 0, last) {
break;
}
}
assert!(visible(&h, 0, last), "resting on ▼ scrolls to the end");
assert!(!visible(&h, 0, 0));
let view = Nav::current(&h.ctx).views[0];
h.hover_at(egui::pos2(view.center().x, view.top() - ARROW / 2.0));
for _ in 0..240 {
h.run_steps(1);
if visible(&h, 0, 0) {
break;
}
}
assert!(visible(&h, 0, 0), "resting on ▲ scrolls back to the top");
}
#[test]
fn arrows_open_and_close_submenus_and_enter_runs_a_command() {
let mut h = harness((1024.0, 600.0, 1.5));
open(&mut h, "Filter");
// Walk down to the first submenu (Blur, Distort, …) and open it with →.
for _ in 0..rows(&h, 0).len() {
key(&mut h, egui::Key::ArrowDown);
let nav = Nav::current(&h.ctx);
if nav.highlighted(0).and_then(|i| nav.rows[0].get(i)).is_some_and(|r| r.command.is_none()) {
break;
}
}
key(&mut h, egui::Key::ArrowRight);
h.run_steps(2);
let nav = Nav::current(&h.ctx);
assert_eq!(nav.level, 1, "→ moves into the submenu");
assert!(nav.rows.get(1).is_some_and(|r| !r.is_empty()), "the submenu is open");
let first = nav.rows[1].iter().position(|r| r.enabled);
assert_eq!(nav.highlighted(1), first, "its first enabled row is highlighted");
assert!(visible(&h, 1, first.unwrap()));
key(&mut h, egui::Key::ArrowDown);
assert_eq!(Nav::current(&h.ctx).level, 1);
// ← closes it again.
key(&mut h, egui::Key::ArrowLeft);
h.run_steps(2);
let nav = Nav::current(&h.ctx);
assert_eq!((nav.level, nav.rows.len()), (0, 1), "← closes the submenu");
// ↩ runs the highlighted command and closes the menu: Select › All.
key(&mut h, egui::Key::Escape);
open(&mut h, "Select");
for _ in 0..rows(&h, 0).len() {
let nav = Nav::current(&h.ctx);
if nav.highlighted(0).and_then(|i| nav.rows[0].get(i)).is_some_and(|r| r.command.as_deref() == Some("select.all")) {
break;
}
key(&mut h, egui::Key::ArrowDown);
}
assert!(h.state().session.active().unwrap().doc.selection.is_none());
key(&mut h, egui::Key::Enter);
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "↩ ran Select › All");
assert!(!is_open(&h.ctx), "and closed the menu");
}
#[test]
fn left_and_right_switch_menus_and_shortcuts_wait_while_one_is_open() {
let mut h = harness((1280.0, 720.0, 1.0));
assert!(!is_open(&h.ctx));
open(&mut h, "File");
assert!(is_open(&h.ctx), "shortcuts leave the keys to an open menu");
key(&mut h, egui::Key::ArrowRight);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_none(), "→ leaves File");
assert!(h.query_by_label_contains("Undo").is_some(), "→ opens Edit");
key(&mut h, egui::Key::ArrowLeft);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_some(), "← back to File");
key(&mut h, egui::Key::Escape);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_none(), "Esc closes the menu");
assert!(!is_open(&h.ctx), "shortcuts work again");
}
+24 -15
View File
@@ -698,6 +698,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let items = menu_items(app);
let mut clicked: Option<String> = None;
let t = crate::theme::Tokens::get(ui.ctx());
let mut nav = crate::menu_nav::Nav::load(ui.ctx());
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let v = &mut ui.style_mut().visuals;
@@ -707,6 +708,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
v.widgets.hovered.bg_stroke = egui::Stroke::NONE;
egui::MenuBar::new().ui(ui, |ui| {
ui.spacing_mut().button_padding = egui::vec2(6.0, 3.0);
nav.bar_bottom = Some(ui.max_rect().bottom());
let mut buttons = Vec::with_capacity(TOP_MENUS.len());
for top in TOP_MENUS {
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
@@ -715,13 +717,16 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if mine.is_empty() {
ui.weak("(coming soon)");
}
render_level(ui, &mine, 1, &mut clicked);
render_level(ui, &mine, 1, &mut clicked, &mut nav);
});
buttons.push(r.response);
}
switch_on_hover(ui.ctx(), &buttons);
let tops: Vec<egui::Id> = buttons.iter().map(egui::Popup::default_response_id).collect();
nav.keys(ui.ctx(), &tops, &mut clicked);
});
});
nav.store(ui.ctx());
if let Some(id) = clicked {
let ctx = ui.ctx().clone();
let _ = invoke(app, &ctx, &id, json!({}));
@@ -743,6 +748,10 @@ fn switch_on_hover(ctx: &egui::Context, buttons: &[egui::Response]) {
// `Response::hovered` is false while the menu's popup layer is open. Hit-test the title
// rect manually, but only when its own layer is top-most at the pointer.
let Some(p) = ctx.pointer_hover_pos() else { return };
// Only a moving pointer switches: a pointer resting on a title must not undo ← / → .
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
return;
}
if let Some(i) = buttons.iter().position(|b| pointer_reaches_title(ctx, b, p))
&& i != open
{
@@ -751,19 +760,13 @@ fn switch_on_hover(ctx: &egui::Context, buttons: &[egui::Response]) {
}
}
fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
// On short displays leave room for the menu bar and popup frame so the ScrollArea becomes
// active before the popup reaches the bottom edge. Keep wheel/scrollbar input and also allow
// direct dragging of the menu contents.
let max_height = (ui.ctx().content_rect().height() - 56.0).max(96.0);
egui::ScrollArea::vertical()
.id_salt(("menu-level", depth))
.max_height(max_height)
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
.show(ui, |ui| render_level_rows(ui, items, depth, clicked));
fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, nav: &mut crate::menu_nav::Nav) {
// Menu popups can be taller than the window: each level stays on screen and scrolls (wheel,
// scroll arrows, keyboard), like a native menu on a small display.
crate::menu_nav::level(ui, depth, nav, |ui, nav| render_level_rows(ui, items, depth, clicked, nav));
}
fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, nav: &mut crate::menu_nav::Nav) {
let t = crate::theme::Tokens::get(ui.ctx());
if t.pro {
// Spectrum/macOS menus: blue highlight row with white text.
@@ -798,7 +801,12 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
if let Some(sc) = &it.shortcut {
b = b.shortcut_text(crate::shortcuts::pretty(sc));
}
if ui.add_enabled(it.enabled, b).clicked() {
let hit = nav.row(ui, depth - 1, it.enabled, Some(&it.id), |ui, _| {
let r = ui.add_enabled(it.enabled, b);
let hit = r.clicked();
(r, hit)
});
if hit {
*clicked = Some(it.id.clone());
ui.close();
}
@@ -811,8 +819,9 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
shown_subs.push(name);
let child: Vec<&MenuItem> = items.iter().copied().filter(|c| c.path.len() > depth && c.path[depth] == name).collect();
let any_enabled = child.iter().any(|c| c.enabled && c.label != "---");
ui.add_enabled_ui(any_enabled || !child.is_empty(), |ui| {
ui.menu_button(name, |ui| render_level(ui, &child, depth + 1, clicked));
let enabled = any_enabled || !child.is_empty();
ui.add_enabled_ui(enabled, |ui| {
nav.row(ui, depth - 1, enabled, None, |ui, nav| (ui.menu_button(name, |ui| render_level(ui, &child, depth + 1, clicked, nav)).response, ()));
});
last_was_sep = false;
}
+4
View File
@@ -156,6 +156,10 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu
}
pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
// An open menu owns the keyboard (arrows, ↩, Esc), like a native menu.
if crate::menu_nav::is_open(ctx) {
return;
}
// Liquify is a full-window custom dialog with focusable sliders. egui can therefore claim
// keyboard input before the distortion-mode handler below runs. Give Liquify's local
// shortcuts first refusal (Undo, brush size, tool keys), but never steal keys from text edits.
+32 -8
View File
@@ -44,8 +44,8 @@ in the dialog.
| macOS 11+ (universal: Apple silicon + Intel) | `photocraft-<v>-macos-universal.dmg`, `photocraft-cli-<v>-macos-universal.zip` | `macos-15` |
| Windows 10+ x64 | `photocraft-<v>-windows-x64.msi`, `photocraft-<v>-windows-x64-portable.zip` | `windows-latest` |
| Windows 10+ x86 (32-bit) | `photocraft-<v>-windows-x86.msi`, `photocraft-<v>-windows-x86-portable.zip` | `windows-latest` |
| Linux x86_64 | `photocraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `photocraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
| Linux x86_64 | `photocraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz,flatpak}` | `ubuntu-22.04` (Flatpak: `ubuntu-24.04`) |
| Linux aarch64 | `photocraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz,flatpak}` | `ubuntu-22.04-arm` (Flatpak: `ubuntu-24.04-arm`) |
| Web | `photocraft-web-<v>.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` |
Every binary reports its version, the commit and the build date: `photocraft --version`,
@@ -128,11 +128,34 @@ Why these formats:
(`nfpm.yaml`). It doesn't need cargo-deb and cargo-generate-rpm metadata in our crates, and
it can build both formats on Ubuntu.
- **.tar.gz** is for people who manage their own `/opt` or `~/.local`.
- **Flatpak** is the route into Flathub, GNOME Software and KDE Discover.
`packaging/linux/flatpak/ai.storyteller.photocraft.yml` is ready for a Flathub submission.
CI only validates it. A real build needs vendored crate sources (`cargo-sources.json` from
`flatpak-cargo-generator.py`) and adds 20+ minutes per architecture. Build it by hand with
the commands in the manifest's header.
- **Flatpak bundle** (`.flatpak`) is a single file that installs into Flatpak, sandboxed and
updatable by installing a newer bundle, for people who prefer Flatpak to AppImage.
`packaging/linux/flatpak-bundle.sh` repackages the job's `.tar.gz` with
`packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml` on the freedesktop 26.08
runtime. It does no Rust build and needs no network inside `flatpak-builder`, so the bundle
holds the same binaries as the other formats. A separate `flatpak` job per architecture
(`ubuntu-24.04` and `ubuntu-24.04-arm`, for flatpak-builder 1.4) downloads the Linux job's
artifact, runs the script, installs the bundle and runs `photocraft-cli --version` inside the
sandbox as a smoke test. The bundle names Flathub as its runtime repo, so users install
it with:
```sh
flatpak install --user photocraft-<v>-linux-x86_64.flatpak # pulls org.freedesktop.Platform//26.08 from Flathub if missing
flatpak run ai.storyteller.photocraft
```
Sandbox permissions (justified in the manifest): Wayland with X11 fallback, IPC (X11
shared memory), `dri` for the GPU, and read/write access to Pictures and Documents. Every
other file goes through the file-chooser and document portals. There's no network, so the
`--control` server is only reachable from inside the sandbox until the user runs
`flatpak override --user --share=network ai.storyteller.photocraft`. Host fonts are read from
`/run/host/fonts` and `/run/host/user-fonts`. Locally (on Linux, with `flatpak` and
`flatpak-builder`): `packaging/linux/package.sh --formats tar && packaging/linux/flatpak-bundle.sh`.
- **Flathub**: `packaging/linux/flatpak/ai.storyteller.photocraft.yml` builds from source and
is ready for a Flathub submission (it keeps the same runtime and `finish-args` as the bundle
manifest, which packaging-lint checks). CI doesn't build it. A real build needs vendored crate
sources (`cargo-sources.json` from `flatpak-cargo-generator.py`) and adds 20+ minutes per
architecture. Build it by hand with the commands in the manifest's header.
All Linux binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**: Ubuntu 22.04+,
Debian 12+, Fedora 36+, RHEL 10, openSUSE Tumbleweed. They link only glibc and libgcc_s. X11,
@@ -192,4 +215,5 @@ The outputs are committed, so packaging never needs those tools.
`.github/workflows/packaging-lint.yml` runs in seconds on any change to `packaging/`, the
workflows or the icons. It runs actionlint, shellcheck, a PowerShell parse, xmllint,
`desktop-file-validate`, `appstreamcli validate`, and a YAML check of the Flatpak manifest.
`desktop-file-validate`, `appstreamcli validate`, and a YAML check that the two Flatpak
manifests agree on the runtime and permissions.
+51 -8
View File
@@ -1,12 +1,14 @@
# Roadmap
Status legend: ✅ done · 🟡 in progress · ⬜ not started. Updated 2026-10-01.
Status legend: ✅ done · 🟡 in progress · ⬜ not started. Updated 2026-10-05.
**Parity metric:** `cargo xtask parity` measures how much of Photoshop's menu tree is live and
writes [`parity.md`](parity.md). It is the headline number for "how close are we", next to the PSD
composite oracle and the test count. For the weighted feature-parity estimate (~82% overall) and the
remaining Opus 5.5 effort (~25–40 wall-clock hours to a 95%, ships-for-most state), see
[`parity-estimate.md`](parity-estimate.md).
**Parity metrics.** `cargo xtask parity` measures how much of Photoshop's menu tree is *wired to a
command* and writes [`parity.md`](parity.md). It does **not** measure whether those commands behave
like Photoshop, feel right, or survive real files. Read it next to the PSD composite oracle, the
test count, and the [honest parity assessment](#honest-parity-assessment-2026-10-05) below, which
is the reference answer to "how close are we really". The weighted feature estimate in
[`parity-estimate.md`](parity-estimate.md) (~82%) counts feature surface and overstates user-facing
readiness; treat it as an upper bound.
| M | Status | Where we are |
|---|---|---|
@@ -26,6 +28,45 @@ remaining Opus 5.5 effort (~25–40 wall-clock hours to a 95%, ships-for-most st
**Menu parity: 532 / 625 (85.1%)** on 2026-10-01, up from 224 (35.8%) the day before. See [`parity.md`](parity.md).
## Honest parity assessment (2026-10-05)
Written after the 0.2.0 release and the first wave of real user feedback. **We are far from real
Photoshop parity.** `parity.md` says 625/625 menu items are live, but that only means each item
dispatches *a* command. In the first day of public use, users hit broken basics that all counted as
"live": text selection offset from the cursor, shortcuts that didn't fire after clicking a panel
(214 failures in an audit), panels resizing themselves, an immovable crop frame, folders that
wouldn't collapse, and lag on layout-style PSDs.
| Area | State |
|---|---|
| Core editing (layers, masks, selections, adjustments, filters, transforms, blend modes) | Broad coverage; engine quality decent; interaction/UI quality only started catching up after 0.2.0. |
| PSD fidelity | Composite oracle 113/170 (~66%). All adjustment layers and blend modes round-trip; Photoshop smart filters in PSDs, some effect shapes and text-engine details remain. psd-tools corpus being added (#81). |
| Tools | ~20 Photoshop tools missing entirely: Pencil, Mixer Brush (as a tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, vertical type and type masks, Frame. |
| Text | Engine works; advanced typography (OpenType features, text-on-path editing, full paragraph composer parity) and PSD text fidelity are partial. |
| Painting | Brush model close to Photoshop after #136/#174; no pen pressure on macOS/Linux (#79), no Mixer Brush tool, no Art History Brush. |
| AI / generative | ~0%: no Generative Fill/Expand (#41, deferred), Neural Filters or Remove tool. Classical Select Subject / Content-Aware Fill only. |
| Ecosystem | No .8BF plug-ins (by decision: sandboxed WebAssembly plug-ins instead), no ExtendScript/UXP/.atn actions, no Adobe Fonts / Libraries / cloud documents. |
| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), RW2, uncompressed ORF decode; Nikon compressed NEF, CR3, RAF fall back to the embedded preview (clean-room limits, #50). |
| Performance / robustness | Strong on large rasters (14k+ on the GPU, banded memory); layout-style documents were laggy (#125/#128 in progress); real-file testing has only just begun. |
**Estimate.** Feature surface: roughly **55–70%** of what a typical Photoshop user touches exists in
some form. "A working professional could switch today": roughly **20–35%**; professionals live in
the details (exact tool behaviour, PSD fidelity on their own files, typography, AI, plug-ins).
True 1:1 parity is **many months** of focused work, and some areas need product decisions rather
than effort (generative AI backend, the 8BF ecosystem, clean-room RAW limits).
**Making it measurable** (replace guesses with numbers; update this section as they land):
1. **Workflow acceptance tests:** 30–50 real tasks (e.g. retouch a portrait, build a social post
with text and effects, composite with masks and adjustment layers, prepare a CMYK print file),
each scripted end to end and checked against Photoshop's output, run on every build. The pass
rate becomes the headline parity number.
2. **Real-file corpus:** users' shareable PSDs plus the psd-tools set (#81), with pass-rate floors
that only go up (`crates/io/tests/corpus.rs`).
3. **Recurring visual QA:** screenshot sweeps of realistic documents (`cargo run -p photocraft-engine
--example designer_psd`). The first sweep found 14 defects (#147–#157) that unit tests missed.
4. **User reports:** fast turnaround from report → issue → fix; ask reporters for OS, document
size, layer count and a screenshot.
## Current focus (infrastructure before the long tail)
Landed on 2026-10-01:
@@ -36,8 +77,10 @@ Landed on 2026-10-01:
(`docs/releasing.md`).
Next:
1. **First signed release**: push to `release`, verify notarization and the installers, and fix CI
(`docs/releasing.md`). Windows code-signing material still needs to be obtained.
1. **Quality of what exists** (from user feedback after 0.2.0, see the assessment above): workflow
acceptance tests, real-file corpus, visual QA, and the open user issues. 0.2.0 shipped signed and
notarized on 2026-10-05 (`docs/releasing.md`); Windows code-signing material still needs to be
obtained.
2. **Fidelity**: the PSD oracle (113/170). Modern Brightness/Contrast and grayscale Levels
curves; chisel-soft / stroke-emboss bevel shapes; Photoshop's 8-bit blend rounding; non-Normal
modes in Lab documents; Photoshop smart filters in `SoLd`.
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Repackage a PhotoCraft Linux tarball as a single-file Flatpak bundle:
#
# $DIST/photocraft-<version>-linux-<arch>.flatpak
#
# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL]
#
# TARBALL defaults to $DIST/photocraft-<version>-linux-<arch>.tar.gz from package.sh. The bundle
# is built for the host architecture (x86_64 or aarch64), which must match the tarball's.
# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's
# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are
# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and
# `photocraft-cli --version` is run inside the sandbox as a smoke test.
# Manifest: packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/linux"
APP_ID=ai.storyteller.photocraft
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
TARBALL=""
while [ $# -gt 0 ]; do
case "$1" in
--no-test) TEST=0; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
esac
done
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ;;
aarch64 | arm64) ARCH=aarch64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
BASENAME="photocraft-$VERSION-linux-$ARCH"
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
echo "==> PhotoCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")"
WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
[ -x "$WORK/stage/bin/photocraft" ] || { echo "error: $TARBALL has no bin/photocraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
# --disable-rofiles-fuse: no FUSE in containers and CI runners.
flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \
--default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml"
OUT="$DIST/$BASENAME.flatpak"
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
if [ "$TEST" = 1 ]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
flatpak run --command=photocraft-cli "$APP_ID" --version
flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/scalable/apps'
fi
echo "==> done"
ls -lh "$OUT"
@@ -0,0 +1,50 @@
# Flatpak manifest for the single-file bundle attached to every GitHub release
# (photocraft-<version>-linux-<arch>.flatpak). Build it with packaging/linux/flatpak-bundle.sh.
#
# It repackages the release's own Linux tarball (photocraft-<version>-linux-<arch>.tar.gz,
# built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no
# network and no vendored crate sources during the build, a few seconds instead of 20+ minutes,
# and byte-for-byte the same binaries as the AppImage/deb/rpm. The script unpacks the tarball
# into `stage/` next to a copy of this file. The binaries need glibc >= 2.35 and load X11,
# Wayland, xkbcommon and Vulkan at runtime; the freedesktop runtime has all of them, and the
# GPU driver comes from its org.freedesktop.Platform.GL extension.
#
# ai.storyteller.photocraft.yml (same directory) is the from-source manifest for Flathub. Keep
# runtime-version and finish-args identical in both (packaging-lint checks this).
id: ai.storyteller.photocraft
runtime: org.freedesktop.Platform
runtime-version: '26.08'
sdk: org.freedesktop.Sdk
command: photocraft
finish-args:
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri
# Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and
# drag-and-drop through the document portal, so picked files work anywhere without broad
# filesystem access. Pictures and Documents are granted so files passed by path (the command
# line, Open Recent, "Save" next to the original) work in the usual places.
- --filesystem=xdg-pictures
- --filesystem=xdg-documents
# Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in
# the sandbox; `flatpak override --user --share=network ai.storyteller.photocraft` exposes it to
# host agents) and home/host filesystem access.
modules:
- name: photocraft
buildsystem: simple
build-commands:
- install -Dm755 bin/photocraft /app/bin/photocraft
- install -Dm755 bin/photocraft-cli /app/bin/photocraft-cli
- install -Dm644 share/applications/ai.storyteller.photocraft.desktop /app/share/applications/ai.storyteller.photocraft.desktop
# .pcraft, .psb and .qoi; the runtime's shared-mime-info already knows PSD and the rest.
- install -Dm644 share/mime/packages/ai.storyteller.photocraft.xml /app/share/mime/packages/ai.storyteller.photocraft.xml
# Already has the release's version and date substituted by package.sh.
- install -Dm644 share/metainfo/ai.storyteller.photocraft.metainfo.xml /app/share/metainfo/ai.storyteller.photocraft.metainfo.xml
- mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/photocraft /app/share/doc/
sources:
- type: dir
path: stage
@@ -8,22 +8,32 @@
# flatpak run ai.storyteller.photocraft
#
# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit.
# The single-file .flatpak attached to each release comes from ai.storyteller.photocraft.bundle.yml,
# which repackages the prebuilt release binaries. Keep runtime-version and finish-args identical.
id: ai.storyteller.photocraft
runtime: org.freedesktop.Platform
runtime-version: '25.08'
runtime-version: '26.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: photocraft
finish-args:
- --share=ipc
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri
# Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document
# portal, so broad filesystem access isn't needed. Pictures is granted for opening files by
# path from the command line.
# Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and
# drag-and-drop through the document portal, so picked files work anywhere without broad
# filesystem access. Pictures and Documents are granted so files passed by path (the command
# line, Open Recent, "Save" next to the original) work in the usual places.
- --filesystem=xdg-pictures
- --filesystem=xdg-documents
# Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in
# the sandbox; `flatpak override --user --share=network ai.storyteller.photocraft` exposes it to
# host agents) and home/host filesystem access.
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules: