mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-10 18:55:14 +00:00
History bar: a component over plain step data that returns what the user chose (#13429)
* Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935) A Shared crate depends only on other Shared crates, solidcore and external crates (UI toolkits included); any layered or testkit crate may use one. The layer check reports a Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each Shared crate's manifest (workspace lints, licence, README) and that no include_str! or include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in a scratch workspace of its own. cargo xtask ci runs it after the layer check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * UI kit: the widget registry is a shared crate, ui-registry (#12936) The handle, rect and count registry that UI tests, scenarios and the control channel read moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are unchanged and every handle keeps its name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Theme: the tab accent takes the tab, the sketch and the tool as data (#12938) TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out in one place. scoped_panel_tokens is public, so a host can draw any component in another palette or accent. The theme and its tests no longer name SolveApp, Services or workspace. Accents are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * UI kit: Preferences search is a kit module, settings_search (#13256) prefs_search moves whole into ui_kit::settings_search: it filters any settings page the kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles into the widget registry and reads the same wall clock as before (web_time), so no kit file calls the app for search any more. Every caller uses the new path; state ids, handle names, matches and scores are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * UI kit: the kit owns the menu entry and the list width (#13257) Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so the control channel's menu report is byte-identical. context_menu keeps building SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file refers to context_menu any more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258) parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker. The kit no longer knows the expression language: a host installs an Expressions (is this a literal, is this a unit, complete or step this text) per egui context, and fields read it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr and params_dialog::complete, when the app first draws into a context. Behaviour in the app is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * UI kit: navigation-bar values and the horizontal wheel in the kit (#13259) The bar's edge, size, idle and button-kind values move beside the bar's drawing in ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the kit already has a container Edge). Serde names are unchanged, so saved preferences load as before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's slot table stays in navigation_bar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260) LayoutMode (with its per-context publish and current), the card gap and the panic-free clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes the workspace width instead of reading the ribbon's constant. The flag that says a report covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved preferences load as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261) continuity_row and its explained and tip variants are CAD controls over the kernel's Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's choice_row. The Preferences navigator figure takes its axes (screen direction and depth) as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine, the record or the viewport outside tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * UI kit: length fields take unit scaling and length parsing from the host (#13270) The kit's length field converted millimetres to and from the design's units through three prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale, length_number and parse_length_mm, and length_drag reads them from the context. DocExpressions implements them with the same prefs helpers, so the app is unchanged. With nothing installed the field edits plain millimetres. No kit file calls prefs outside tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291) geom's srgb module (the one sRGB transfer function every encode and decode goes through) moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use the crate; there is no re-export at the old paths. The theme's unused material_color wrapper is removed, so no non-test theme code names solidcraft_engine. Values are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Material editor: the Remove buttons are translated (material.editor.remove) The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read "Remove" in every language. They use a new id, material.editor.remove, whose text in each locale is the one that locale already gives the plain Remove button prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now). The label-literal guard found it once the file's import named tr directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290) bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the build script that merges them, and the adapters that read engine types (diagnostic, tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait) stay in the app's i18n. The app installs its catalogs when it starts, when an app is built and in the widget test context. Call sites use the crate's path, with no re-export at the old one; messages, ids and fallbacks are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * i18n tests: the painted-English sweeps install the catalogs themselves (#13290) no_english_is_painted_where_the_language_has_a_translation and the_sweep_reports_english_only_where_a_translation_exists read translations without installing the app's catalogs, so they passed only when another test in the same process had installed them first (run alone, every id read as itself). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Theme: the icon palette leaves Tokens; the icon renderer owns it (#13304) A theme file's icon palette was a field of Tokens, so the token set depended on the icon roles ui-egui's build script generates. It is now the icon renderer's per-thread state (icon_svg::set_theme_palette), set alongside the theme's palette by theme::set_file_palette, and theme_files resolves it with resolve_icon_palette (the same overlay merge as the tokens) and exports it as an argument. Rendered pixels, theme files and exports are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Shared UI crates: the token contract as a shared crate, solidcraft-theme (#13304) Tokens with SolidCraft's light and dark values in code (palettes.rs moves in from assets/styles/ui, with the eight primary strokes it shared with the icon artwork written as icon_strokes), colour vision, TabAccent, contrast checks, apply/visuals and the per-thread and per-context theme state move into crates/theme, class Shared. It holds no data files and does not depend on theme files or icon roles; with nothing set, components draw with the built-in tokens. The theme system stays in ui-egui's theme module: the custom-theme glue (prepared theme, tab targets and their fade, blur intent), publish_tab (was TabAccent::publish, which now stores its result through TabAccent::set_current and set_published), set_file_palette, ViewportTheme. ui-egui's build script no longer generates the primary-stroke constants; an icon test holds the crate's literals equal to palette.toml's. Call sites use solidcraft_theme paths with no re-export (one rename pass). Tokens, theme files and rendered pixels are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Theme: the icon lint and the docs read palettes.rs at crates/theme (#13304) lint_icons.py compared the icon review page with assets/styles/ui/palettes.rs, which moved into solidcraft-theme; it reads crates/theme/src/palettes.rs now (cargo xtask icons passes again), and the READMEs, docs and test comments name the new path. set_custom and scoped_tokens say they set tokens only, so a host with an icon renderer sets its palette too; the field contrast test applies each built-in theme with set_file_palette, as the app does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Theme: format the field contrast test (#13304) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * MCP: the server machinery is a shared crate, solidcraft-mcp-server (#12947) JSON-RPC over stdio, the MCP lifecycle, tool and resource methods, tool definitions with their annotations, the argument check and the text and image results move to crates/mcp-server, which has no engine or GPU dependency and is classed Shared. SolidCraft's tools, instructions, resources and the agent's folders stay in crates/mcp and register into it through the Tools trait; its public API, tools list and replies are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * MCP: the shared server tests what it owns, on its own (#12947) Batches (mixed, notifications only, the 1000 limit), an unasked response, the protocol version fallback, null arguments, enum mismatches, the empty default lists and the list-then-call order Tools::call relies on are tested in solidcraft-mcp-server with a fake Tools; the protocol-only lines move out of SolidCraft's wrapper tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Docs: the architecture table lists the shared crates and the MCP split (#12947) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Control: the token files and Effect are a shared crate, solidcraft-control (#12946) The per-launch token, its files, the discovery file and the client's token lookup move from the engine to crates/control, with no engine dependency. Which app they belong to (name, data folder, environment, UTC format, default port) is an `App` value; SolidCraft's is engine::control_auth::APP. `Effect` moves with them; which requests do what stays in the engine's control_pending. Callers switch paths; files and messages are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Control: the loopback control server is shared; SolidCraft gives it its Protocol (#12946) The token-admitted JSON-lines server (framing and line limit, connection cap, control.running/pending/capabilities, busy and timeout replies, control.subscribe events), ControlRequest and the design watch move to solidcraft-control as `server::Hub`. What a request means (its name in messages, its effect, command id, step count, the batch wait, the capabilities) is the app's `server::Protocol`; SolidCraft's is in apps/solidcraft/src/control_server.rs with its listeners and token files. The wire format is unchanged and the server's tests move with it. Also from review: Effect::when_done and the timeout text take the app's word for its document ("design" for SolidCraft), the Listen docs name no SolidCraft preference, and bench_assistant.rs is formatted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Control: the shared server speaks the app's words and listens on loopback only (#12946) The change event, the revision method, the docs path in the unauthorized reply and the document noun come from the app's Protocol; SolidCraft's replies are byte-identical. The design watch is the DocumentWatch. Hub::listen refuses a listener off loopback, so no app can put the channel on the network; SolidCraft binds 127.0.0.1 as before. control_pending.rs is formatted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Control: ui.screenshot and ui.resize are shared over egui (#12946) The window's screenshots in flight (the frame waits, the AO-settled shot, the hidden-window and chat-turn refusals, the 8 s deadline) move from SolveApp to solidcraft_control::screenshot::Shots, with the PNG encoding; ui.resize and the hidden-window test go to window. SolidCraft passes its AO settle, its issue report's frames and its vfs write, so a second app can serve ui.screenshot with the shared crate alone. The issue report and tests reach the queue as app.shots; replies are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Control: ui.screenshot and ui.resize are their own shared crate, solidcraft-control-egui (#12946) d8d35d454 made egui and image dependencies of solidcraft-control, so the headless engine, the CLI and the MCP bridge compiled egui. The screenshot and window modules move unchanged to solidcraft-control-egui; ui-egui uses them from there. cargo tree finds no egui under engine, mcp or control. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Control: the app gives the screenshot reply for no frame (#12946) The shared text named SolidCraft's ui.render. Shots::new takes the app's words; SolidCraft passes its reply unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Control: tests for the screenshot gate, a hidden window and frames nobody waits for (#12946) Shots::issue takes a frame on the third frame and 150 ms after the request, or 500 ms later for a view that keeps moving; a hidden window refuses a request and answers one waiting; with nothing pending the frames that came are dropped. Each fails on a one-line mutation of screenshot.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Window chrome: the title bar and window settings from plain inputs (#12940) window_chrome holds what any Craft app's window needs without SolveApp: viewport(title, app_id, custom_titlebar) with SolidCraft's window settings, TitleBar (traffic-light inset, drag area, caption buttons) that returns a WindowAction for the app to run, and the resize zones and hit test. titlebar.rs is SolidCraft's thin use of it: Close asks about unsaved designs first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Window chrome: the macOS menu bar from the kit's menu items (#12940) window_chrome::native_menu builds the menu bar from ui_kit::Item trees (the app menu's system layout, the system's Window entries) and returns the chosen entry as (id, params); the app-menu title comes from the app. SolidCraft's native_menu.rs builds its Items from FileAction with command ids or ui.* ids and maps the ids back, with the same entries, shortcuts and labels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Window chrome: the application bar is laid out by TitleBar (#12940) app_bar drew its own drag area, traffic-light inset and caption buttons beside the shared TitleBar; it now runs TitleBar::show and answers the returned WindowAction through titlebar::run, so the title bar tests pin what SolidCraft draws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Window chrome: the menu bar's layout is pure and tested on every system (#12940) MenuTable lays out the whole bar (the app menu's system entries, the Window menu's extras, submenus) and replaces a submenu's entries itself; NativeMenu only turns rows into AppKit items. Nothing is dead code off macOS, and the layout and replace tests run everywhere. The Window menu's id is menu.window; menu ids are never dispatched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Native menu: entries use the control channel's ids, tested on every system (#12940) Quit is app.quit, Settings and Keyboard Shortcuts are ui.prefs {open}, Agent Chat is ui.agent_chat and Help's items are ui.help {item}; the menus are menu.file, menu.file.recent, menu.edit, menu.window and menu.help. The id round trip and the shortcut table are tested without a window on every system; the module builds there for its tests only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Native menu: its control ids do the same over the control channel (#12940) Agent Chat sends ui.agent_chat {toggle}, which the control channel now takes (show or hide as the panel's button does). ui.help runs every item the Help menu sends (docs, messages and install_cli too) and answers an error for an unknown item instead of a silent ok. A test runs the menu's control ids through the control channel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Window chrome: a menu of the bar that is off stays off; replace is for submenus (#12940) The bar's own menus take their item's enabled flag. replace() refuses a menu of the bar (it keeps the system entries the layout gave it), now documented and tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Native menu: the actions with a fixed id are a slice, not a sized array (#12940) The list grows with the menu, so it follows the repo's rule for growing lists (cargo xtask lists). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Navigation bar: the bar is a component that returns the slot or menu row chosen (#12944) nav_bar draws the bar, More, its context menu and the open menu from plain data (SlotView, Menu, BarLabels) and returns a NavAction; it keeps which menu is open and reads nothing of the app. navigation_bar_view now describes SolidCraft's slots and its Visual style and View menus as that data and runs the action it gets back with the same calls as before. The Preferences editor keeps using paint_row, which now paints through the component. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * View compass: the signed-axis compass is a component that returns the view chosen (#12944) view_compass draws the six signed axes from the camera's view axes (plain right, up and back vectors), the Home chip and the hover tip, and returns a ViewAction: Snap to an axis, Home, or Orbit by this frame's drag delta. Its drag tracker (edge warp included) is shared by the cube and the custom body, which keep their own drawing. SolidCraft turns the camera with the same calls as before (animate_turn with the reverse-on-repeat rule, animate_view, orbit_drag). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Navigation bar: a slot's second registry name is published where it is drawn (#12944) SlotView gains an alias the bar publishes at the slot's handle, on the bar and in More alike. SolidCraft gives the View menu's slot its older name `nav:view` there, instead of looking the slot's handle up after the bar has drawn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Navigation bar: repaint after running the chosen action (#12944) A menu that stays open after a pick (View) shows its rows' new state on the next frame instead of waiting for the next input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Navigation bar: the component's returned actions are tested from plain data (#12944) Driven with no app: a slot and a row of its menu are returned and a closing menu closes; More returns a folded slot and closes, opens a folded menu in its place, and a menu that does not close on a pick stays open; a submenu row is returned by its own id; the bar's context menu returns Customise, Hide and Reset, and a slot's (on the bar or in More) returns Remove with the slot's stored index. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * View compass: plain [f64; 3] axes in and out, no geometry type (#12944) ViewAxes, Tip and ViewAction::Snap carry plain arrays, with the dot product a local function, so the component needs neither solidcore_record::Vec3 nor the engine's geometry trait. The compass tip painter takes the axis as an array too (it only reads which axis it is, for the colour); SolidCraft converts at its edge (view_axes, the Snap arm), and the Preferences navigator figure is posed by the same ViewAxes::pose and AXES list as the live compass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * View compass: the tip reach is defined once, by the compass (#12944) The Preferences navigator figure placed its tips with its own 38.0 * 0.84; it now scales view_compass::TIP_REACH, so the figure and the live compass cannot drift. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * View compass: SolidCraft runs only the actions the compass can return (#12944) show() returns a tip or Home, never Orbit (a drag comes from orbit()); the docs say so and signed_axis_view no longer carries a dead Orbit arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * View compass: the module list in rustfmt's order (#12944) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * View compass: one function places a tip, for the compass and its figure (#12944) tip_offset maps a posed screen direction to where its tip is drawn, applying the reach once at f64; tips() and the Preferences navigator figure both call it, so TIP_REACH goes. The figure's tips move by about one f32 ulp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * View compass: show() returns only a tip or Home; a drag is an OrbitStep (#12944) ViewAction is now Snap or Home, so a drag cannot be represented as something the compass returns; orbit() returns its motion as an OrbitStep. signed_axis_view's match is exhaustive over what the compass can return, with no catch-all arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Navigation bar: SolidCraft's View slot is `nav:view` on the bar and in More (#12944) A harness test: with the View slot on the bar, `nav:view` is its handle; folded into More in a narrow view it is not shown until More opens, then `nav:view` is the folded row's handle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start page: the page is a component that returns the entry chosen (#12941) start_page::show draws the rail, the title and one body (action tiles, recent files and samples; a tile grid; a review list) from plain data: labels, icon names, host thumbnails, and what each entry stands for. It returns the entry clicked; it holds no app, design or engine type. home.rs is SolidCraft's use of it: it builds the page from the app's state and runs the chosen FileAction. Recent, menu and sample clicks now go through dispatch after the frame instead of running mid-draw. Every published home rect and handle is unchanged (dumped before/after). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW * Start page: tiles and samples take a host Icon (#12941) ui_kit::Icon is the picture a host gives a kit surface: a kit icon name, a texture it made, or its own painter. FileView's action tiles and sample cards draw it, so the start page shows another app's icons. start_page is a public module like view_compass; Body::Start is boxed (it is far larger than the other bodies). Tests: a tile draws the host's texture icon, a recent row's context menu returns the entry picked, a disabled rail entry returns nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Home: start-page entries name a recovery entry or recent file by index (#12941) FileAction::Recovery, OpenRecent, ForgetRecent and ShowInFolder carry an index into the app's lists instead of a cloned entry or path, and the page borrows titles and file names from the app, so drawing the start page no longer clones an entry or a path per frame. dispatch looks the index up once, when the entry is chosen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start page: a menu entry's icon is a kit name until MenuItem takes an Icon (#12941) Recent rows' context-menu entries are drawn by ui_kit::MenuItem, whose icon is a kit icon name; #13379 gives it a host Icon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UI kit: the type API moves to ui_kit::type_face (#13327) The weights (medium, semibold, bold, RichTextExt), the family and fallback names, the script checks and the CJK font loader state move out of fonts.rs into ui_kit::type_face, which holds no font data and no engine reference. fonts.rs keeps the host part: the embedded Plex faces, ui_font over the engine's font lookup, the system font search and the install functions. with_weight_families lets a host that installs no faces draw the kit's weights with egui's default faces. Call sites use the new path; no re-export at the old one. The font-family guard names the new file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fonts: which faces the web shell fetches is the host's choice (#13327) web_script_font_kind and needs_chinese_font decide which on-demand faces SolidCraft's web shell downloads, so they move from the kit's type API back to fonts.rs, with their assertions in a host test. The Arabic fallback's doc link points at solidcraft_ui_text::bidi. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UI kit: the CJK font loader tests install the catalogs they read (#13327) CjkFontLoader resolves the language through the installed catalogs, so its tests passed only when another test in the process had installed them first (they failed under `-- cjk` alone). Each now installs them itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UI kit: a host with no faces of its own calls type_face::install (#13327) The kit's weights draw only once the context binds the weight families. The host contract is now stated on the module and in docs/ui-kit.md: a host with faces binds them in its definitions (with_weight_families binds any it leaves out), a host with none calls type_face::install once. A test draws the semibold name of a kit status header on a bare context after only that call; without it egui has no face for the family and draws no glyphs. Also puts `pub mod type_face` in rustfmt's order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fonts: the Thai, Hebrew and Arabic names are imported only off the web (#13327) Only the desktop's system font search uses them, so the wasm build warned that the imports were unused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fonts: the wasm test build imports the Thai, Hebrew and Arabic names (#13327) The font tests use them on every target, so the import is gated on test or a desktop target, not on the desktop alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UI kit: type_face::install is install_weight_families (#13327) The kit's entry point had the same name as the host's private fonts::install, which installs SolidCraft's faces. The new name says what the kit call does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UI kit: install_weight_families says it replaces the font definitions (#13327) It sets egui's default definitions. It cannot merge into the context's own: egui keeps the definitions set this frame private until the next one, and reading the current fonts before the first frame panics. The doc and docs/ui-kit.md say so; a host with faces passes its definitions through with_weight_families instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UI kit: the bare-context test also checks a context without the binding (#13327) The control half: a bare context that never called install_weight_families has no semibold family and no face that draws the name, which is why the call is the host contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UI kit: the egui-faces test sets up through install_weight_families (#13327) It inlined the entry point's body; it now calls the call a host makes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * History bar: the bottom History bar as a component over plain step data (#12945) history_bar draws the grip and title, the History controls and a strip of step chips with the marker, from plain data (Bar, Step, StepState), and returns Actions: Seek (a control or the dropped marker, as a slot and its side of the marker), Play, ControlsMenu, Select with its modifiers, Edit, Menu (the app shows its own items) and Reorder. The app draws its own chips, trays and inline rows through the strip (Contents). It keeps only the drag and reads nothing of the app. Tested with no app: clicks, a double click, right clicks, step and marker drags on both sides of the marker, a folded chip of the app's, the controls with and without Play, a narrow bar, an empty bar and a release outside the window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * History bar: SolidCraft's bottom History bar is the component with its features (#12945) timeline_contents describes the features as Steps and draws its folded runs, folders, joint rows, trays and the trailing joints through the component's strip; it runs the returned actions with the same calls as before (select or pick into the dialog, Edit Feature, the one context menu, roll_to at the dropped slot's design position, drop_feature). The History controls' actions run right after the header, as before; a chip's click and menu now run once the strip is drawn. transport_response (the left History) routes through the same transport actions. The bar's own drag and slot code leaves timeline.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * History bar: the grip and the History controls publish under the bar's Handles (#12945) The grip's id comes from the bar's scope and its grip handle instead of one global id, so two bars never share a drag. The History controls are published as Handles::controls followed by their id through HistoryTransport::show_as; show keeps "history:" for its other callers. SolidCraft passes "history:", so its names are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * History bar: a step carries its icon's accent; the bar reads no kind names (#12945) Step.kind (matched against SolidCraft's feature type names for the icon's accent) becomes Step.icon_accent, an icon palette name or none, painted through HistoryChip::paint_with_accent. SolidCraft passes what its kinds give (feature_icon_accent of the type name and icon), so its chips are unchanged; another app gives its own accents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * History bar: tests for a drop at the marker from after it and for the chip's marks (#12945) A step from after the marker dropped at the marker's own slot returns the side it lands on; highlighted, rolled back and failed steps reach their chips (lit, faded, the error fill); only the floating card dots the last step before the marker, and only that step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
//! The History bar as a component: the grip and title, the History controls, and a strip of step
|
||||
//! chips with the history marker between them. The app describes each step as plain data
|
||||
//! ([`Step`]) in the order the bar shows them, draws chips of its own between them through
|
||||
//! [`Strip::chip`] and [`Strip::inline`] (its [`Contents`]), and runs the [`Action`]s the bar
|
||||
//! returns. The bar keeps only which chip, or the marker, is being dragged, and reads nothing of
|
||||
//! the app. Its grip, marker and chips publish their places to the widget registry under the
|
||||
//! names the app gives ([`Handles`]).
|
||||
//!
|
||||
//! Slots count the steps the bar shows: slot `k` is the gap before the `k`-th of them, slot
|
||||
//! `steps` the end. The marker sits in one; a drop lands in one, on one side of the marker.
|
||||
|
||||
use crate::ui_kit::{self, BackingTooltipExt as _, HistoryChip, HistoryStyle, HistoryTransport};
|
||||
use egui::{Painter, Pos2, Rect, Sense, UiBuilder, pos2, vec2};
|
||||
use solidcraft_theme::{TabAccent, Tokens};
|
||||
|
||||
/// Where the bar publishes its parts in the widget registry.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Handles<'a> {
|
||||
/// The grip and title, which drag the bar to another dock.
|
||||
pub grip: &'a str,
|
||||
pub marker: &'a str,
|
||||
/// Each step's chip, as this followed by the step's label.
|
||||
pub step: &'a str,
|
||||
/// Each History control, as this followed by its id (`first`, `back`, `play`...).
|
||||
pub controls: &'a str,
|
||||
}
|
||||
|
||||
/// The bar as the app describes it this frame.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Bar<'a> {
|
||||
/// The heading after the grip, left out when the bar is too narrow for it.
|
||||
pub title: &'a str,
|
||||
/// The marker's hover text.
|
||||
pub marker_tip: &'a str,
|
||||
/// The History controls show Play.
|
||||
pub play: bool,
|
||||
/// History is playing: Play shows Stop.
|
||||
pub playing: bool,
|
||||
pub accent: TabAccent,
|
||||
/// A dragged chip's colour and its drop bar's.
|
||||
pub drag_accent: TabAccent,
|
||||
/// The marker's slot.
|
||||
pub marker: usize,
|
||||
/// How many steps the bar shows.
|
||||
pub steps: usize,
|
||||
/// The floating card, which also dots the last step before the marker.
|
||||
pub floating: bool,
|
||||
pub handles: Handles<'a>,
|
||||
}
|
||||
|
||||
/// One step's chip.
|
||||
pub struct Step<'a> {
|
||||
pub id: u64,
|
||||
/// Its slot: its place among the steps the bar shows.
|
||||
pub slot: usize,
|
||||
pub label: &'a str,
|
||||
/// A kit icon name.
|
||||
pub icon: &'a str,
|
||||
/// The icon's accent, an icon palette name (`None`: the artwork's own colours).
|
||||
pub icon_accent: Option<&'a str>,
|
||||
pub state: StepState,
|
||||
pub error: bool,
|
||||
pub warning: bool,
|
||||
/// Draw the icon in this one colour instead of its artwork.
|
||||
pub ink: Option<egui::Color32>,
|
||||
/// The hover text, written only while it shows.
|
||||
pub tip: &'a dyn Fn() -> String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StepState {
|
||||
pub selected: bool,
|
||||
/// Lit as though hovered (it made what is under the pointer elsewhere).
|
||||
pub highlighted: bool,
|
||||
/// After the marker: drawn faded.
|
||||
pub rolled_back: bool,
|
||||
pub suppressed: bool,
|
||||
/// The last step before the marker (dotted on the floating card).
|
||||
pub last_done: bool,
|
||||
}
|
||||
|
||||
/// Where the marker goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Seek {
|
||||
First,
|
||||
Back,
|
||||
Forward,
|
||||
Last,
|
||||
/// Dropped in `slot`; `before` says on which side of the marker, at the marker's own slot.
|
||||
Slot {
|
||||
slot: usize,
|
||||
before: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the user chose on the bar this frame, in the order it happened.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
Seek(Seek),
|
||||
/// Play, or Stop while playing.
|
||||
Play,
|
||||
/// The History controls' menu, opened at `at` (shown by the app, as for [`Action::Menu`]).
|
||||
ControlsMenu {
|
||||
at: Pos2,
|
||||
},
|
||||
/// A click on a step, with the modifiers held.
|
||||
Select {
|
||||
step: u64,
|
||||
modifiers: egui::Modifiers,
|
||||
},
|
||||
/// A double click on a step.
|
||||
Edit(u64),
|
||||
/// A step's menu, opened at `at`; the app shows its own items in its own menu. A host
|
||||
/// without a menu system can draw its items with `ui_kit::Menu` at `at`.
|
||||
Menu {
|
||||
step: u64,
|
||||
at: Pos2,
|
||||
},
|
||||
/// A step dropped in `slot`; `before` as for [`Seek::Slot`].
|
||||
Reorder {
|
||||
step: u64,
|
||||
slot: usize,
|
||||
before: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// The header drawn: where the strip may start, the grip's response, what was chosen.
|
||||
pub struct HeaderOut {
|
||||
pub x: f32,
|
||||
pub grip: egui::Response,
|
||||
pub actions: Vec<Action>,
|
||||
}
|
||||
|
||||
/// What a History control's response asks for: a click runs it, a right-click opens the
|
||||
/// controls' menu. (Crate-wide while `HistoryTransport` is.)
|
||||
pub(crate) fn transport_actions(control: HistoryTransport, response: &egui::Response) -> Vec<Action> {
|
||||
let mut actions = Vec::new();
|
||||
if response.clicked() {
|
||||
actions.push(match control {
|
||||
HistoryTransport::First => Action::Seek(Seek::First),
|
||||
HistoryTransport::Back => Action::Seek(Seek::Back),
|
||||
HistoryTransport::Play => Action::Play,
|
||||
HistoryTransport::Forward => Action::Seek(Seek::Forward),
|
||||
HistoryTransport::Last => Action::Seek(Seek::Last),
|
||||
});
|
||||
}
|
||||
if response.secondary_clicked() {
|
||||
actions.push(Action::ControlsMenu { at: response.interact_pointer_pos().unwrap_or(response.rect.left_bottom()) });
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
/// Draws the grip, the title when it fits and the History controls along the left of `rect`.
|
||||
pub fn header(ui: &mut egui::Ui, bar: &Bar, rect: Rect) -> HeaderOut {
|
||||
let t = Tokens::get();
|
||||
let p = ui.painter().clone();
|
||||
let mut x = rect.left() + t.space_10;
|
||||
let grip_rect = Rect::from_center_size(pos2(x + t.space_10 / 2.0, rect.center().y), vec2(t.space_10, t.space_16 + t.space_2));
|
||||
let mut grip_ui = ui.new_child(UiBuilder::new().max_rect(grip_rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
let grip = ui_kit::panel_grip(&mut grip_ui).rect;
|
||||
x += t.space_10 + t.space_8;
|
||||
let mut drag_rect = grip;
|
||||
let heading = ui_kit::StripHeading::new(&p, bar.title);
|
||||
if x + heading.width() + ui_kit::history_transport_width() <= rect.right() {
|
||||
let title_rect = heading.paint(&p, Rect::from_min_max(pos2(x, rect.top()), rect.right_bottom()));
|
||||
drag_rect = drag_rect.union(title_rect);
|
||||
x += heading.width();
|
||||
}
|
||||
let grip = ui.interact(drag_rect, ui.scope_id().with(("history_bar_grip", bar.handles.grip)), Sense::drag());
|
||||
solidcraft_ui_registry::publish_handle(bar.handles.grip, drag_rect.center());
|
||||
let mut actions = Vec::new();
|
||||
for &control in HistoryTransport::shown(bar.play) {
|
||||
let br = Rect::from_center_size(pos2(x + t.space_24 / 2.0, rect.center().y), vec2(t.space_24, t.space_24));
|
||||
let response = control.show_as(ui, br, bar.playing, bar.handles.controls);
|
||||
actions.extend(transport_actions(control, &response));
|
||||
x += t.space_24 + t.space_2;
|
||||
}
|
||||
HeaderOut { x: x + 14.0, grip, actions }
|
||||
}
|
||||
|
||||
/// What the app draws in the strip.
|
||||
pub trait Contents {
|
||||
/// Lays out the strip's entries in order: steps, the app's own chips and trays.
|
||||
fn entries(&mut self, strip: &mut Strip<'_, '_>);
|
||||
|
||||
/// Draws what follows the steps and the marker at the end, from `end`; returns where it ends.
|
||||
fn trailing(&mut self, _ui: &mut egui::Ui, _painter: &Painter, end: f32, _bar: Rect) -> f32 {
|
||||
end
|
||||
}
|
||||
}
|
||||
|
||||
/// The strip drawn: what was chosen, and the step under the pointer.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct StripOut {
|
||||
pub actions: Vec<Action>,
|
||||
pub hovered: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Drag {
|
||||
Marker,
|
||||
Step(u64),
|
||||
}
|
||||
|
||||
/// The strip being laid out, left to right.
|
||||
pub struct Strip<'u, 'b> {
|
||||
ui: &'u mut egui::Ui,
|
||||
painter: Painter,
|
||||
bar: Bar<'b>,
|
||||
/// The whole bar's rect: chips sit at its top.
|
||||
rect: Rect,
|
||||
x: f32,
|
||||
/// Each slot's left edge, one per step (a folded chip repeats its own for the steps it holds).
|
||||
xs: Vec<f32>,
|
||||
marker_rect: Option<Rect>,
|
||||
/// Each open tray: where it starts, its place under the chips, its depth.
|
||||
trays: Vec<(f32, egui::layers::ShapeIdx, usize)>,
|
||||
drag: Option<Drag>,
|
||||
hovered: Option<u64>,
|
||||
edit: Option<u64>,
|
||||
actions: Vec<Action>,
|
||||
}
|
||||
|
||||
/// Draws the steps in `rect`, scrolled sideways (`scroll` keeps its own scroll offset), the bar's
|
||||
/// rect being `bar_rect`; the drop of a dragged chip or marker is returned when it is released.
|
||||
pub fn strip(
|
||||
ui: &mut egui::Ui,
|
||||
bar: &Bar,
|
||||
bar_rect: Rect,
|
||||
rect: Rect,
|
||||
scroll: impl std::hash::Hash + std::fmt::Debug,
|
||||
contents: &mut dyn Contents,
|
||||
) -> StripOut {
|
||||
let mut out = StripOut::default();
|
||||
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
|
||||
// A plain mouse wheel scrolls the strip sideways too (#3356).
|
||||
ui.style_mut().always_scroll_the_only_direction = true;
|
||||
let area = egui::ScrollArea::horizontal().id_salt(scroll).auto_shrink([false, false]);
|
||||
area.show(ui, |ui| out = lay_out(ui, *bar, bar_rect, contents));
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn lay_out(ui: &mut egui::Ui, bar: Bar, rect: Rect, contents: &mut dyn Contents) -> StripOut {
|
||||
let t = Tokens::get();
|
||||
let drag_id = ui.scope_id().with("tl_drag");
|
||||
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id));
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let x = ui.max_rect().left();
|
||||
let origin = x;
|
||||
let painter = ui.painter().clone();
|
||||
let mut strip = Strip {
|
||||
ui,
|
||||
painter,
|
||||
bar,
|
||||
rect,
|
||||
x,
|
||||
xs: Vec::new(),
|
||||
marker_rect: None,
|
||||
trays: Vec::new(),
|
||||
drag,
|
||||
hovered: None,
|
||||
edit: None,
|
||||
actions: Vec::new(),
|
||||
};
|
||||
contents.entries(&mut strip);
|
||||
let Strip { ui, painter: p, x: end, xs, mut marker_rect, mut drag, hovered, edit, mut actions, .. } = strip;
|
||||
let marker = bar.marker;
|
||||
if marker >= bar.steps && marker_rect.is_none() {
|
||||
marker_rect = Some(marker_at(rect, end));
|
||||
}
|
||||
// The history marker: drag it to roll the model back or forward.
|
||||
if let Some(mr) = marker_rect {
|
||||
solidcraft_ui_registry::publish_rect(bar.handles.marker, mr);
|
||||
let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.scope_id().with("tl_marker"), Sense::drag());
|
||||
if resp.drag_started() {
|
||||
drag = Some(Drag::Marker);
|
||||
}
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
ui_kit::history_marker(&p, mr, false, bar.accent);
|
||||
resp.backing_on_hover_text(bar.marker_tip);
|
||||
}
|
||||
let content_end = contents.trailing(ui, &p, end, rect);
|
||||
// The scroll range follows the drawn content (the chips don't take layout space themselves).
|
||||
ui.allocate_exact_size(vec2((content_end - origin).max(0.0), rect.height()), Sense::hover());
|
||||
// Drag feedback, and the drop.
|
||||
if drag.is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
// At the marker's slot, which side of the marker the pointer is on: before it, a dropped
|
||||
// step stays in the model; after it, the step is rolled back.
|
||||
let before_marker = |slot: usize, x: f32| slot < marker || (slot == marker && marker_rect.is_none_or(|mr| x < mr.center().x));
|
||||
// The marker dropped where it already is needs no bar: the marker shows the spot.
|
||||
if let (Some(dg), Some(pp)) = (drag.as_ref(), pointer)
|
||||
&& let local = slot_at(&xs, end, pp.x)
|
||||
&& !(matches!(dg, Drag::Marker) && local == marker)
|
||||
{
|
||||
// Centred in the gap between two chips, or beside the marker at its slot.
|
||||
let sx = match marker_rect {
|
||||
Some(mr) if local == marker => {
|
||||
if before_marker(local, pp.x) {
|
||||
mr.left() - 2.0
|
||||
} else {
|
||||
mr.right() + 2.0
|
||||
}
|
||||
}
|
||||
_ => xs.get(local).copied().unwrap_or(end) - 2.0,
|
||||
};
|
||||
// Kept inside the strip so the slot before the first step stays visible.
|
||||
let sx = sx.max(origin + t.space_2);
|
||||
ui_kit::history_drop_bar(
|
||||
&p,
|
||||
Rect::from_min_max(pos2(sx, rect.top() + 3.0), pos2(sx, rect.bottom() - 3.0)),
|
||||
false,
|
||||
if matches!(dg, Drag::Marker) { bar.accent.color } else { bar.drag_accent.color },
|
||||
);
|
||||
}
|
||||
let released = ui.input(|i| i.pointer.any_released());
|
||||
// (Taking the drag ends it even when the pointer has left the window.)
|
||||
if released
|
||||
&& let Some(dg) = drag.take()
|
||||
&& let Some(pp) = pointer
|
||||
{
|
||||
let slot = slot_at(&xs, end, pp.x);
|
||||
let before = before_marker(slot, pp.x);
|
||||
actions.push(match dg {
|
||||
Drag::Marker => Action::Seek(Seek::Slot { slot, before }),
|
||||
Drag::Step(step) => Action::Reorder { step, slot, before },
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| match drag {
|
||||
Some(dg) => {
|
||||
d.insert_temp(drag_id, dg);
|
||||
}
|
||||
None => d.remove::<Drag>(drag_id),
|
||||
});
|
||||
actions.extend(edit.map(Action::Edit));
|
||||
StripOut { actions, hovered }
|
||||
}
|
||||
|
||||
/// The marker's rect at `x` in the bar `rect`.
|
||||
fn marker_at(rect: Rect, x: f32) -> Rect {
|
||||
let t = Tokens::get();
|
||||
Rect::from_min_size(pos2(x, rect.top() + 5.0), vec2(t.space_12, rect.height() - 10.0))
|
||||
}
|
||||
|
||||
/// Slot index (0..=n) nearest to screen x, given the left edges of the chips.
|
||||
fn slot_at(xs: &[f32], end: f32, x: f32) -> usize {
|
||||
let mut best = (0usize, f32::INFINITY);
|
||||
for (i, sx) in xs.iter().chain(std::iter::once(&end)).enumerate() {
|
||||
let d = (sx - 2.0 - x).abs();
|
||||
if d < best.1 {
|
||||
best = (i, d);
|
||||
}
|
||||
}
|
||||
best.0
|
||||
}
|
||||
|
||||
impl Strip<'_, '_> {
|
||||
/// The strip's ui, for what the app draws itself.
|
||||
pub fn ui(&mut self) -> &mut egui::Ui {
|
||||
self.ui
|
||||
}
|
||||
|
||||
pub fn painter(&self) -> &Painter {
|
||||
&self.painter
|
||||
}
|
||||
|
||||
/// The whole bar's rect.
|
||||
pub fn rect(&self) -> Rect {
|
||||
self.rect
|
||||
}
|
||||
|
||||
/// Puts the marker here once.
|
||||
fn marker_here(&mut self) {
|
||||
if self.marker_rect.is_none() {
|
||||
self.marker_rect = Some(marker_at(self.rect, self.x));
|
||||
self.x += Tokens::get().space_16;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a step's chip and returns its rect. A chip scrolled out of the strip is neither
|
||||
/// drawn nor interactive; its slot still places the marker and drops. One being dragged or
|
||||
/// focused, or under an open menu, stays alive until that ends.
|
||||
pub fn step(&mut self, step: &Step) -> Rect {
|
||||
let t = Tokens::get();
|
||||
if step.slot == self.bar.marker {
|
||||
self.marker_here();
|
||||
}
|
||||
self.xs.push(self.x);
|
||||
let br = Rect::from_min_size(pos2(self.x, self.rect.top() + 6.0), vec2(28.0, 28.0));
|
||||
self.x += 32.0;
|
||||
solidcraft_ui_registry::publish_handle(&format!("{}{}", self.bar.handles.step, step.label), br.center());
|
||||
let ui = &mut *self.ui;
|
||||
let chip_id = ui.scope_id().with(("feat", step.id));
|
||||
let dragged = self.drag == Some(Drag::Step(step.id));
|
||||
if !ui.clip_rect().intersects(br) && !dragged && !ui.memory(|m| m.has_focus(chip_id)) && !egui::Popup::is_any_open(ui.ctx()) {
|
||||
return br;
|
||||
}
|
||||
let resp = ui.interact(br, chip_id, Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
self.hovered = Some(step.id);
|
||||
}
|
||||
HistoryChip {
|
||||
icon: step.icon,
|
||||
name: step.label,
|
||||
kind: "",
|
||||
selected: step.state.selected,
|
||||
hovered: resp.hovered() || step.state.highlighted,
|
||||
dim: step.state.rolled_back,
|
||||
suppressed: step.state.suppressed,
|
||||
error: step.error,
|
||||
warning: step.warning,
|
||||
reference: false,
|
||||
ink: step.ink,
|
||||
accent: if dragged { self.bar.drag_accent } else { self.bar.accent },
|
||||
}
|
||||
.paint_with_accent(&self.painter, br, HistoryStyle::Bottom, step.icon_accent);
|
||||
if self.bar.floating && step.state.last_done {
|
||||
self.painter.circle_filled(pos2(br.center().x, br.bottom() + t.space_3), t.space_2, self.bar.accent.color);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
self.drag = Some(Drag::Step(step.id));
|
||||
}
|
||||
let resp = resp.backing_on_hover_ui(|ui| {
|
||||
ui.set_max_width(ui.spacing().tooltip_width);
|
||||
ui.add(egui::Label::new((step.tip)()));
|
||||
});
|
||||
if resp.clicked() {
|
||||
self.actions.push(Action::Select { step: step.id, modifiers: ui.input(|i| i.modifiers) });
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
self.edit = Some(step.id);
|
||||
}
|
||||
if resp.secondary_clicked() {
|
||||
self.actions.push(Action::Menu { step: step.id, at: resp.interact_pointer_pos().unwrap_or(br.center()) });
|
||||
}
|
||||
br
|
||||
}
|
||||
|
||||
/// A chip of the app's own for the steps in slots `start..end` (a folded run or folder), drawn
|
||||
/// by `draw` with its top left at the given point, returning its width. The marker goes before
|
||||
/// it at its first slot, and after it inside a folded one; a drop on a folded chip lands
|
||||
/// before it. An `open` one starts a tray `depth` deep, ended by [`Strip::end_tray`].
|
||||
/// Returns its rect.
|
||||
pub fn chip(&mut self, start: usize, end: usize, open: bool, depth: usize, draw: impl FnOnce(&mut egui::Ui, &Painter, Pos2) -> f32) -> Rect {
|
||||
let marker = self.bar.marker;
|
||||
if start < end && start == marker {
|
||||
self.marker_here();
|
||||
}
|
||||
if open {
|
||||
self.trays.push((self.x, self.painter.add(egui::Shape::Noop), depth));
|
||||
self.x += ui_kit::HISTORY_TRAY_PAD;
|
||||
}
|
||||
let at = pos2(self.x, self.rect.top() + 6.0);
|
||||
let width = draw(self.ui, &self.painter, at);
|
||||
if !open {
|
||||
self.xs.extend(std::iter::repeat_n(self.x, end.saturating_sub(start)));
|
||||
}
|
||||
self.x += width + 4.0;
|
||||
if !open && start < marker && marker < end {
|
||||
self.marker_here();
|
||||
}
|
||||
Rect::from_min_size(at, vec2(width, 28.0))
|
||||
}
|
||||
|
||||
/// Ends the innermost open tray here.
|
||||
pub fn end_tray(&mut self) {
|
||||
if let Some((left, tray, depth)) = self.trays.pop() {
|
||||
let right = self.x - 4.0 + ui_kit::HISTORY_TRAY_PAD;
|
||||
self.painter.set(tray, ui_kit::history_tray(left, right, self.rect.top() + 6.0, depth, self.bar.accent));
|
||||
self.x = right + 4.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Something of the app's own that takes no slot, drawn by `draw` from x in the bar's rect,
|
||||
/// returning where it ends.
|
||||
pub fn inline(&mut self, draw: impl FnOnce(&mut egui::Ui, &Painter, f32, Rect) -> f32) {
|
||||
self.x = draw(self.ui, &self.painter, self.x, self.rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "history_bar_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,334 @@
|
||||
//! The History bar driven with plain data, no app: what each click and drag returns.
|
||||
use super::*;
|
||||
use egui::{Event, Modifiers, PointerButton, RawInput};
|
||||
use std::cell::Cell;
|
||||
|
||||
const HANDLES: Handles<'static> = Handles { grip: "test:grip", marker: "test:marker", step: "test:", controls: "test:ctl:" };
|
||||
|
||||
fn bar(steps: usize, marker: usize, play: bool) -> Bar<'static> {
|
||||
let t = Tokens::get();
|
||||
Bar {
|
||||
title: "Steps",
|
||||
marker_tip: "Drag me",
|
||||
play,
|
||||
playing: false,
|
||||
accent: TabAccent::for_tab(&t, "MODEL"),
|
||||
drag_accent: TabAccent::for_tab(&t, "SKETCH"),
|
||||
marker,
|
||||
steps,
|
||||
floating: false,
|
||||
handles: HANDLES,
|
||||
}
|
||||
}
|
||||
|
||||
/// The steps in order; `folded` draws one chip of the app's own for those slots instead.
|
||||
struct Steps {
|
||||
labels: Vec<&'static str>,
|
||||
folded: Option<(usize, usize)>,
|
||||
/// Each step's state and whether it failed, by slot (default: neither).
|
||||
marks: Vec<(StepState, bool)>,
|
||||
}
|
||||
|
||||
impl Contents for Steps {
|
||||
fn entries(&mut self, strip: &mut Strip<'_, '_>) {
|
||||
let mut slot = 0;
|
||||
while let Some(label) = self.labels.get(slot).copied() {
|
||||
if let Some((start, end)) = self.folded.filter(|(start, _)| *start == slot) {
|
||||
strip.chip(start, end, false, 0, |ui, _, at| {
|
||||
solidcraft_ui_registry::publish_handle("test:folded", at + vec2(20.0, 14.0));
|
||||
let _ = ui;
|
||||
40.0
|
||||
});
|
||||
slot = end;
|
||||
continue;
|
||||
}
|
||||
let tip = || format!("{label} tip");
|
||||
let (state, error) = self.marks.get(slot).copied().unwrap_or_default();
|
||||
strip.step(&Step {
|
||||
id: slot as u64 + 1,
|
||||
slot,
|
||||
label,
|
||||
icon: "box",
|
||||
icon_accent: None,
|
||||
state,
|
||||
error,
|
||||
warning: false,
|
||||
ink: None,
|
||||
tip: &tip,
|
||||
});
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Rig {
|
||||
ctx: egui::Context,
|
||||
bar: Bar<'static>,
|
||||
rect: Rect,
|
||||
labels: Vec<&'static str>,
|
||||
folded: Option<(usize, usize)>,
|
||||
marks: Vec<(StepState, bool)>,
|
||||
time: Cell<f64>,
|
||||
hovered: Cell<Option<u64>>,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new(labels: &[&'static str], marker: usize) -> Rig {
|
||||
let rig = Rig {
|
||||
ctx: crate::fonts::test_context(),
|
||||
bar: bar(labels.len(), marker, true),
|
||||
rect: Rect::from_min_size(pos2(0.0, 100.0), vec2(700.0, 40.0)),
|
||||
labels: labels.to_vec(),
|
||||
folded: None,
|
||||
marks: Vec::new(),
|
||||
time: Cell::new(0.0),
|
||||
hovered: Cell::new(None),
|
||||
};
|
||||
rig.frame(Vec::new());
|
||||
rig
|
||||
}
|
||||
|
||||
fn frame_with(&self, events: Vec<Event>, modifiers: Modifiers) -> Vec<Action> {
|
||||
self.output(events, modifiers).0
|
||||
}
|
||||
|
||||
/// A frame's actions and what it painted.
|
||||
fn output(&self, events: Vec<Event>, modifiers: Modifiers) -> (Vec<Action>, egui::FullOutput) {
|
||||
solidcraft_ui_registry::clear_handles();
|
||||
self.time.set(self.time.get() + 1.0 / 60.0);
|
||||
let mut actions = Vec::new();
|
||||
let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect();
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 300.0))),
|
||||
time: Some(self.time.get()),
|
||||
events,
|
||||
focused: true,
|
||||
..Default::default()
|
||||
};
|
||||
let output = crate::test_support::frame(&self.ctx, input, |ui| {
|
||||
let head = header(ui, &self.bar, self.rect);
|
||||
actions.extend(head.actions);
|
||||
let strip_rect = Rect::from_min_max(pos2(head.x, self.rect.top()), self.rect.right_bottom());
|
||||
let mut steps = Steps { labels: self.labels.clone(), folded: self.folded, marks: self.marks.clone() };
|
||||
let out = strip(ui, &self.bar, self.rect, strip_rect, "test_strip", &mut steps);
|
||||
self.hovered.set(out.hovered);
|
||||
actions.extend(out.actions);
|
||||
});
|
||||
(actions, output)
|
||||
}
|
||||
|
||||
fn frame(&self, events: Vec<Event>) -> Vec<Action> {
|
||||
self.frame_with(events, Modifiers::NONE)
|
||||
}
|
||||
|
||||
fn at(&self, name: &str) -> Pos2 {
|
||||
solidcraft_ui_registry::handle_at(name).unwrap_or_else(|| panic!("{name} is drawn"))
|
||||
}
|
||||
|
||||
/// The marker's centre (it is published as a rect).
|
||||
fn marker(&self) -> Pos2 {
|
||||
solidcraft_ui_registry::rect_of("test:marker").map(|r| r.center()).unwrap_or_else(|| panic!("the marker is drawn"))
|
||||
}
|
||||
|
||||
fn press(&self, at: Pos2, button: PointerButton, pressed: bool, modifiers: Modifiers) -> Vec<Action> {
|
||||
self.frame_with(vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button, pressed, modifiers }], modifiers)
|
||||
}
|
||||
|
||||
/// A click well after the last one, so the two are never a double click.
|
||||
fn click_with(&self, at: Pos2, button: PointerButton, modifiers: Modifiers) -> Vec<Action> {
|
||||
self.time.set(self.time.get() + 1.0);
|
||||
let mut actions = self.frame(vec![Event::PointerMoved(at)]);
|
||||
actions.extend(self.press(at, button, true, modifiers));
|
||||
actions.extend(self.press(at, button, false, modifiers));
|
||||
actions
|
||||
}
|
||||
|
||||
fn click(&self, at: Pos2) -> Vec<Action> {
|
||||
self.click_with(at, PointerButton::Primary, Modifiers::NONE)
|
||||
}
|
||||
|
||||
/// Press at `from`, move past the drag threshold, then to `to`, and release there.
|
||||
fn drag(&self, from: Pos2, to: Pos2) -> Vec<Action> {
|
||||
let mut actions = self.frame(vec![Event::PointerMoved(from)]);
|
||||
actions.extend(self.press(from, PointerButton::Primary, true, Modifiers::NONE));
|
||||
actions.extend(self.frame(vec![Event::PointerMoved(from + vec2(0.0, 10.0))]));
|
||||
actions.extend(self.frame(vec![Event::PointerMoved(to)]));
|
||||
actions.extend(self.frame(vec![Event::PointerMoved(to)]));
|
||||
actions.extend(self.press(to, PointerButton::Primary, false, Modifiers::NONE));
|
||||
actions
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_step_returns_select_with_its_modifiers_and_runs_nothing() {
|
||||
let rig = Rig::new(&["A", "B", "C"], 2);
|
||||
let b = rig.at("test:B");
|
||||
assert_eq!(rig.click(b), vec![Action::Select { step: 2, modifiers: Modifiers::NONE }]);
|
||||
assert_eq!(rig.click_with(b, PointerButton::Primary, Modifiers::SHIFT), vec![Action::Select { step: 2, modifiers: Modifiers::SHIFT }]);
|
||||
assert_eq!(rig.frame(vec![Event::PointerMoved(b)]), Vec::new(), "a hover chooses nothing");
|
||||
assert_eq!(rig.hovered.get(), Some(2), "the step under the pointer is returned");
|
||||
assert_eq!(rig.frame(vec![Event::PointerMoved(pos2(790.0, 290.0))]), Vec::new());
|
||||
assert_eq!(rig.hovered.get(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_returns_edit_after_the_select_and_a_right_click_its_menu() {
|
||||
let rig = Rig::new(&["A", "B", "C"], 2);
|
||||
let c = rig.at("test:C");
|
||||
let mut actions = rig.click(c);
|
||||
actions.extend(rig.press(c, PointerButton::Primary, true, Modifiers::NONE));
|
||||
actions.extend(rig.press(c, PointerButton::Primary, false, Modifiers::NONE));
|
||||
let select = Action::Select { step: 3, modifiers: Modifiers::NONE };
|
||||
assert_eq!(actions, vec![select.clone(), select, Action::Edit(3)], "the second click selects again, then edits");
|
||||
assert_eq!(rig.click_with(c, PointerButton::Secondary, Modifiers::NONE), vec![Action::Menu { step: 3, at: c }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_step_returns_reorder_with_the_slot_and_side_of_the_marker() {
|
||||
let rig = Rig::new(&["A", "B", "C"], 2);
|
||||
let (a, c) = (rig.at("test:A"), rig.at("test:C"));
|
||||
// A..B, the marker, C; the end is one chip past C.
|
||||
let end = c + vec2(32.0 - 14.0 + 2.0, 0.0);
|
||||
assert_eq!(rig.drag(a, end), vec![Action::Reorder { step: 1, slot: 3, before: false }], "after C, past the marker");
|
||||
assert_eq!(rig.drag(c, a - vec2(12.0, 0.0)), vec![Action::Reorder { step: 3, slot: 0, before: true }], "before A");
|
||||
let marker = rig.marker();
|
||||
let left = marker - vec2(5.0, 0.0);
|
||||
let right = marker + vec2(5.0, 0.0);
|
||||
assert_eq!(rig.drag(a, left), vec![Action::Reorder { step: 1, slot: 2, before: true }], "the marker's slot, before it");
|
||||
assert_eq!(rig.drag(a, right), vec![Action::Reorder { step: 1, slot: 2, before: false }], "the marker's slot, after it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dragged_marker_returns_seek_and_a_drag_that_leaves_the_window_returns_nothing() {
|
||||
let rig = Rig::new(&["A", "B", "C"], 2);
|
||||
let (a, marker) = (rig.at("test:A"), rig.marker());
|
||||
assert_eq!(rig.drag(marker, a - vec2(12.0, 0.0)), vec![Action::Seek(Seek::Slot { slot: 0, before: true })]);
|
||||
let marker = rig.marker();
|
||||
assert_eq!(rig.drag(marker, marker), vec![Action::Seek(Seek::Slot { slot: 2, before: false })], "dropped where it is");
|
||||
// Released as the pointer leaves the window: the drag ends with no drop.
|
||||
let marker = rig.marker();
|
||||
rig.frame(vec![Event::PointerMoved(marker)]);
|
||||
rig.press(marker, PointerButton::Primary, true, Modifiers::NONE);
|
||||
rig.frame(vec![Event::PointerMoved(a)]);
|
||||
let gone = rig.frame_with(
|
||||
vec![Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, Event::PointerGone],
|
||||
Modifiers::NONE,
|
||||
);
|
||||
assert_eq!(gone, Vec::new());
|
||||
assert_eq!(rig.click(rig.at("test:A")), vec![Action::Select { step: 1, modifiers: Modifiers::NONE }], "nothing is still dragged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folded_chip_of_the_apps_own_takes_its_slots_and_the_marker_inside_it_goes_after_it() {
|
||||
let mut rig = Rig::new(&["A", "B", "C", "D"], 2);
|
||||
rig.folded = Some((1, 3));
|
||||
rig.frame(Vec::new());
|
||||
let (a, folded, d) = (rig.at("test:A"), rig.at("test:folded"), rig.at("test:D"));
|
||||
assert!(solidcraft_ui_registry::handle_at("test:B").is_none(), "folded steps have no chip");
|
||||
let marker = rig.marker();
|
||||
assert!(folded.x < marker.x && marker.x < d.x, "the marker inside a folded chip is drawn after it");
|
||||
assert_eq!(rig.drag(a, folded), vec![Action::Reorder { step: 1, slot: 1, before: true }], "a drop on a folded chip lands before it");
|
||||
assert_eq!(rig.drag(d, folded - vec2(20.0, 0.0)), vec![Action::Reorder { step: 4, slot: 1, before: true }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_controls_return_seek_and_play_and_their_menu() {
|
||||
let rig = Rig::new(&["A", "B"], 1);
|
||||
for (control, action) in [
|
||||
("test:ctl:first", Action::Seek(Seek::First)),
|
||||
("test:ctl:back", Action::Seek(Seek::Back)),
|
||||
("test:ctl:play", Action::Play),
|
||||
("test:ctl:forward", Action::Seek(Seek::Forward)),
|
||||
("test:ctl:last", Action::Seek(Seek::Last)),
|
||||
] {
|
||||
assert_eq!(rig.click(rig.at(control)), vec![action], "{control}");
|
||||
}
|
||||
let back = rig.at("test:ctl:back");
|
||||
assert_eq!(rig.click_with(back, PointerButton::Secondary, Modifiers::NONE), vec![Action::ControlsMenu { at: back }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_play_the_controls_close_up_and_a_narrow_bar_leaves_the_title_out() {
|
||||
let mut rig = Rig::new(&["A"], 1);
|
||||
rig.bar.play = false;
|
||||
rig.frame(Vec::new());
|
||||
assert!(solidcraft_ui_registry::handle_at("test:ctl:play").is_none());
|
||||
assert_eq!(rig.click(rig.at("test:ctl:forward")), vec![Action::Seek(Seek::Forward)]);
|
||||
let grip = rig.at("test:grip");
|
||||
rig.rect = Rect::from_min_size(rig.rect.min, vec2(100.0, 40.0));
|
||||
rig.frame(Vec::new());
|
||||
assert!(rig.at("test:grip").x < grip.x, "the grip alone, without the title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_bar_has_the_marker_at_its_start_and_returns_only_its_drop() {
|
||||
let rig = Rig::new(&[], 0);
|
||||
assert_eq!(rig.frame(Vec::new()), Vec::new());
|
||||
let marker = rig.marker();
|
||||
let first = rig.at("test:ctl:last");
|
||||
assert!(marker.x > first.x, "the marker after the controls");
|
||||
assert_eq!(rig.drag(marker, marker + vec2(80.0, 0.0)), vec![Action::Seek(Seek::Slot { slot: 0, before: false })]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_after_the_marker_dropped_at_the_marker_returns_the_side_it_is_dropped_on() {
|
||||
let rig = Rig::new(&["A", "B", "C"], 2);
|
||||
let c = rig.at("test:C");
|
||||
let marker = rig.marker();
|
||||
assert_eq!(rig.drag(c, marker - vec2(5.0, 0.0)), vec![Action::Reorder { step: 3, slot: 2, before: true }], "before it: built");
|
||||
let marker = rig.marker();
|
||||
assert_eq!(rig.drag(rig.at("test:C"), marker + vec2(5.0, 0.0)), vec![Action::Reorder { step: 3, slot: 2, before: false }], "after it");
|
||||
}
|
||||
|
||||
/// Every filled rect and circle a frame painted.
|
||||
fn painted(output: &egui::FullOutput) -> (Vec<egui::epaint::RectShape>, Vec<egui::epaint::CircleShape>) {
|
||||
let (mut rects, mut circles) = (Vec::new(), Vec::new());
|
||||
for clipped in &output.shapes {
|
||||
crate::test_support::each_shape(&clipped.shape, &mut |shape| match shape {
|
||||
egui::Shape::Rect(rect) => rects.push(rect.clone()),
|
||||
egui::Shape::Circle(circle) => circles.push(*circle),
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
(rects, circles)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steps_state_reaches_its_chip_and_the_floating_card_dots_the_last_done_step() {
|
||||
let t = Tokens::get();
|
||||
let mut rig = Rig::new(&["A", "B", "C", "D"], 4);
|
||||
let done = StepState { last_done: true, ..StepState::default() };
|
||||
rig.marks = vec![
|
||||
(StepState::default(), false),
|
||||
(StepState { highlighted: true, ..StepState::default() }, false),
|
||||
(StepState { rolled_back: true, ..StepState::default() }, false),
|
||||
(done, true),
|
||||
];
|
||||
rig.frame(Vec::new());
|
||||
let (_, output) = rig.output(Vec::new(), Modifiers::NONE);
|
||||
let (rects, circles) = painted(&output);
|
||||
let chip = |label: &str| {
|
||||
let centre = rig.at(&format!("test:{label}"));
|
||||
let rect = Rect::from_center_size(centre, vec2(28.0, 28.0));
|
||||
rects.iter().find(|r| r.rect == rect).cloned().unwrap_or_else(|| panic!("{label}'s chip is painted"))
|
||||
};
|
||||
let (plain, lit, rolled, failed) = (chip("A"), chip("B"), chip("C"), chip("D"));
|
||||
assert_eq!(plain.fill, t.timeline_item);
|
||||
assert_eq!((lit.fill, lit.stroke.color), (t.hover, rig.bar.accent.color), "highlighted: lit as though hovered");
|
||||
assert!(rolled.fill.a() < plain.fill.a(), "rolled back: faded");
|
||||
assert_eq!((failed.fill, failed.stroke.color), (t.form_error_fill, t.form_error), "an error marks the chip");
|
||||
let dot = |rig: &Rig| {
|
||||
let d = rig.at("test:D");
|
||||
let at = pos2(d.x, d.y + 14.0 + t.space_3);
|
||||
let (_, output) = rig.output(Vec::new(), Modifiers::NONE);
|
||||
painted(&output).1.iter().any(|c| c.center == at && c.fill == rig.bar.accent.color)
|
||||
};
|
||||
assert!(!circles.iter().any(|c| c.fill == rig.bar.accent.color && c.radius == t.space_2), "a docked bar draws no dot");
|
||||
assert!(!dot(&rig));
|
||||
rig.bar.floating = true;
|
||||
rig.frame(Vec::new());
|
||||
assert!(dot(&rig), "the floating card dots the last step before the marker");
|
||||
rig.marks[3] = (StepState::default(), true);
|
||||
rig.frame(Vec::new());
|
||||
assert!(!dot(&rig), "only that step");
|
||||
}
|
||||
@@ -71,6 +71,7 @@ pub mod frame_rate;
|
||||
pub mod gizmo;
|
||||
pub mod gpu;
|
||||
pub mod help;
|
||||
pub mod history_bar;
|
||||
mod history_folds;
|
||||
pub(crate) mod history_rows;
|
||||
#[cfg(test)]
|
||||
|
||||
+245
-367
@@ -1,8 +1,7 @@
|
||||
//! The timeline (bottom): features in order, the rollback marker, errors and warnings.
|
||||
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use crate::ui_kit::{Container, Edge, Status, Surface};
|
||||
use egui::{Rect, Sense, UiBuilder, pos2, vec2};
|
||||
use egui::{Rect, UiBuilder, pos2};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::doc::{
|
||||
FeatureKind, FeatureResult, PlaneRef, SketchId,
|
||||
@@ -13,6 +12,7 @@ use solidcraft_engine::doc::{
|
||||
use solidcraft_engine::schema::edit::PlaybackAction;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::history_bar;
|
||||
use crate::ui_kit::Item;
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
@@ -54,15 +54,33 @@ pub(crate) fn transport_availability(app: &SolveApp, control: crate::ui_kit::His
|
||||
|
||||
/// A click runs a History control; a right-click offers to show or hide the Play button.
|
||||
pub(crate) fn transport_response(app: &mut SolveApp, control: crate::ui_kit::HistoryTransport, response: &egui::Response) {
|
||||
if response.clicked() {
|
||||
run_transport(app, control);
|
||||
}
|
||||
if response.secondary_clicked() {
|
||||
let at = response.interact_pointer_pos().unwrap_or(response.rect.left_bottom());
|
||||
crate::context_menu::open_for(app, at, crate::context_menu::Target::HistoryControls);
|
||||
for action in history_bar::transport_actions(control, response) {
|
||||
run_control_action(app, action);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs what a History control asked for (the bar's other actions are its strip's).
|
||||
fn run_control_action(app: &mut SolveApp, action: history_bar::Action) {
|
||||
use crate::ui_kit::HistoryTransport;
|
||||
let control = match action {
|
||||
history_bar::Action::Seek(history_bar::Seek::First) => HistoryTransport::First,
|
||||
history_bar::Action::Seek(history_bar::Seek::Back) => HistoryTransport::Back,
|
||||
history_bar::Action::Play => HistoryTransport::Play,
|
||||
history_bar::Action::Seek(history_bar::Seek::Forward) => HistoryTransport::Forward,
|
||||
history_bar::Action::Seek(history_bar::Seek::Last) => HistoryTransport::Last,
|
||||
history_bar::Action::ControlsMenu { at } => {
|
||||
crate::context_menu::open_for(app, at, crate::context_menu::Target::HistoryControls);
|
||||
return;
|
||||
}
|
||||
history_bar::Action::Seek(history_bar::Seek::Slot { .. })
|
||||
| history_bar::Action::Select { .. }
|
||||
| history_bar::Action::Edit(_)
|
||||
| history_bar::Action::Menu { .. }
|
||||
| history_bar::Action::Reorder { .. } => return,
|
||||
};
|
||||
run_transport(app, control);
|
||||
}
|
||||
|
||||
/// The History controls' menu: shows or hides the Play button, and — when the design marks any
|
||||
/// component out of the bar — shows or hides those components' entries (#10393).
|
||||
pub(crate) fn transport_items(app: &SolveApp) -> Vec<crate::ui_kit::Item> {
|
||||
@@ -269,12 +287,6 @@ fn construction_plane_icon(plane: &PlaneRef) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Drag {
|
||||
Marker,
|
||||
Feature(u64),
|
||||
}
|
||||
|
||||
/// Scrolls the History bar to `rect` when it `holds` the feature to reveal. The request is done
|
||||
/// only once the rect is presented in the viewport (an offscreen or hidden bar asks again on the
|
||||
/// next frame); then it returns true and the caller clears `timeline_reveal`.
|
||||
@@ -286,18 +298,6 @@ fn reveal_once(ui: &egui::Ui, reveal: &mut Option<usize>, holds: bool, rect: Rec
|
||||
false
|
||||
}
|
||||
|
||||
/// Slot index (0..=n) nearest to screen x, given the left edges of the feature buttons.
|
||||
fn slot_at(xs: &[f32], end: f32, x: f32) -> usize {
|
||||
let mut best = (0usize, f32::INFINITY);
|
||||
for (i, sx) in xs.iter().chain(std::iter::once(&end)).enumerate() {
|
||||
let d = (sx - 2.0 - x).abs();
|
||||
if d < best.1 {
|
||||
best = (i, d);
|
||||
}
|
||||
}
|
||||
best.0
|
||||
}
|
||||
|
||||
/// Check what deleting `ids` takes with it or makes fail (off the window thread when that takes a
|
||||
/// while, #2678), then delete them or ask first.
|
||||
pub(crate) fn delete_features(app: &mut SolveApp, ids: Vec<u64>) {
|
||||
@@ -906,35 +906,37 @@ pub(crate) fn edit_holes_item(app: &mut SolveApp, ui: &mut egui::Ui, folder: u64
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) {
|
||||
/// Where the bottom History bar publishes its grip, marker and feature chips.
|
||||
const BAR_HANDLES: history_bar::Handles<'static> =
|
||||
history_bar::Handles { grip: "history:grip", marker: "history:marker", step: "timeline:", controls: "history:" };
|
||||
|
||||
/// A feature as the bar draws it: id, name, icon, suppressed, type name, kind.
|
||||
type BarFeature = (u64, String, &'static str, bool, &'static str, TimelineKind);
|
||||
|
||||
/// The bar as SolidCraft describes it; the strip sets the marker's slot and the step count.
|
||||
fn bar(app: &SolveApp, floating: bool) -> history_bar::Bar<'static> {
|
||||
let t = Tokens::get();
|
||||
let r = ui.max_rect();
|
||||
let p = ui.painter().clone();
|
||||
// Playback controls.
|
||||
let mut x = r.left() + t.space_10;
|
||||
let grip_rect = Rect::from_center_size(pos2(x + t.space_10 / 2.0, r.center().y), vec2(t.space_10, t.space_16 + t.space_2));
|
||||
let mut grip_ui = ui.new_child(UiBuilder::new().max_rect(grip_rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
let grip = crate::ui_kit::panel_grip(&mut grip_ui).rect;
|
||||
x += t.space_10 + t.space_8;
|
||||
let mut drag_rect = grip;
|
||||
let heading = crate::ui_kit::StripHeading::new(&p, solidcraft_ui_text::tr("ui.history_title"));
|
||||
if x + heading.width() + crate::ui_kit::history_transport_width() <= r.right() {
|
||||
let title_rect = heading.paint(&p, Rect::from_min_max(pos2(x, r.top()), r.right_bottom()));
|
||||
drag_rect = drag_rect.union(title_rect);
|
||||
x += heading.width();
|
||||
history_bar::Bar {
|
||||
title: solidcraft_ui_text::tr("ui.history_title"),
|
||||
marker_tip: solidcraft_ui_text::tr("ui.history_marker_tip"),
|
||||
play: app.preferences.history_play_button,
|
||||
playing: app.timeline_playback.is_some(),
|
||||
accent: solidcraft_theme::TabAccent::for_tab(&t, &app.ui.tab),
|
||||
drag_accent: solidcraft_theme::TabAccent::for_tab(&t, "SKETCH"),
|
||||
marker: 0,
|
||||
steps: 0,
|
||||
floating,
|
||||
handles: BAR_HANDLES,
|
||||
}
|
||||
let response = ui.interact(drag_rect, egui::Id::unique("sc_history_dock_grip"), Sense::drag());
|
||||
crate::scenario::publish_handle("history:grip", drag_rect.center());
|
||||
crate::timeline_left::dock_drag(app, ui, &response);
|
||||
let controls = crate::ui_kit::HistoryTransport::shown(app.preferences.history_play_button);
|
||||
for &control in controls {
|
||||
let br = Rect::from_center_size(pos2(x + t.space_24 / 2.0, r.center().y), vec2(t.space_24, t.space_24));
|
||||
let response = control.show(ui, br, app.timeline_playback.is_some());
|
||||
transport_response(app, control, &response);
|
||||
x += t.space_24 + t.space_2;
|
||||
}
|
||||
|
||||
fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) {
|
||||
let head = history_bar::header(ui, &bar(app, floating), ui.max_rect());
|
||||
crate::timeline_left::dock_drag(app, ui, &head.grip);
|
||||
for action in head.actions {
|
||||
run_control_action(app, action);
|
||||
}
|
||||
x += 14.0;
|
||||
timeline_strip(app, ui, floating, x);
|
||||
timeline_strip(app, ui, floating, head.x);
|
||||
}
|
||||
|
||||
/// The shared host already supplies the title and declared playback controls.
|
||||
@@ -950,6 +952,7 @@ fn timeline_strip(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool, x: f32)
|
||||
let p = ui.painter().clone();
|
||||
let n = app.session.doc.features.len();
|
||||
let marker = app.session.doc.marker.unwrap_or(n);
|
||||
let mut bar = bar(app, floating);
|
||||
// The feature that made what is under the cursor or picked in the viewport.
|
||||
let from_view = [hovered_feature(app), picked_feature(app)];
|
||||
// Long runs of one component's features show as one chip (`timeline_fold`). The bar shows the
|
||||
@@ -961,335 +964,210 @@ fn timeline_strip(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool, x: f32)
|
||||
// folded chip holding it, its component's own chip, or the feature itself. Read before the
|
||||
// path, whose click asks for it in what the next frame shows.
|
||||
let reveal_at = app.tree.timeline_reveal.and_then(|id| app.session.doc.features.iter().position(|f| f.id == id));
|
||||
let mut reveal = reveal_at.and_then(|i| history.view.find(i));
|
||||
let reveal = reveal_at.and_then(|i| history.view.find(i));
|
||||
let x = crate::timeline_scope::header(app, ui, &p, x, r).min(r.right() - t.space_24);
|
||||
// Below, slots and features count those the bar shows; the marker's slot among them.
|
||||
let shown = history.view.len();
|
||||
let marker_view = history.view.slot(marker);
|
||||
bar.marker = marker_view;
|
||||
bar.steps = shown;
|
||||
// The features scroll horizontally in the space right of the playback controls.
|
||||
let strip_rect = Rect::from_min_max(pos2(x, r.top()), r.right_bottom());
|
||||
ui.scope_builder(UiBuilder::new().max_rect(strip_rect), |ui| {
|
||||
// A plain mouse wheel scrolls the strip sideways too (#3356).
|
||||
ui.style_mut().always_scroll_the_only_direction = true;
|
||||
let scroll = egui::ScrollArea::horizontal().id_salt(("history_strip", history.within)).auto_shrink([false, false]);
|
||||
scroll.show(ui, |ui| {
|
||||
let p = ui.painter().clone();
|
||||
let mut x = ui.max_rect().left();
|
||||
let origin = x;
|
||||
let feats: Vec<_> = app
|
||||
.session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), TimelineKind::of(&f.kind)))
|
||||
.collect();
|
||||
// The bar's features in the order it draws them, folded ones included: a Shift+click
|
||||
// range runs over these.
|
||||
let order: Vec<u64> = history.view.indices(0, shown).iter().filter_map(|i| feats.get(*i).map(|f| f.0)).collect();
|
||||
let drag_id = ui.scope_id().with("tl_drag");
|
||||
let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id));
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut hovered: Option<u64> = None;
|
||||
let mut edit: Option<u64> = None;
|
||||
let mut marker_rect: Option<Rect> = None;
|
||||
// Each sketch's solve state, read once for the strip (#7245).
|
||||
let sketch_states = crate::sketch_state::SketchStates::of(app);
|
||||
// Each feature's result, found once for the strip: a search per chip made a long
|
||||
// timeline cost the square of its length every frame.
|
||||
let results = result_index(&app.session.model.results);
|
||||
// The features picked in the design or in the open dialog, collected once for the
|
||||
// strip: the dialog built its item list again for every chip.
|
||||
let feature_of = |selection: &solidcraft_engine::Sel| match selection {
|
||||
solidcraft_engine::Sel::Feature { id } => Some(*id),
|
||||
_ => None,
|
||||
let feats: Vec<BarFeature> = app
|
||||
.session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), TimelineKind::of(&f.kind)))
|
||||
.collect();
|
||||
// The bar's features in the order it draws them, folded ones included: a Shift+click
|
||||
// range runs over these.
|
||||
let order: Vec<u64> = history.view.indices(0, shown).iter().filter_map(|i| feats.get(*i).map(|f| f.0)).collect();
|
||||
let out = {
|
||||
let mut steps = BarSteps { app: &mut *app, history: &history, strip: &strip, feats: &feats, reveal, marker, marker_view };
|
||||
history_bar::strip(ui, &bar, r, strip_rect, ("history_strip", history.within), &mut steps)
|
||||
};
|
||||
app.viewport.hover_feature = out.hovered;
|
||||
// The design's position of a slot; at the marker's, on the side of the marker the pointer is
|
||||
// on (features not shown may sit between it and the next one shown).
|
||||
let position = |slot: usize, before: bool| {
|
||||
let at = history.view.index(slot);
|
||||
match (slot == marker_view, before) {
|
||||
(true, true) => at.min(marker),
|
||||
(true, false) => at.max(marker),
|
||||
(false, _) => at,
|
||||
}
|
||||
};
|
||||
for action in out.actions {
|
||||
match action {
|
||||
history_bar::Action::Select { step, modifiers } => click_feature(app, &order, step, modifiers),
|
||||
history_bar::Action::Edit(id) => app.edit_feature(id),
|
||||
// The same menu as the browser's, so the control channel reports it (#10745).
|
||||
history_bar::Action::Menu { step, at } => crate::context_menu::open_for(app, at, crate::context_menu::Target::Timeline { feature: step }),
|
||||
history_bar::Action::Seek(history_bar::Seek::Slot { slot, before }) => {
|
||||
let slot = position(slot, before);
|
||||
let _ = app.run_heavy("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
|
||||
}
|
||||
// Crossing the marker rolls the feature back or forward, in the same step.
|
||||
history_bar::Action::Reorder { step, slot, before } => {
|
||||
crate::timeline_folders::drop_feature(app, &history.folders, step, position(slot, before), before, marker.min(n));
|
||||
}
|
||||
control => run_control_action(app, control),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a feature chip: into the open dialog's input when it takes features, else a
|
||||
/// selection by the modifiers held.
|
||||
fn click_feature(app: &mut SolveApp, order: &[u64], id: u64, modifiers: egui::Modifiers) {
|
||||
if pick_feature_into_dialog(app, id, modifiers) {
|
||||
return;
|
||||
}
|
||||
if modifiers.shift {
|
||||
select_range(app, order, id, modifiers.command || modifiers.ctrl);
|
||||
} else if modifiers.command || modifiers.ctrl {
|
||||
toggle_feature(app, id);
|
||||
} else if !modifiers.any() && crate::browser::pick_sketch_into_dialog(app, id) {
|
||||
// Extrude, Revolve or Emboss took every profile of the sketch (#6106).
|
||||
} else {
|
||||
select_one(app, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// SolidCraft's entries of the bar: its features, and its folded runs, folders, joints and trays.
|
||||
struct BarSteps<'a> {
|
||||
app: &'a mut SolveApp,
|
||||
history: &'a crate::timeline_fold::History,
|
||||
strip: &'a crate::timeline_fold::Strip,
|
||||
feats: &'a [BarFeature],
|
||||
/// The slot to scroll into view, until it is.
|
||||
reveal: Option<usize>,
|
||||
/// The marker's design position, and its slot among the features shown.
|
||||
marker: usize,
|
||||
marker_view: usize,
|
||||
}
|
||||
|
||||
impl history_bar::Contents for BarSteps<'_> {
|
||||
fn entries(&mut self, s: &mut history_bar::Strip<'_, '_>) {
|
||||
let t = Tokens::get();
|
||||
let app = &mut *self.app;
|
||||
let (history, strip) = (self.history, self.strip);
|
||||
// Each sketch's solve state, read once for the strip (#7245).
|
||||
let sketch_states = crate::sketch_state::SketchStates::of(app);
|
||||
// Each feature's result, found once for the strip: a search per chip made a long
|
||||
// timeline cost the square of its length every frame.
|
||||
let results = result_index(&app.session.model.results);
|
||||
// The features picked in the design or in the open dialog, collected once for the
|
||||
// strip: the dialog built its item list again for every chip.
|
||||
let feature_of = |selection: &solidcraft_engine::Sel| match selection {
|
||||
solidcraft_engine::Sel::Feature { id } => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let picked: std::collections::HashSet<u64> = app
|
||||
.session
|
||||
.selection
|
||||
.iter()
|
||||
.filter_map(feature_of)
|
||||
.chain(app.dialog.as_ref().map(|dialog| dialog.items()).unwrap_or_default().iter().filter_map(feature_of))
|
||||
.collect();
|
||||
let editing = crate::timeline_scope::focus(app).unwrap_or(0);
|
||||
for entry in history.entries.iter().copied() {
|
||||
let i = match entry {
|
||||
crate::timeline_fold::Entry::Feature(i) => i,
|
||||
crate::timeline_fold::Entry::Group(group) => {
|
||||
let rect = s.chip(group.start, group.end, group.open, group.depth, |ui, p, at| {
|
||||
crate::timeline_fold::group_chip(app, ui, p, at, &group, history, strip)
|
||||
});
|
||||
let holds = self.reveal.is_some_and(|k| group.start <= k && k < group.end && (!group.open || group.component == editing));
|
||||
if reveal_once(s.ui(), &mut self.reveal, holds, rect, egui::Align::Min) {
|
||||
app.tree.timeline_reveal = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
crate::timeline_fold::Entry::Folder(folder) => {
|
||||
let rect = s.chip(folder.start, folder.end, folder.open, folder.depth, |ui, p, at| {
|
||||
crate::timeline_folders::folder_chip(app, ui, p, at, &folder, history, strip)
|
||||
});
|
||||
let holds = self.reveal.is_some_and(|k| folder.start <= k && k < folder.end && !folder.open);
|
||||
if reveal_once(s.ui(), &mut self.reveal, holds, rect, egui::Align::Min) {
|
||||
app.tree.timeline_reveal = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
crate::timeline_fold::Entry::Joints(folder) => {
|
||||
let joints = history.folders.joints.get(&folder).cloned().unwrap_or_default();
|
||||
let run = crate::dialogs_assembly::JointRun::Folder { id: folder, name: history.folders.name(folder) };
|
||||
s.inline(|ui, p, x, r| crate::dialogs_assembly::joint_chips(app, ui, p, x, r, &joints, run));
|
||||
continue;
|
||||
}
|
||||
crate::timeline_fold::Entry::End(_) => {
|
||||
s.end_tray();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let picked: std::collections::HashSet<u64> = app
|
||||
.session
|
||||
.selection
|
||||
.iter()
|
||||
.filter_map(feature_of)
|
||||
.chain(app.dialog.as_ref().map(|dialog| dialog.items()).unwrap_or_default().iter().filter_map(feature_of))
|
||||
.collect();
|
||||
// Each open run or folder's tray: where it starts, its place under the chips, its depth.
|
||||
let mut trays: Vec<(f32, egui::layers::ShapeIdx, usize)> = Vec::new();
|
||||
let pad = crate::ui_kit::HISTORY_TRAY_PAD;
|
||||
let marker_at = |x: f32| Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(t.space_12, r.height() - 10.0));
|
||||
let editing = crate::timeline_scope::focus(app).unwrap_or(0);
|
||||
for entry in history.entries.iter().copied() {
|
||||
let i = match entry {
|
||||
crate::timeline_fold::Entry::Feature(i) => i,
|
||||
crate::timeline_fold::Entry::Group(group) => {
|
||||
if group.start == marker_view && marker_rect.is_none() {
|
||||
marker_rect = Some(marker_at(x));
|
||||
x += t.space_16;
|
||||
}
|
||||
if group.open {
|
||||
trays.push((x, p.add(egui::Shape::Noop), group.depth));
|
||||
x += pad;
|
||||
}
|
||||
let at = pos2(x, r.top() + 6.0);
|
||||
let width = crate::timeline_fold::group_chip(app, ui, &p, at, &group, &history, &strip);
|
||||
let holds = reveal.is_some_and(|k| group.start <= k && k < group.end && (!group.open || group.component == editing));
|
||||
if reveal_once(ui, &mut reveal, holds, Rect::from_min_size(at, vec2(width, 28.0)), egui::Align::Min) {
|
||||
app.tree.timeline_reveal = None;
|
||||
}
|
||||
if !group.open {
|
||||
// Dropping on a folded run puts the feature before it.
|
||||
xs.extend(std::iter::repeat_n(x, group.end - group.start));
|
||||
}
|
||||
x += width + 4.0;
|
||||
if !group.open && group.start < marker_view && marker_view < group.end && marker_rect.is_none() {
|
||||
marker_rect = Some(marker_at(x));
|
||||
x += t.space_16;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
crate::timeline_fold::Entry::Folder(folder) => {
|
||||
let holds = folder.start < folder.end;
|
||||
if holds && folder.start == marker_view && marker_rect.is_none() {
|
||||
marker_rect = Some(marker_at(x));
|
||||
x += t.space_16;
|
||||
}
|
||||
if folder.open {
|
||||
trays.push((x, p.add(egui::Shape::Noop), folder.depth));
|
||||
x += pad;
|
||||
}
|
||||
let at = pos2(x, r.top() + 6.0);
|
||||
let width = crate::timeline_folders::folder_chip(app, ui, &p, at, &folder, &history, &strip);
|
||||
let holds = reveal.is_some_and(|k| folder.start <= k && k < folder.end && !folder.open);
|
||||
if reveal_once(ui, &mut reveal, holds, Rect::from_min_size(at, vec2(width, 28.0)), egui::Align::Min) {
|
||||
app.tree.timeline_reveal = None;
|
||||
}
|
||||
if !folder.open {
|
||||
// Dropping on a folded folder puts the feature before it.
|
||||
xs.extend(std::iter::repeat_n(x, folder.end - folder.start));
|
||||
}
|
||||
x += width + 4.0;
|
||||
if !folder.open && folder.start < marker_view && marker_view < folder.end && marker_rect.is_none() {
|
||||
marker_rect = Some(marker_at(x));
|
||||
x += t.space_16;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
crate::timeline_fold::Entry::Joints(folder) => {
|
||||
let joints = history.folders.joints.get(&folder).cloned().unwrap_or_default();
|
||||
let run = crate::dialogs_assembly::JointRun::Folder { id: folder, name: history.folders.name(folder) };
|
||||
x = crate::dialogs_assembly::joint_chips(app, ui, &p, x, r, &joints, run);
|
||||
continue;
|
||||
}
|
||||
crate::timeline_fold::Entry::End(_) => {
|
||||
if let Some((left, tray, depth)) = trays.pop() {
|
||||
let right = x - 4.0 + pad;
|
||||
p.set(tray, crate::ui_kit::history_tray(left, right, r.top() + 6.0, depth, accent));
|
||||
x = right + 4.0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The feature's index in the design.
|
||||
let at = history.view.index(i);
|
||||
let Some((id, name, icon, suppressed, ty, kind)) = feats.get(at) else {
|
||||
continue;
|
||||
};
|
||||
let sketch_state = matches!(kind, TimelineKind::Sketch).then(|| sketch_states.get(*id)).flatten();
|
||||
if i == marker_view && marker_rect.is_none() {
|
||||
marker_rect = Some(marker_at(x));
|
||||
x += t.space_16;
|
||||
}
|
||||
xs.push(x);
|
||||
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0));
|
||||
let holds = reveal == Some(i);
|
||||
if reveal_once(ui, &mut reveal, holds, br, egui::Align::Center) {
|
||||
app.tree.timeline_reveal = None;
|
||||
}
|
||||
crate::scenario::publish_handle(&format!("timeline:{name}"), br.center());
|
||||
// A chip scrolled out of the strip is neither drawn nor interactive: a long
|
||||
// history drew every chip each frame. Its slot above still places the marker and drops.
|
||||
// One being dragged or focused, or under an open menu, stays alive until that ends.
|
||||
let chip_id = ui.scope_id().with(("feat", *id));
|
||||
if !ui.clip_rect().intersects(br)
|
||||
&& drag != Some(Drag::Feature(*id))
|
||||
&& !ui.memory(|m| m.has_focus(chip_id))
|
||||
&& !egui::Popup::is_any_open(ui.ctx())
|
||||
{
|
||||
x += 32.0;
|
||||
continue;
|
||||
}
|
||||
let resp = ui.interact(br, chip_id, Sense::click_and_drag());
|
||||
// A feature without a result has none; an index that no longer holds this id (a
|
||||
// command run from an earlier chip evaluated again) is searched for.
|
||||
let res =
|
||||
results.get(id).and_then(|k| app.session.model.results.get(*k).filter(|r| r.id == *id).or_else(|| app.session.model.result(*id)));
|
||||
let err = res.and_then(|r| r.error.clone());
|
||||
let warn = res.and_then(warning_text);
|
||||
let rolled = i >= marker_view;
|
||||
let sel = picked.contains(id);
|
||||
if resp.hovered() {
|
||||
hovered = Some(*id);
|
||||
}
|
||||
crate::ui_kit::HistoryChip {
|
||||
icon,
|
||||
name,
|
||||
kind: ty,
|
||||
selected: sel,
|
||||
hovered: resp.hovered() || from_view.contains(&Some(*id)),
|
||||
dim: rolled,
|
||||
suppressed: *suppressed,
|
||||
error: err.is_some(),
|
||||
warning: warn.is_some(),
|
||||
reference: false,
|
||||
ink: sketch_state.and_then(|state| state.ink(&t)),
|
||||
// A dragged feature takes the colour of its drop bar.
|
||||
accent: if drag == Some(Drag::Feature(*id)) { solidcraft_theme::TabAccent::for_tab(&t, "SKETCH") } else { accent },
|
||||
}
|
||||
.paint(&p, br, crate::ui_kit::HistoryStyle::Bottom);
|
||||
if floating && at + 1 == marker {
|
||||
p.circle_filled(pos2(br.center().x, br.bottom() + t.space_3), t.space_2, accent.color);
|
||||
}
|
||||
let ms = res.map(|r| r.ms);
|
||||
if resp.drag_started() {
|
||||
drag = Some(Drag::Feature(*id));
|
||||
}
|
||||
// The tip is written only while it shows (as `on_hover_text` would lay it out).
|
||||
let resp = resp.backing_on_hover_ui(|ui| {
|
||||
let mut tip = feature_status_text(name, ty, res);
|
||||
// Greyed out says only "not shown": say why (#8100).
|
||||
if rolled {
|
||||
tip.push('\n');
|
||||
tip.push_str(solidcraft_ui_text::tr("timeline.feature_rolled_back"));
|
||||
}
|
||||
if let Some(state) = sketch_state {
|
||||
tip.push('\n');
|
||||
tip.push_str(&state.text());
|
||||
}
|
||||
if let Some(ms) = ms {
|
||||
tip += &format!("\n{ms:.1} ms");
|
||||
}
|
||||
// The feature's index in the design.
|
||||
let at = history.view.index(i);
|
||||
let Some((id, name, icon, suppressed, ty, kind)) = self.feats.get(at) else {
|
||||
continue;
|
||||
};
|
||||
let sketch_state = matches!(kind, TimelineKind::Sketch).then(|| sketch_states.get(*id)).flatten();
|
||||
// A feature without a result has none; an index that no longer holds this id is
|
||||
// searched for.
|
||||
let res =
|
||||
results.get(id).and_then(|k| app.session.model.results.get(*k).filter(|r| r.id == *id).or_else(|| app.session.model.result(*id)));
|
||||
let rolled = i >= self.marker_view;
|
||||
let tip = || {
|
||||
let mut tip = feature_status_text(name, ty, res);
|
||||
// Greyed out says only "not shown": say why (#8100).
|
||||
if rolled {
|
||||
tip.push('\n');
|
||||
tip.push_str(solidcraft_ui_text::tr("timeline.feature_hint"));
|
||||
ui.set_max_width(ui.spacing().tooltip_width);
|
||||
ui.add(egui::Label::new(tip));
|
||||
});
|
||||
if resp.clicked() && !pick_feature_into_dialog(app, *id, ui.input(|i| i.modifiers)) {
|
||||
let modifiers = ui.input(|i| i.modifiers);
|
||||
if modifiers.shift {
|
||||
select_range(app, &order, *id, modifiers.command || modifiers.ctrl);
|
||||
} else if modifiers.command || modifiers.ctrl {
|
||||
toggle_feature(app, *id);
|
||||
} else if !modifiers.any() && crate::browser::pick_sketch_into_dialog(app, *id) {
|
||||
// Extrude, Revolve or Emboss took every profile of the sketch (#6106).
|
||||
} else {
|
||||
select_one(app, *id);
|
||||
}
|
||||
tip.push_str(solidcraft_ui_text::tr("timeline.feature_rolled_back"));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
edit = Some(*id);
|
||||
if let Some(state) = sketch_state {
|
||||
tip.push('\n');
|
||||
tip.push_str(&state.text());
|
||||
}
|
||||
// The same menu as the browser's, so the control channel reports it (#10745).
|
||||
if resp.secondary_clicked() {
|
||||
let at = resp.interact_pointer_pos().unwrap_or(br.center());
|
||||
crate::context_menu::open_for(app, at, crate::context_menu::Target::Timeline { feature: *id });
|
||||
if let Some(ms) = res.map(|r| r.ms) {
|
||||
tip += &format!("\n{ms:.1} ms");
|
||||
}
|
||||
x += 32.0;
|
||||
}
|
||||
let end = x;
|
||||
if marker_view >= shown && marker_rect.is_none() {
|
||||
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(t.space_12, r.height() - 10.0)));
|
||||
}
|
||||
// The history marker: drag it to roll the model back or forward.
|
||||
if let Some(mr) = marker_rect {
|
||||
crate::scenario::publish_rect("history:marker", mr);
|
||||
let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.scope_id().with("tl_marker"), Sense::drag());
|
||||
if resp.drag_started() {
|
||||
drag = Some(Drag::Marker);
|
||||
}
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
crate::ui_kit::history_marker(&p, mr, false, accent);
|
||||
resp.backing_on_hover_text(solidcraft_ui_text::tr("ui.history_marker_tip"));
|
||||
}
|
||||
// The joints at the end belong to the whole design: a bar with its groups that shows one
|
||||
// component leaves them out. Without the groups they stay, as they always have.
|
||||
let content_end = if history.within.is_none() || !app.preferences.history_bar_groups {
|
||||
crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r, &history.folders.held)
|
||||
} else {
|
||||
end
|
||||
tip.push('\n');
|
||||
tip.push_str(solidcraft_ui_text::tr("timeline.feature_hint"));
|
||||
tip
|
||||
};
|
||||
// The scroll range follows the drawn content (the buttons don't take layout space themselves).
|
||||
ui.allocate_exact_size(vec2((content_end - origin).max(0.0), r.height()), Sense::hover());
|
||||
// Drag feedback, and the drop.
|
||||
if drag.is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
// At the marker's slot, which side of the marker the pointer is on: before it, a
|
||||
// dropped feature stays in the model; after it, the feature is rolled back.
|
||||
let before_marker = |slot: usize, x: f32| slot < marker_view || (slot == marker_view && marker_rect.is_none_or(|mr| x < mr.center().x));
|
||||
// The design's position of a slot; at the marker's, on the side of the marker the
|
||||
// pointer is on (features not shown may sit between it and the next one shown).
|
||||
let position = |slot: usize, before: bool| {
|
||||
let at = history.view.index(slot);
|
||||
match (slot == marker_view, before) {
|
||||
(true, true) => at.min(marker),
|
||||
(true, false) => at.max(marker),
|
||||
(false, _) => at,
|
||||
}
|
||||
};
|
||||
// The marker dropped where it already is needs no bar: the marker shows the spot.
|
||||
if let (Some(dg), Some(pp)) = (drag.as_ref(), pointer)
|
||||
&& let local = slot_at(&xs, end, pp.x)
|
||||
&& !(matches!(dg, Drag::Marker) && local == marker_view)
|
||||
{
|
||||
// Centred in the gap between two chips, or beside the marker at its slot.
|
||||
let sx = match marker_rect {
|
||||
Some(mr) if local == marker_view => {
|
||||
if before_marker(local, pp.x) {
|
||||
mr.left() - 2.0
|
||||
} else {
|
||||
mr.right() + 2.0
|
||||
}
|
||||
}
|
||||
_ => xs.get(local).copied().unwrap_or(end) - 2.0,
|
||||
};
|
||||
// Kept inside the strip so the slot before the first feature stays visible.
|
||||
let sx = sx.max(origin + t.space_2);
|
||||
crate::ui_kit::history_drop_bar(
|
||||
&p,
|
||||
Rect::from_min_max(pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)),
|
||||
false,
|
||||
if matches!(dg, Drag::Marker) { accent.color } else { t.sketch_accent },
|
||||
);
|
||||
}
|
||||
let released = ui.input(|i| i.pointer.any_released());
|
||||
// (Taking the drag ends it even when the pointer has left the window.)
|
||||
if released
|
||||
&& let Some(dg) = drag.take()
|
||||
&& let Some(pp) = pointer
|
||||
{
|
||||
let local = slot_at(&xs, end, pp.x);
|
||||
let before = before_marker(local, pp.x);
|
||||
let slot = position(local, before);
|
||||
match dg {
|
||||
Drag::Marker => {
|
||||
let _ = app.run_heavy("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
|
||||
}
|
||||
// Crossing the marker rolls the feature back or forward, in the same step.
|
||||
Drag::Feature(id) => crate::timeline_folders::drop_feature(app, &history.folders, id, slot, before, marker.min(n)),
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| match drag {
|
||||
Some(dg) => {
|
||||
d.insert_temp(drag_id, dg.clone());
|
||||
}
|
||||
None => d.remove::<Drag>(drag_id),
|
||||
let rect = s.step(&history_bar::Step {
|
||||
id: *id,
|
||||
slot: i,
|
||||
label: name,
|
||||
icon,
|
||||
icon_accent: crate::ui_kit::feature_icon_accent(ty, icon),
|
||||
state: history_bar::StepState {
|
||||
selected: picked.contains(id),
|
||||
highlighted: strip.from_view.contains(&Some(*id)),
|
||||
rolled_back: rolled,
|
||||
suppressed: *suppressed,
|
||||
last_done: at + 1 == self.marker,
|
||||
},
|
||||
error: res.is_some_and(|r| r.error.is_some()),
|
||||
warning: res.and_then(warning_text).is_some(),
|
||||
ink: sketch_state.and_then(|state| state.ink(&t)),
|
||||
tip: &tip,
|
||||
});
|
||||
app.viewport.hover_feature = hovered;
|
||||
if let Some(id) = edit {
|
||||
app.edit_feature(id);
|
||||
let holds = self.reveal == Some(i);
|
||||
if reveal_once(s.ui(), &mut self.reveal, holds, rect, egui::Align::Center) {
|
||||
app.tree.timeline_reveal = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The joints at the end belong to the whole design: a bar with its groups that shows one
|
||||
/// component leaves them out. Without the groups they stay, as they always have.
|
||||
fn trailing(&mut self, ui: &mut egui::Ui, p: &egui::Painter, end: f32, r: Rect) -> f32 {
|
||||
if self.history.within.is_none() || !self.app.preferences.history_bar_groups {
|
||||
crate::dialogs_assembly::timeline_joints(self.app, ui, p, end + 10.0, r, &self.history.folders.held)
|
||||
} else {
|
||||
end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select what a feature made, so the browser shows it. While the browser's rows start closed
|
||||
|
||||
@@ -73,7 +73,7 @@ pub(crate) struct HistoryChip<'a> {
|
||||
|
||||
/// Share the feature classification across chips and name rows. Surface aliases also
|
||||
/// cover Patch/Stitch/Thicken and other surface features without a Surface-prefixed kind.
|
||||
fn feature_icon_accent(kind: &str, icon: &str) -> Option<&'static str> {
|
||||
pub(crate) fn feature_icon_accent(kind: &str, icon: &str) -> Option<&'static str> {
|
||||
let tab = if kind.starts_with("Sketch") {
|
||||
"SKETCH"
|
||||
} else if kind.starts_with("Surface") || icon.starts_with("surface_") || icon == "continuity" {
|
||||
@@ -89,8 +89,15 @@ fn feature_icon_accent(kind: &str, icon: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
impl HistoryChip<'_> {
|
||||
/// All strip variants use this painter; no connections are drawn between features.
|
||||
/// All strip variants use this painter; no connections are drawn between features. The
|
||||
/// icon takes the accent of the feature's kind ([`feature_icon_accent`]).
|
||||
pub fn paint(&self, painter: &Painter, rect: Rect, style: HistoryStyle) {
|
||||
self.paint_with_accent(painter, rect, style, feature_icon_accent(self.kind, self.icon));
|
||||
}
|
||||
|
||||
/// [`HistoryChip::paint`] with the icon's accent given (an icon palette name; `None`: its
|
||||
/// artwork's own colours).
|
||||
pub fn paint_with_accent(&self, painter: &Painter, rect: Rect, style: HistoryStyle, icon_accent: Option<&str>) {
|
||||
let t = Tokens::get();
|
||||
let mut p = painter.clone();
|
||||
if self.dim || self.suppressed {
|
||||
@@ -139,7 +146,7 @@ impl HistoryChip<'_> {
|
||||
Rect::from_center_size(if names { pos2(rect.left() + t.space_8 + side / 2.0, rect.center().y) } else { rect.center() }, vec2(side, side));
|
||||
match self.ink {
|
||||
Some(ink) => icons::paint_ink(&p, icon, self.icon, ink),
|
||||
None => icons::paint_accented(&p, icon, self.icon, feature_icon_accent(self.kind, self.icon), 1.0),
|
||||
None => icons::paint_accented(&p, icon, self.icon, icon_accent, 1.0),
|
||||
}
|
||||
if names {
|
||||
let ink = if self.selected {
|
||||
@@ -233,11 +240,18 @@ impl HistoryTransport {
|
||||
})
|
||||
}
|
||||
|
||||
/// `playing` turns the Play button into Stop; the other controls ignore it.
|
||||
/// `playing` turns the Play button into Stop; the other controls ignore it. Published as
|
||||
/// `history:` and its [`HistoryTransport::id`].
|
||||
pub fn show(self, ui: &mut egui::Ui, rect: Rect, playing: bool) -> egui::Response {
|
||||
self.show_as(ui, rect, playing, "history:")
|
||||
}
|
||||
|
||||
/// [`HistoryTransport::show`], published in the widget registry as `prefix` followed by its
|
||||
/// [`HistoryTransport::id`].
|
||||
pub fn show_as(self, ui: &mut egui::Ui, rect: Rect, playing: bool, prefix: &str) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let response = ui.interact(rect, ui.scope_id().with(("history_transport", self.id())), egui::Sense::click());
|
||||
solidcraft_ui_registry::publish_target(&format!("history:{}", self.id()), rect);
|
||||
solidcraft_ui_registry::publish_target(&format!("{prefix}{}", self.id()), rect);
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(rect, t.radius_sm, t.hover);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user