Compare commits

..

1 Commits

Author SHA1 Message Date
PeppaWutz c18aaf7cc3 Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508) (#12601)
* Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508)

sketch.insert_dxf, sketch.insert_svg and sketch.export_dxf now publish typed
input and output schemas. Each parameter is read the way its handler reads
it today and refused with the handler's own message, and the replies are
built from the same types the output schemas describe, so the JSON they
return does not change. Keys the handler reads only in some cases (a path
next to text, a height next to a width, a scale next to either) are read by
the check in the same cases only. Their lines leave unconverted.txt.

Part of #2829.

* Insert DXF and SVG: the schema refuses a blank or too-long path when it is read

* Insert DXF and SVG: the schema checks height, scale and path only when they are read

The published schema constrained `height` next to a `width`, `scale` next to
either, and `path` next to a `text`, which the handler never reads. Their shapes
now apply only in the cases the handler reads them, matching `leave_unread`.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 02:38:51 +00:00
136 changed files with 2246 additions and 2222 deletions
Generated
-8
View File
@@ -4366,7 +4366,6 @@ dependencies = [
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-ui-registry",
"syn 2.0.119",
"sys-locale",
"tiny-skia",
@@ -4378,13 +4377,6 @@ dependencies = [
"zip",
]
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.3"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.3"
-1
View File
@@ -28,7 +28,6 @@ solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-ui-registry = { path = "crates/ui-registry" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" }
+33 -10
View File
@@ -6,29 +6,48 @@ use std::collections::HashMap;
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
use super::CommandSpec;
use super::sketch::{edit, ids_of};
use super::sketch::{Left, edit_solved, ids_of};
use super::sketch_store;
use crate::params::{bad, num, str_, vec2};
use crate::schema::sketch_import::{DrawingInserted, DxfExported, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams, SketchSolve};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const INSERT_DXF_CONTRACT: TypedContract<InsertDxfParams> = TypedContract::new();
const INSERT_SVG_CONTRACT: TypedContract<InsertSvgParams> = TypedContract::new();
const EXPORT_DXF_CONTRACT: TypedContract<ExportDxfParams> = TypedContract::new();
/// A command's reply as JSON, built from the type its output schema describes so the two can't
/// drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
CommandSpec::new("sketch.insert_dxf", "Insert DXF", crate::checked!("sketch.insert_dxf", insert_dxf))
.at("SKETCH", "INSERT")
.icon("sketch_dxf")
.contract(&INSERT_DXF_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.insert_svg", insert_svg))
.at("SKETCH", "INSERT")
.icon("svg")
.contract(&INSERT_SVG_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.drawing_bounds", "Drawing Size", drawing_bounds)
.icon("svg")
.noundo()
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height in the file's own size"),
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
CommandSpec::new("sketch.export_dxf", "Save As DXF", crate::checked!("sketch.export_dxf", export_dxf))
.icon("sketch_dxf")
.noundo()
.contract(&EXPORT_DXF_CONTRACT)
.output(output_schema_for::<ExportDxfResult>)
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned); units?: \"design\" | \"in\" | \"inch\" | \"mm\" | \"cm\" | \"m\" | \"ft\" (default mm); layer?: profile layer name (default \"0\"); layers?: \"single\" | \"roles\" (default \"single\": all on `layer`; \"roles\": outer on \"OUTLINE\", holes on \"CUT\"); join?: bool (default false: R12 LINE/ARC; true: closed loops as LWPOLYLINE with arc bulges)"),
];
@@ -251,10 +270,13 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if geom.len() > solidcraft_sketch::MAX_POINTS {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
// `sketch::edit`, but keeping the solve as a typed value for the reply.
let target = sketch_store::target(s, p, cmd)?;
let ((curves, texts), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
Ok((ids_of(sk, &cs), texts))
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
})?;
let info = report.map(|rep| SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof });
// Text becomes sketch text.
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch"))?;
let mut n_text = 0;
@@ -279,7 +301,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
}
}
}
Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info}))
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
}
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
@@ -522,9 +544,10 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "entities": entity_count}));
let exported = DxfExported::Written { path: path.to_string(), entities: entity_count };
return reply(cmd, ExportDxfResult { exported, param_warnings: Vec::new() });
}
Ok(json!({"dxf": text, "entities": entity_count}))
reply(cmd, ExportDxfResult { exported: DxfExported::Text { dxf: text, entities: entity_count }, param_warnings: Vec::new() })
}
#[cfg(test)]
+1
View File
@@ -110,6 +110,7 @@ pub mod sketch_constraints;
pub mod sketch_create;
pub mod sketch_ends;
pub mod sketch_equation;
pub mod sketch_import;
pub mod sketch_inspect;
pub mod sketch_modify;
pub mod sketch_project;
+448
View File
@@ -0,0 +1,448 @@
//! Contracts and result types for the commands in `cmd/sketch_import.rs`: `sketch.insert_dxf`,
//! `sketch.insert_svg` and `sketch.export_dxf`.
//!
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
//! reads it and fails with the handler's message. Whether a file can be read or written, a sketch
//! exists or a drawing has anything to import stays the handler's to check. A call with several
//! problems may get a different one first in strict than with the check off (the check goes key
//! by key, the handlers in their own order).
//!
//! Two insert keys are read only in some cases, and the contract reads them in the same cases:
//! `path` only when `text` is not given, and of `width`, `height` and `scale` only the first one
//! given, in that order (a `height` next to a `width`, or a `scale` next to either, is never read).
//! A value of the wrong type that the handler reads as if the key were left out (`text: 5` next
//! to a good `path`, a text for `scale`) is refused by the contract, as schemas.md has it: that
//! difference is what warn and strict report.
//! `plane` and `name` are read only when no sketch is being edited, which the check can't see
//! (it has no session), so they take any value.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use solidcraft_io::MAX_DRAWING_BYTES;
use super::check::{located, whole};
use super::sketch_ends::SketchTarget;
use super::types::{Point2, not_blank_pattern};
/// A key that, once given (`null` included), must parse as `T`, as the handler reads it.
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
T::deserialize(d).map(Some)
}
/// The keys the two insert commands read. See [`InsertDxfParams`] and [`InsertSvgParams`], which
/// leave `path`, `height` and `scale` unread in the cases the handler does.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = source_required)]
pub struct InsertDrawingKeys {
/// The drawing itself, as text. Wins over `path`. At most 16 MiB (`MAX_DRAWING_BYTES`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<DrawingText>,
/// The drawing file to read (not blank, fewer than 4096 UTF-8 bytes). Used when `text` is
/// not given; ignored when it is.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "path_schema")]
pub path: Option<DrawingPath>,
/// An offset, in mm, [x, y]. Default: [0, 0]. With `align` "origin" it is where the file's
/// origin goes; with "center", where the drawing's middle goes.
#[serde(default)]
pub at: Option<Point2>,
/// A size factor, 1e-9 to 1e6 (a value outside is read as 1). Default: 1. Used only when
/// neither `width` nor `height` is given.
#[serde(default)]
pub scale: Option<f64>,
/// The drawing's width in mm after sizing, before rotation; the aspect ratio is kept. Wins
/// over `height` and `scale`. The drawing must have a width.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PositiveMm")]
pub width: Option<PositiveMm>,
/// The drawing's height in mm after sizing, before rotation; the aspect ratio is kept. Wins
/// over `scale`; ignored when `width` is given. The drawing must have a height.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PositiveMm")]
pub height: Option<PositiveMm>,
/// A turn in degrees, about the drawing's origin (with `align` "center": about its middle).
/// Default: 0.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "AngleDegrees")]
pub angle: Option<AngleDegrees>,
/// What `at` places: the file's origin ("origin") or the drawing's middle ("center", which it
/// then also turns and scales about). Default: origin.
#[serde(default)]
pub align: Option<DrawingAlign>,
/// The distance in mm within which ends join into one point, 1e-9 to 10 (a value outside is
/// brought inside; one that is not above 0 reads as the default). Default: 0.0001.
#[serde(default)]
pub tolerance: Option<f64>,
/// Read only when no sketch is being edited: the plane of the new sketch, as `sketch.create`
/// takes it. Default: XY. Ignored (and not checked) while a sketch is being edited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plane: Option<Value>,
/// Read only when no sketch is being edited: the new sketch's name. Ignored (and not checked)
/// while a sketch is being edited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<Value>,
/// The sketch to insert into: its feature id or its name. Default: the one being edited (a
/// new one when none is).
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// Leaves out the keys the handler never reads for this call: `path` next to a `text` that is
/// text, `height` next to a `width`, and `scale` next to either.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
if keys.get("text").is_some_and(Value::is_string) {
keys.remove("path");
}
if keys.contains_key("width") {
keys.remove("height");
}
if keys.contains_key("width") || keys.contains_key("height") {
keys.remove("scale");
}
}
fn read_keys<'de, D: Deserializer<'de>>(d: D) -> Result<InsertDrawingKeys, D::Error> {
let mut value = Value::deserialize(d)?;
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))
}
/// Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none
/// is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be
/// sized, turned and placed. Text in the drawing becomes sketch text. One undo step.
#[derive(Debug)]
pub struct InsertDxfParams(pub InsertDrawingKeys);
impl<'de> Deserialize<'de> for InsertDxfParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(InsertDxfParams)
}
}
impl JsonSchema for InsertDxfParams {
fn schema_name() -> Cow<'static, str> {
"InsertDxfParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertDrawingKeys::json_schema(generator);
schema.insert("description".into(), INSERT_DXF.into());
schema
}
}
const INSERT_DXF: &str = "Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
/// Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is
/// being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed.
/// Text in the drawing becomes sketch text. One undo step.
#[derive(Debug)]
pub struct InsertSvgParams(pub InsertDrawingKeys);
impl<'de> Deserialize<'de> for InsertSvgParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(InsertSvgParams)
}
}
impl JsonSchema for InsertSvgParams {
fn schema_name() -> Cow<'static, str> {
"InsertSvgParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertDrawingKeys::json_schema(generator);
schema.insert("description".into(), INSERT_SVG.into());
schema
}
}
const INSERT_SVG: &str = "Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
/// The insert commands need `path` or `text`; the handler says so when both are left out. A
/// `path` it reads (no `text`) is not blank and under 4096 bytes; `maxLength` counts
/// characters, so 4095 is as close as the schema gets. `height` is read only without a `width`
/// and `scale` only without either, so their shapes apply only then, as [`leave_unread`] has it.
fn source_required(schema: &mut Schema) {
let read_path = serde_json::json!({"type": "string", "maxLength": 4095, "pattern": not_blank_pattern()});
schema.insert("anyOf".into(), serde_json::json!([{"required": ["text"]}, {"required": ["path"], "properties": {"path": read_path}}]));
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
let mut read_only_without = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), serde_json::json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let height = read_only_without("height");
let scale = read_only_without("scale");
schema.insert(
"allOf".into(),
serde_json::json!([
{"anyOf": [{"required": ["width"]}, {"properties": {"height": height}}]},
{"anyOf": [{"required": ["width"]}, {"required": ["height"]}, {"properties": {"scale": scale}}]},
]),
);
}
/// `path` is checked by [`source_required`] only when it is read (no `text`).
fn path_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({})
}
/// A drawing's text, as `source_text` reads it: at most 16 MiB, or the handler's refusal.
#[derive(Clone, Debug, PartialEq)]
pub struct DrawingText(pub String);
impl<'de> Deserialize<'de> for DrawingText {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let text = String::deserialize(d)?;
if text.len() > MAX_DRAWING_BYTES {
return Err(D::Error::custom(whole("the drawing is too large")));
}
Ok(DrawingText(text))
}
}
impl JsonSchema for DrawingText {
fn schema_name() -> Cow<'static, str> {
"DrawingText".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "string"})
}
}
/// A drawing file name, as `source_text` reads `path`: not blank and under 4096 bytes, or the
/// handler's "give `path` or `text`" (anything but text reads as no `path`).
#[derive(Clone, Debug, PartialEq)]
pub struct DrawingPath(pub String);
impl<'de> Deserialize<'de> for DrawingPath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(DrawingPath(s)),
_ => Err(D::Error::custom(whole("give `path` or `text`"))),
}
}
}
/// A size in mm above 0, as the insert commands' `width` and `height` are read.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PositiveMm(pub f64);
/// The message the insert commands give for `width` or `height`: the check puts the key first.
const POSITIVE_MM: &str = "must be a positive number (mm)";
impl<'de> Deserialize<'de> for PositiveMm {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_f64().filter(|w| w.is_finite() && *w > 0.0).map(PositiveMm).ok_or_else(|| D::Error::custom(POSITIVE_MM))
}
}
impl JsonSchema for PositiveMm {
fn schema_name() -> Cow<'static, str> {
"PositiveMm".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "A size in mm, above 0.", "type": "number", "exclusiveMinimum": 0})
}
}
/// An angle in degrees as `angle` is read: a number, whatever its size.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AngleDegrees(pub f64);
impl<'de> Deserialize<'de> for AngleDegrees {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_f64().filter(|a| a.is_finite()).map(AngleDegrees).ok_or_else(|| D::Error::custom("must be a number (degrees)"))
}
}
impl JsonSchema for AngleDegrees {
fn schema_name() -> Cow<'static, str> {
"AngleDegrees".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "An angle in degrees.", "type": "number"})
}
}
/// What an insert's `at` places, as `align` is read (the words are case-sensitive).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DrawingAlign {
/// `at` is where the file's origin goes.
Origin,
/// `at` is where the drawing's middle goes, and it turns and scales about it.
Center,
}
impl JsonSchema for DrawingAlign {
fn schema_name() -> Cow<'static, str> {
"DrawingAlign".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "What `at` places: \"origin\" (the file's origin goes there) or \"center\" (the drawing's middle goes there, and it turns and scales about it). Lower case only.",
"type": "string",
"enum": ["origin", "center"]
})
}
}
impl<'de> Deserialize<'de> for DrawingAlign {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)?.as_str() {
Some("origin") => Ok(DrawingAlign::Origin),
Some("center") => Ok(DrawingAlign::Center),
_ => Err(D::Error::custom("must be \"origin\" or \"center\"")),
}
}
}
/// What `sketch.insert_dxf` and `sketch.insert_svg` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DrawingInserted {
/// The sketch the drawing went into, by feature id.
pub sketch: u64,
/// The new sketch's id when no sketch was being edited and one was made; else null.
pub created_sketch: Option<u64>,
/// How many curves were added.
pub curves: usize,
/// How many sketch texts were added.
pub texts: usize,
/// The sketch's solve afterwards; null when it was not solved.
pub info: Option<SketchSolve>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// A sketch's solve, as the sketch commands report it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SketchSolve {
/// Whether the solve kept the sketch's orientation.
pub solved: bool,
/// The degrees of freedom left (0 = fully constrained).
pub dof: usize,
}
/// Save a sketch's curves as an ASCII DXF: to the file `path`, or, without it, as text in the
/// reply. Construction curves are left out; splines and other curves are written as line
/// segments.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ExportDxfParams {
/// The sketch: its feature id or its name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// The file to write. Default (also when blank): none, and the DXF text is returned.
#[serde(default)]
pub path: Option<String>,
/// The drawing's units (any case): `design` (the design's length unit), `mm`, `in` or
/// `inch`, `cm`, `m` or `ft`. Default: mm.
#[serde(default, deserialize_with = "dxf_units")]
#[schemars(schema_with = "dxf_units_schema")]
pub units: Option<String>,
/// The layer of the profiles (blank: the default). Default: `0`.
#[serde(default, deserialize_with = "dxf_layer")]
#[schemars(schema_with = "dxf_layer_schema")]
pub layer: Option<String>,
/// `single` (any case, the default): everything on `layer`; `roles`: outer loops on
/// `OUTLINE` and holes on `CUT`.
#[serde(default, deserialize_with = "dxf_layers")]
#[schemars(schema_with = "dxf_layers_schema")]
pub layers: Option<String>,
/// `true` writes closed loops as LWPOLYLINE with arc bulges. Default: false (LINE and ARC).
#[serde(default, deserialize_with = "dxf_join")]
pub join: Option<bool>,
}
/// The units `sketch.export_dxf` takes, in lower case (the handler reads them in any case).
const DXF_UNITS: [&str; 7] = ["design", "mm", "in", "inch", "cm", "m", "ft"];
fn dxf_units<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::String(units)) => {
let lower = units.to_ascii_lowercase();
if DXF_UNITS.contains(&lower.as_str()) {
Ok(Some(lower))
} else {
Err(D::Error::custom(whole(format!("unsupported unit `{lower}` (use \"design\", \"mm\", \"in\", \"cm\", \"m\", or \"ft\")"))))
}
}
Some(_) => Err(D::Error::custom(whole("`units` must be a string"))),
}
}
fn dxf_units_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["string", "null"], "description": "design, mm, in, inch, cm, m or ft, in any case. Default: mm."})
}
fn dxf_layer<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::String(layer)) => Ok(Some(layer)),
Some(_) => Err(D::Error::custom(whole("`layer` must be a string"))),
}
}
fn dxf_layer_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["string", "null"], "description": "The profiles' layer (blank: the default). Default: 0."})
}
fn dxf_layers<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::String(layers)) if ["single", "roles"].contains(&layers.to_ascii_lowercase().as_str()) => Ok(Some(layers.to_ascii_lowercase())),
Some(_) => Err(D::Error::custom(whole("`layers` must be \"single\" or \"roles\""))),
}
}
fn dxf_layers_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["string", "null"], "description": "single or roles, in any case. Default: single."})
}
fn dxf_join<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(join)) => Ok(Some(join)),
Some(_) => Err(D::Error::custom(whole("`join` must be true or false"))),
}
}
/// What `sketch.export_dxf` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportDxfResult {
#[serde(flatten)]
pub exported: DxfExported,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Where the DXF went.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum DxfExported {
/// With a `path`: the file was written.
Written {
/// The file written, as given.
path: String,
/// How many entities it holds.
entities: usize,
},
/// Without one: the DXF as text.
Text {
/// The DXF file's text.
dxf: String,
/// How many entities it holds.
entities: usize,
},
}
#[cfg(test)]
#[path = "sketch_import_tests.rs"]
mod tests;
@@ -0,0 +1,313 @@
use serde_json::{Value, json};
use solidcraft_io::MAX_DRAWING_BYTES;
use super::{DrawingInserted, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
/// A 30 x 20 rectangle as DXF lines.
const DXF: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n30\n21\n0\n0\nLINE\n10\n30\n20\n0\n11\n30\n21\n20\n0\nLINE\n10\n30\n20\n20\n11\n0\n21\n20\n0\nLINE\n10\n0\n20\n20\n11\n0\n21\n0\n0\nENDSEC\n0\nEOF\n";
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10"><rect x="0" y="0" width="20" height="10"/><text x="2" y="5" font-size="3">Hi</text></svg>"#;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-12508-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn schemas_and_the_key_check_agree() {
let insert = ["align", "angle", "at", "height", "name", "path", "plane", "scale", "sketch", "text", "tolerance", "width"];
assert_keys(input_schema_for::<InsertDxfParams>(), &insert);
assert_keys(input_schema_for::<InsertSvgParams>(), &insert);
assert_keys(input_schema_for::<ExportDxfParams>(), &["join", "layer", "layers", "path", "sketch", "units"]);
}
/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `path` and
/// `text` included: the handler's words, not "is required"). The session has a sketch being edited.
#[test]
fn strict_keeps_todays_errors() {
let too_big = "x".repeat(MAX_DRAWING_BYTES + 1);
let mut cases: Vec<(&str, Value)> = vec![
("sketch.insert_dxf", json!({})),
("sketch.insert_dxf", json!({"path": ""})),
("sketch.insert_dxf", json!({"path": " "})),
("sketch.insert_dxf", json!({"path": 5})),
("sketch.insert_dxf", json!({"path": null})),
("sketch.insert_dxf", json!({"path": "x".repeat(4096)})),
("sketch.insert_dxf", json!({"text": too_big})),
("sketch.insert_svg", json!({})),
("sketch.insert_svg", json!({"path": " "})),
("sketch.insert_svg", json!({"text": null})),
("sketch.export_dxf", json!({"sketch": true})),
("sketch.export_dxf", json!({"sketch": [1]})),
("sketch.export_dxf", json!({"sketch": -1})),
("sketch.export_dxf", json!({"sketch": 1.5})),
];
for (id, text) in [("sketch.insert_dxf", DXF), ("sketch.insert_svg", SVG)] {
for extra in [
json!({"width": 0}),
json!({"width": -2}),
json!({"width": null}),
json!({"width": "wide"}),
json!({"width": [1]}),
json!({"height": 0}),
json!({"height": null}),
json!({"height": true}),
json!({"angle": "x"}),
json!({"angle": null}),
json!({"angle": {}}),
json!({"align": "left"}),
json!({"align": "Center"}),
json!({"align": ""}),
json!({"sketch": true}),
json!({"sketch": null, "width": 0}),
json!({"sketch": [1]}),
json!({"sketch": 1.5}),
] {
let mut p = json!({"text": text});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
cases.push((id, p));
}
}
assert_strict_keeps_todays_errors(&cases);
}
/// The same refusals for a sketch that is named but doesn't exist, a width the drawing can't
/// give (a drawing with no width to size is refused by the handler alone), and the export's
/// options, which the handler reads only once a sketch is being edited.
#[test]
fn strict_keeps_the_handlers_own_refusals() {
// A vertical line has no width to size.
let vertical = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n0\n21\n9\n0\nENDSEC\n0\nEOF\n";
for (id, p) in [
("sketch.insert_dxf", json!({"text": DXF, "sketch": "Nope"})),
("sketch.insert_dxf", json!({"text": DXF, "sketch": 9999})),
("sketch.insert_dxf", json!({"text": vertical, "width": 10})),
("sketch.insert_dxf", json!({"text": "0\nEOF\n"})),
("sketch.export_dxf", json!({"sketch": "Nope"})),
("sketch.export_dxf", json!({"sketch": 9999})),
("sketch.export_dxf", json!({"units": 5})),
("sketch.export_dxf", json!({"units": "Yard"})),
("sketch.export_dxf", json!({"layer": 1})),
("sketch.export_dxf", json!({"layers": "all"})),
("sketch.export_dxf", json!({"layers": true})),
("sketch.export_dxf", json!({"join": "yes"})),
] {
let error = |level: ParamCheck| {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute(id, &p).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{id} {p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
}
}
/// The types read each key as the handler does: keys it never reads in a call are not read by the
/// contract either, and the ones it reads fail with its words.
#[test]
fn the_types_read_like_the_handler() {
let reads = |p: Value| serde_json::from_value::<InsertDxfParams>(p).is_ok();
let issues = |p: Value| crate::schema::issues("sketch.insert_svg", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
// `path` is not read when `text` is given; `height` not when `width` is; `scale` not when
// either is.
assert!(reads(json!({"text": "x", "path": 5})) && reads(json!({"text": "x", "path": ""})));
assert!(reads(json!({"width": 5, "height": "x", "scale": "y"})) && reads(json!({"width": 5, "height": null})));
assert!(reads(json!({"height": 5, "scale": "y"})) && reads(json!({"height": 5, "scale": null})));
assert!(reads(json!({"path": "a.dxf", "scale": 2, "tolerance": 0.01, "at": [1, 2], "angle": 90, "align": "center"})));
assert!(reads(json!({"text": "x", "at": {"x": 1, "y": 2}, "angle": 720.5, "width": 1e12, "scale": 0, "tolerance": -1})));
// They are read when the handler reads them.
// Stricter than the handler, which reads a `text` that isn't text as left out.
assert!(!reads(json!({"text": 5, "path": "a.dxf"})) && !reads(json!({"path": "a.dxf", "scale": "y"})));
assert!(!reads(json!({"path": ""})) && !reads(json!({"path": 5, "scale": 1})));
assert!(!reads(json!({"text": "x", "height": 0})) && !reads(json!({"text": "x", "height": "x", "scale": 2})));
assert!(!reads(json!({"text": "x", "width": null, "height": 5})) && !reads(json!({"text": "x", "scale": "y", "width": 0})));
// Only while no sketch is being edited does `plane` or `name` matter, which the check can't
// see: they take anything.
assert!(reads(json!({"text": "x", "plane": 5, "name": [1]})) && reads(json!({"text": "x", "plane": null, "name": null})));
assert_eq!(issues(json!({"text": "x", "width": 0})), ["`width` must be a positive number (mm)"]);
assert_eq!(issues(json!({"text": "x", "height": -1})), ["`height` must be a positive number (mm)"]);
assert_eq!(issues(json!({"text": "x", "angle": "1"})), ["`angle` must be a number (degrees)"]);
assert_eq!(issues(json!({"text": "x", "align": "left"})), ["`align` must be \"origin\" or \"center\""]);
assert_eq!(issues(json!({"path": " "})), ["give `path` or `text`"]);
assert_eq!(issues(json!({"text": "x", "sketch": true})), ["`sketch` must be an id or a name"]);
assert_eq!(issues(json!({"text": "x", "wdth": 5})), ["unknown key `wdth`; did you mean `width`?"]);
assert!(issues(json!({"text": "x", "width": 5, "height": "x", "path": 5})).is_empty());
let export = |p: Value| serde_json::from_value::<ExportDxfParams>(p).is_ok();
assert!(export(json!({})) && export(json!({"sketch": null, "path": null})) && export(json!({"sketch": "S", "path": ""})));
assert!(export(json!({"sketch": 3, "path": "a.dxf"})) && !export(json!({"sketch": true})) && !export(json!({"path": 5})));
}
/// What the schemas take: a drawing from `path` or `text`, and the keys' own shapes.
#[test]
fn the_schemas_take_what_the_handlers_read() {
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
let schema = schema.as_value();
for (p, takes) in cases {
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
}
};
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
takes(
schema,
&[
(json!({"text": "x"}), true),
(json!({"path": "a.svg", "at": [1, 2], "scale": 2, "angle": 45, "align": "center", "tolerance": 0.01}), true),
(json!({"text": "x", "width": 40, "plane": "XZ", "name": "Logo", "sketch": "Sketch1"}), true),
(json!({"text": "x", "plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "sketch": 4}), true),
(json!({}), false),
(json!({"width": 40}), false),
(json!({"text": "x", "width": 0}), false),
(json!({"text": "x", "height": -1}), false),
(json!({"text": "x", "angle": "x"}), false),
(json!({"text": "x", "align": "left"}), false),
(json!({"text": "x", "at": [1]}), false),
(json!({"text": "x", "wdth": 5}), false),
(json!({"path": "x".repeat(4096)}), false),
(json!({"text": "x", "path": " "}), true),
// Unread keys take anything, as in the handler; read ones keep their shapes.
(json!({"text": "x", "path": 5}), true),
(json!({"text": "x", "width": 5, "height": -1}), true),
(json!({"text": "x", "width": 5, "scale": "y"}), true),
(json!({"text": "x", "height": 5, "scale": "y"}), true),
(json!({"path": 5}), false),
(json!({"text": "x", "height": "x", "scale": 2}), false),
(json!({"text": "x", "scale": "y"}), false),
],
);
}
// A `path` read without `text` is not blank (`conforms` doesn't check patterns).
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
let read_path = &schema.as_value()["anyOf"][1]["properties"]["path"];
assert_eq!(read_path["pattern"], json!(crate::schema::types::not_blank_pattern()));
}
takes(
input_schema_for::<ExportDxfParams>(),
&[
(json!({}), true),
(json!({"sketch": "S", "path": "a.dxf"}), true),
(json!({"sketch": 2}), true),
(json!({"sketch": true}), false),
(json!({"file": "a.dxf"}), false),
],
);
}
fn matches(schema: schemars::Schema, r: &Value) {
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
}
/// Every form of the three commands replies as its output schema says, in Warn without warnings;
/// the replies carry the keys they always had; a misspelt key gets its warning and the reply still
/// matches, and strict refuses it.
#[test]
fn replies_match_the_output_schema() {
let dir = temp_dir("replies");
let svg_file = dir.join("plate.svg");
let dxf_file = dir.join("plate.dxf");
std::fs::write(&svg_file, SVG).unwrap();
std::fs::write(&dxf_file, DXF).unwrap();
let (svg_path, dxf_path) = (svg_file.to_string_lossy().into_owned(), dxf_file.to_string_lossy().into_owned());
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let run = |s: &mut Session, id: &str, p: Value| {
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
r
};
// The session adds `created` (the ids a sketch command made) to every `sketch.*` reply, outside
// the handlers; it is not part of what they build.
let keys = |r: &Value| {
r.as_object().map(|o| o.keys().filter(|k| *k != "created").cloned().collect::<std::collections::BTreeSet<_>>()).unwrap_or_default()
};
let insert_keys: std::collections::BTreeSet<String> = ["created_sketch", "curves", "info", "sketch", "texts"].map(String::from).into();
// With no sketch being edited: a new one is made, on the plane and with the name given.
let r = run(&mut s, "sketch.insert_dxf", json!({"text": DXF, "plane": "XZ", "name": "Plate", "at": [5, 5]}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!(keys(&r), insert_keys, "{r}");
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(0)), "{r}");
assert_eq!(r["created_sketch"], r["sketch"], "{r}");
// Exactly what the handler returned before it built its reply from the type.
let mut handler_reply = r.clone();
handler_reply.as_object_mut().unwrap().remove("created");
assert_eq!(
handler_reply,
json!({"sketch": r["sketch"], "created_sketch": r["sketch"], "curves": 4, "texts": 0, "info": {"solved": true, "dof": 8}})
);
assert!(r["info"]["solved"].is_boolean() && r["info"]["dof"].is_u64(), "{r}");
// With one: into it, and `plane` and `name` (of any kind) are not read.
let id = s.active_sketch.unwrap();
for p in [
json!({"text": DXF, "path": 5, "plane": 7, "name": [1]}),
json!({"text": DXF, "width": 60, "height": "x", "scale": "y", "angle": 90, "align": "center", "at": {"x": 1, "y": 2}}),
json!({"text": DXF, "height": 10, "scale": "y", "tolerance": 0.01}),
json!({"text": DXF, "scale": 2, "align": "origin", "sketch": id}),
json!({"text": DXF, "sketch": s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default()}),
json!({"path": dxf_path, "text": null, "sketch": null}),
] {
let r = run(&mut s, "sketch.insert_dxf", p);
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!(keys(&r), insert_keys);
assert_eq!((r["sketch"].as_u64(), r["created_sketch"].clone(), r["curves"].as_u64()), (Some(id), Value::Null, Some(4)), "{r}");
}
let r = run(&mut s, "sketch.insert_svg", json!({"text": SVG, "angle": 15}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(1)), "{r}");
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "width": 40, "align": "center", "at": [10, 10]}));
assert_eq!(r["texts"], 1, "{r}");
s.execute("sketch.finish", &json!({})).unwrap();
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "plane": "YZ"}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_ne!(r["created_sketch"], Value::Null, "{r}");
s.execute("sketch.finish", &json!({})).unwrap();
// Export: the text, or a file.
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id}));
matches(output_schema_for::<ExportDxfResult>(), &r);
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
assert!(r["dxf"].as_str().is_some_and(|t| t.contains("ENTITIES")), "{r}");
assert!(r["entities"].as_u64().is_some_and(|n| n >= 4), "{r}");
let out = dir.join("out.dxf");
let out_path = out.to_string_lossy().into_owned();
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id, "path": out_path}));
matches(output_schema_for::<ExportDxfResult>(), &r);
assert_eq!(r["path"], json!(out_path), "{r}");
assert_eq!(keys(&r), ["entities", "path"].map(String::from).into(), "{r}");
assert!(std::fs::read_to_string(&out).unwrap().contains("ENTITIES"));
// A blank `path` and a null `sketch` read as left out.
s.execute("sketch.edit", &json!({"sketch": id})).unwrap();
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": null, "path": " "}));
assert!(r.get("dxf").is_some(), "{r}");
matches(output_schema_for::<ExportDxfResult>(), &r);
// The units and layer options, in any case; null and a blank layer read as left out.
for p in [
json!({"units": "IN", "layer": "PART"}),
json!({"units": "design", "layers": "Roles"}),
json!({"join": true, "layer": " "}),
json!({"units": null, "layer": null, "layers": null, "join": null}),
] {
let r = run(&mut s, "sketch.export_dxf", p);
matches(output_schema_for::<ExportDxfResult>(), &r);
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
}
let _ = std::fs::remove_dir_all(&dir);
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
let p = json!({"text": DXF, "wdth": 5});
let r = s.execute("sketch.insert_dxf", &p).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("`wdth`")))), "{r}");
matches(output_schema_for::<DrawingInserted>(), &r);
let r = s.execute("sketch.export_dxf", &json!({"pth": "a.dxf"})).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
matches(output_schema_for::<ExportDxfResult>(), &r);
let r = s.execute("sketch.insert_svg", &json!({"text": SVG, "angel": 5})).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
s.param_check = ParamCheck::Strict;
let error = s.execute("sketch.insert_dxf", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`wdth`") && error.contains("did you mean `width`"), "{error}");
}
-3
View File
@@ -67,9 +67,6 @@ sketch.dimension_text
sketch.drag
sketch.drag_end
sketch.draw.dimensioned
sketch.export_dxf
sketch.insert_dxf
sketch.insert_svg
sketch.move_point
sketch.options
sketch.polygon.circumscribed
-2
View File
@@ -11,8 +11,6 @@ workspace = true
[dependencies]
solidcore-record = { workspace = true }
solidcraft-engine = { workspace = true }
# The widget registry (handles, rects, counts) the UI kit publishes into.
solidcraft-ui-registry = { workspace = true }
egui = { workspace = true }
egui-wgpu = { workspace = true }
solidcraft-gpu = { workspace = true }
+5 -5
View File
@@ -189,7 +189,7 @@ pub(crate) fn left_inset(app: &SolveApp, available: Rect) -> f32 {
&& app.preferences.agent_chat_side == "left"
{
let width = if app.preferences.agent_chat_minimized { STRIP } else { app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH) };
(width + crate::ui_kit::SHELL_GAP).min(available.width().max(0.0))
(width + crate::shell::SHELL_GAP).min(available.width().max(0.0))
} else {
0.0
}
@@ -205,9 +205,9 @@ pub(crate) fn tool_space(app: &SolveApp, mut available: Rect) -> Rect {
&& let Some(chat) = app.agent_chat.panel_rect.filter(|r| r.is_finite() && r.is_positive())
{
if app.preferences.agent_chat_side == "left" {
available.min.x = crate::ui_kit::within(chat.right() + crate::ui_kit::SHELL_GAP, available.left(), available.right());
available.min.x = crate::shell::within(chat.right() + crate::shell::SHELL_GAP, available.left(), available.right());
} else {
available.max.x = crate::ui_kit::within(chat.left() - crate::ui_kit::SHELL_GAP, available.left(), available.right());
available.max.x = crate::shell::within(chat.left() - crate::shell::SHELL_GAP, available.left(), available.right());
}
}
available
@@ -216,7 +216,7 @@ pub(crate) fn tool_space(app: &SolveApp, mut available: Rect) -> Rect {
/// The same outer rectangle is used for the floating sheet and its docking preview.
fn floating_bounds(app: &SolveApp, available: Rect, left: bool) -> Rect {
let t = Tokens::get();
let gap = crate::ui_kit::SHELL_GAP;
let gap = crate::shell::SHELL_GAP;
let mut bounds = available.shrink(gap);
// The ribbon already supplies the top gap shared by browser and history cards.
bounds.min.y = available.top();
@@ -1659,7 +1659,7 @@ fn copy_message_menu(ui: &egui::Ui, response: &egui::Response, n: usize, text: &
.frame(ui_kit::Menu::frame())
.open_memory(if opened { Some(egui::SetOpenCommand::Bool(true)) } else { None })
.show(|ui| {
ui_kit::Menu::show(ui, crate::ui_kit::LIST_W, |ui| {
ui_kit::Menu::show(ui, crate::context_menu::LIST_W, |ui| {
let item = ui_kit::MenuItem::new("copy_message", tr("ui.agent_chat_copy_message"), "copy");
let row = item.show(ui);
crate::scenario::publish_handle(&format!("agent_chat:copy_menu:{n}"), row.rect.center());
+26 -26
View File
@@ -519,7 +519,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
permissions_section(app, ui);
#[cfg(not(target_arch = "wasm32"))]
{
let browsing = !crate::ui_kit::settings_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_ASSISTANT;
let browsing = !crate::prefs_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_ASSISTANT;
if app.preferences.ai_features && browsing && app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, &ui.ctx().clone());
}
@@ -561,7 +561,7 @@ fn instructions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let response = ui_kit::text_area(ui, tr("ai.prefs.instructions"), id, text, 6, acp::INSTRUCTIONS_LIMIT);
if response.has_focus() {
// Typed text belongs to the field, not to Preferences search.
crate::ui_kit::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
crate::scenario::publish_target("prefs:agent_instructions", response.rect);
ui_kit::text(ui, tr("ai.prefs.instructions_note"), ui_kit::Text::Hint("instructions"));
@@ -658,20 +658,20 @@ fn panel_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let previous = usize::from(p.agent_chat_turn_limit != 0);
let mut limit = previous;
let options = [tr("ui.agent_chat_turn_unlimited"), tr("prefs.assistant_limited")];
crate::ui_kit::settings_search::note_current(ui, &options.join(" "));
crate::prefs_search::note_current(ui, &options.join(" "));
let dropdown_width = ui_kit::ContentWidth::dropdown(ui, &options);
let response =
ui_kit::settings_control_width(ui, dropdown_width, |ui| ui_kit::dropdown(ui, "assistant_turn_limit", &mut limit, &options));
let query = crate::ui_kit::settings_search::query(ui);
let query = crate::prefs_search::query(ui);
if let Some(option) = options
.iter()
.find(|option| option.to_lowercase() == query)
.or_else(|| options.iter().find(|option| crate::ui_kit::settings_search::texts_hit(ui, &[*option])))
.or_else(|| options.iter().find(|option| crate::prefs_search::texts_hit(ui, &[*option])))
{
crate::ui_kit::settings_search::remember(ui, option, response.rect);
crate::prefs_search::remember(ui, option, response.rect);
}
for option in options {
crate::ui_kit::settings_search::follow(ui, option, response.rect);
crate::prefs_search::follow(ui, option, response.rect);
}
crate::scenario::publish_target("prefs:agent_chat_turn_limit:mode", response.rect);
if limit != previous {
@@ -706,7 +706,7 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_form(ui, speed_label_width, |ui| {
ui_kit::row(ui, tr("ai.prefs.pointer_speed"), |ui| {
let name = tr("prefs.assistant_pointer_speed");
crate::ui_kit::settings_search::note_current(ui, name);
crate::prefs_search::note_current(ui, name);
let response =
ui_kit::named_control(ui, name, |ui| crate::agent_cursor::speed_slider(ui, &mut app.ui.agent_cursor.speed));
crate::scenario::publish_target("prefs:agent_pointer_speed", response.rect);
@@ -717,11 +717,11 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), value, name));
// The visible row owns Speed and its legacy Motion speed alias. The
// native editor's name owns queries that only match Pointer speed.
if !crate::ui_kit::settings_search::texts_hit(ui, &[tr("ai.prefs.pointer_speed")]) {
crate::ui_kit::settings_search::remember(ui, name, response.rect);
if !crate::prefs_search::texts_hit(ui, &[tr("ai.prefs.pointer_speed")]) {
crate::prefs_search::remember(ui, name, response.rect);
}
for label in [tr("ai.prefs.pointer_speed"), name] {
crate::ui_kit::settings_search::follow(ui, label, response.rect);
crate::prefs_search::follow(ui, label, response.rect);
}
});
});
@@ -781,12 +781,12 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
for entry in &all {
ui.push_id(&entry.id, |ui| {
let detail = entry.detail();
let gate = crate::ui_kit::settings_search::block_gate(ui, &entry.name, Some(&format!("{} {detail}", entry.id)));
let before = crate::ui_kit::settings_search::hits(ui);
let gate = crate::prefs_search::block_gate(ui, &entry.name, Some(&format!("{} {detail}", entry.id)));
let before = crate::prefs_search::hits(ui);
let mut paint = |ui: &mut egui::Ui| {
let card = ui_kit::settings_card(ui, |ui| {
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
crate::ui_kit::settings_search::visible(ui, &[&entry.name, &entry.id, &detail]);
crate::prefs_search::visible(ui, &[&entry.name, &entry.id, &detail]);
ui.label(egui::RichText::new(&entry.name).strong().size(t.font_label).color(t.form_ink));
ui.allocate_ui_with_layout(
egui::vec2(ui.available_width(), t.control_height),
@@ -851,7 +851,7 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let shown = current.map_or_else(|| tr("ui.ai_mode_global").to_owned(), |mode| mode.label().to_owned());
let mut pick: Option<Option<Mode>> = None;
let options: Vec<_> = std::iter::once(tr("ui.ai_mode_global")).chain(Mode::ALL.map(Mode::label)).collect();
crate::ui_kit::settings_search::note_current(ui, &options.join(" "));
crate::prefs_search::note_current(ui, &options.join(" "));
let response = ui_kit::settings_control_width(ui, t.space_24 * 9.0 + t.space_4, |ui| {
ui_kit::dropdown_menu(ui, ("sc_ai_mode", &entry.id), &shown, |ui| {
if ui_kit::list_picker(ui, "global", tr("ui.ai_mode_global"), current.is_none()).clicked() {
@@ -864,16 +864,16 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
}
})
});
let query = crate::ui_kit::settings_search::query(ui);
let query = crate::prefs_search::query(ui);
if let Some(option) = options
.iter()
.find(|option| option.to_lowercase() == query)
.or_else(|| options.iter().find(|option| crate::ui_kit::settings_search::texts_hit(ui, &[*option])))
.or_else(|| options.iter().find(|option| crate::prefs_search::texts_hit(ui, &[*option])))
{
crate::ui_kit::settings_search::remember(ui, option, response.rect);
crate::prefs_search::remember(ui, option, response.rect);
}
for option in options {
crate::ui_kit::settings_search::follow(ui, option, response.rect);
crate::prefs_search::follow(ui, option, response.rect);
}
match pick {
Some(Some(mode)) => {
@@ -965,23 +965,23 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
});
crate::scenario::publish_rect(&format!("prefs:ai_card:{}", entry.id), card.response.rect);
let metadata = [&entry.name, &entry.id, &detail];
if let Some(text) = metadata.iter().find(|text| crate::ui_kit::settings_search::texts_hit(ui, &[*text])) {
crate::ui_kit::settings_search::remember(ui, text, card.response.rect);
if let Some(text) = metadata.iter().find(|text| crate::prefs_search::texts_hit(ui, &[*text])) {
crate::prefs_search::remember(ui, text, card.response.rect);
}
for text in metadata {
crate::ui_kit::settings_search::follow(ui, text, card.response.rect);
crate::prefs_search::follow(ui, text, card.response.rect);
}
};
let show = gate.as_ref().is_none_or(|gate| gate.show);
if show {
paint(ui);
} else {
crate::ui_kit::settings_search::run_hidden(ui, &entry.id, |ui| {
crate::prefs_search::run_hidden(ui, &entry.id, |ui| {
paint(ui);
});
}
if let Some(gate) = gate {
crate::ui_kit::settings_search::block_finish(ui, &gate, show, before);
crate::prefs_search::block_finish(ui, &gate, show, before);
}
});
}
@@ -1524,7 +1524,7 @@ mod card_tests {
#[test]
fn pointer_speed_search_reveals_one_real_slider_in_all_enablement_states() {
use crate::ui_kit::settings_search::{nav, set_cursor};
use crate::prefs_search::{nav, set_cursor};
for theme in ["light", "dark"] {
for (ai, show) in [(true, true), (true, false), (false, true), (false, false)] {
let mut h = Harness::new();
@@ -1569,7 +1569,7 @@ mod card_tests {
#[test]
fn overlapping_agent_metadata_has_one_card_target_and_keeps_fields_indexed() {
use crate::ui_kit::settings_search::{nav, set_cursor};
use crate::prefs_search::{nav, set_cursor};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
+7 -7
View File
@@ -1,7 +1,7 @@
use super::*;
use std::sync::mpsc::channel;
fn transcript_clipboard_harness(layout: crate::ui_kit::LayoutMode) -> crate::scenario::Harness {
fn transcript_clipboard_harness(layout: crate::shell::LayoutMode) -> crate::scenario::Harness {
let mut h = narrow_chat();
h.app.ui.layout = layout;
h.app.preferences.agent_chat_width = 500.0;
@@ -36,7 +36,7 @@ fn transcript_click(h: &mut crate::scenario::Harness, pos: egui::Pos2) {
#[test]
fn transcript_copy_and_cut_do_not_touch_the_cad_clipboard() {
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
let mut h = transcript_clipboard_harness(layout);
let at = transcript_text_position(&h);
transcript_click(&mut h, at);
@@ -57,7 +57,7 @@ fn transcript_copy_and_cut_do_not_touch_the_cad_clipboard() {
#[test]
fn transcript_paste_does_not_insert_a_previously_copied_body() {
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
let mut h = transcript_clipboard_harness(layout);
h.frame_with(vec![egui::Event::Copy]);
assert!(!h.app.copied_bodies.is_empty(), "prime the real CAD clipboard");
@@ -77,7 +77,7 @@ fn transcript_paste_does_not_insert_a_previously_copied_body() {
#[test]
fn transcript_key_events_do_not_run_cad_shortcuts() {
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
let mut h = transcript_clipboard_harness(layout);
let at = transcript_text_position(&h);
transcript_click(&mut h, at);
@@ -106,7 +106,7 @@ fn transcript_key_events_do_not_run_cad_shortcuts() {
#[test]
fn transcript_shortcut_ownership_ends_when_the_panel_is_hidden() {
for hidden in ["closed", "minimized", "disabled"] {
let mut h = transcript_clipboard_harness(crate::ui_kit::LayoutMode::Floating);
let mut h = transcript_clipboard_harness(crate::shell::LayoutMode::Floating);
h.frame_with(vec![egui::Event::Copy]);
let at = transcript_text_position(&h);
transcript_click(&mut h, at);
@@ -3184,7 +3184,7 @@ fn a_press_at_the_edge_without_a_drag_towards_it_scrolls_nothing() {
/// at the panel's edge or is cut off at the other one.
#[test]
fn the_agent_list_keeps_the_panels_side_margin() {
use crate::ui_kit::LayoutMode;
use crate::shell::LayoutMode;
for layout in [LayoutMode::Docked, LayoutMode::Floating] {
let mut h = narrow_chat();
h.app.ui.layout = layout;
@@ -4634,7 +4634,7 @@ fn the_composer_hint_stays_on_one_row_at_the_narrowest_width() {
#[test]
fn tool_room_beside_the_docked_assistant_survives_an_inside_out_view() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.ui.layout = crate::ui_kit::LayoutMode::Floating;
app.ui.layout = crate::shell::LayoutMode::Floating;
app.preferences.ai_features = true;
app.agent_chat.open = true;
app.preferences.agent_chat_popped = false;
+1 -2
View File
@@ -148,9 +148,8 @@ use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{BrowserEntry, Document, FeatureKind};
use crate::context_menu::Target;
use crate::context_menu::{Item, Target};
use crate::theme::Tokens;
use crate::ui_kit::Item;
use crate::SolveApp;
+1 -2
View File
@@ -12,9 +12,8 @@ use solidcraft_engine::view::POINT_CLOUDS_FOLDER;
use super::{Actions, Row, draw_row, is_open, panel_pos, rows_start_open, toggle, toggle_folder};
use crate::SolveApp;
use crate::context_menu::Target;
use crate::context_menu::{Item, Target};
use crate::ui_kit;
use crate::ui_kit::Item;
#[path = "point_cloud_tracks.rs"]
pub(crate) mod point_cloud_tracks;
+4 -4
View File
@@ -1,7 +1,7 @@
use super::*;
use crate::scenario::{Harness, handle_at, rect_of};
use crate::semantic_content::{self, ContentOwner, ContentRequest};
use crate::ui_kit::{LayoutMode, SHELL_GAP};
use crate::shell::{LayoutMode, SHELL_GAP};
#[test]
fn clicked_browser_folders_toggle_visibility_with_v() {
@@ -318,7 +318,7 @@ fn selected_row_paints_tab_soft_fill_and_accent_text_in_both_layouts() {
let mut app = SolveApp::new(Default::default(), Default::default());
app.ui.layout = mode;
app.ui.tab = tab.into();
app.publish_tab_accent(&ctx);
crate::theme::TabAccent::publish(&app, &ctx);
let accent = crate::theme::TabAccent::for_tab(&Tokens::get(), tab);
let mut row_rect = Rect::NOTHING;
let out = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
@@ -646,7 +646,7 @@ fn browser_width_rejects_hostile_preferences_and_tiny_windows_still_fit() {
assert_eq!(browser_width(-100.0), MIN_BROWSER_WIDTH);
assert_eq!(browser_width(f32::MAX), MAX_BROWSER_WIDTH);
let available = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 300.0));
for top_gap in [0.0, crate::ui_kit::SHELL_GAP] {
for top_gap in [0.0, crate::shell::SHELL_GAP] {
let bounds = panel_bounds(available, None, MAX_BROWSER_WIDTH, top_gap);
assert!(available.contains_rect(bounds));
}
@@ -677,7 +677,7 @@ fn collapsed_rail_shares_header_hover_inset_and_expands_from_its_whole_card() {
app.ui.tab = "SURFACE".into();
app.preferences.timeline_left = true;
app.preferences.timeline_left_browser_collapsed = collapsed;
app.publish_tab_accent(&ctx);
crate::theme::TabAccent::publish(&app, &ctx);
let bounds = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 700.0));
let draw = |app: &mut SolveApp, events| {
crate::test_support::frame(&ctx, egui::RawInput { screen_rect: Some(bounds), events, ..Default::default() }, |ui| {
+5 -1
View File
@@ -211,7 +211,11 @@ fn with_rows_starting_closed_a_designs_folders_open_with_their_arrow_or_find_in_
"the Bodies folder stays closed"
);
crate::context_menu::run_item(&mut h.app, &crate::ui_kit::Item::action("ui.findBrowser", "", "").with(json!({"body":"Block"})), egui::Pos2::ZERO);
crate::context_menu::run_item(
&mut h.app,
&crate::context_menu::Item::action("ui.findBrowser", "", "").with(json!({"body":"Block"})),
egui::Pos2::ZERO,
);
h.frames(2);
assert!(super::is_open(&h.app, "c0/bodies", false), "Find in Browser opens the Bodies folder");
assert!(
+97 -2
View File
@@ -11,7 +11,7 @@
//! state; [`items`] lists them and [`run_item`] runs one, so the control channel (`ui.menu`,
//! `ui.menuPick`) and tests reach everything a click does.
use crate::ui_kit::{BackingTooltipExt as _, Container, Item, LIST_W, Surface};
use crate::ui_kit::{BackingTooltipExt as _, Container, Surface};
use egui::{Color32, Pos2, Rect, Sense, Vec2, pos2, vec2};
use serde::Serialize;
use serde_json::{Value, json};
@@ -127,6 +127,88 @@ pub enum Target {
},
}
/// One menu entry: a command id (run with `params` when given, else started like a toolbar
/// click) or a `ui.*` action. `-` is a separator, `#…` a section heading.
#[derive(Clone, Debug, Serialize)]
pub struct Item {
pub id: String,
pub label: String,
pub shortcut: String,
pub enabled: bool,
pub icon: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub children: Vec<Item>,
#[serde(skip_serializing_if = "Option::is_none")]
pub swatch: Option<[u8; 3]>,
/// Why the entry is off: shown on hover, and in the control channel's report.
#[serde(skip_serializing_if = "Option::is_none")]
pub tip: Option<String>,
}
impl Item {
/// A view action (`ui.*`).
pub fn action(id: &str, label: &str, icon: &str) -> Item {
act(id, label, icon)
}
pub fn sep() -> Item {
Item {
id: "-".into(),
label: String::new(),
shortcut: String::new(),
enabled: false,
icon: String::new(),
params: None,
children: Vec::new(),
swatch: None,
tip: None,
}
}
pub(crate) fn heading(label: &str) -> Item {
Item {
id: format!("#{label}"),
label: label.into(),
shortcut: String::new(),
enabled: false,
icon: String::new(),
params: None,
children: Vec::new(),
swatch: None,
tip: None,
}
}
fn is_sep(&self) -> bool {
self.id == "-"
}
fn is_heading(&self) -> bool {
self.id.starts_with('#')
}
pub fn with(mut self, params: Value) -> Item {
self.params = Some(params);
self
}
pub fn on_if(self, enabled: bool) -> Item {
self.on(enabled)
}
pub(crate) fn on(mut self, enabled: bool) -> Item {
self.enabled &= enabled;
self
}
/// Off, saying why, when there is a reason.
pub(crate) fn off_because(mut self, why: Option<String>) -> Item {
if let Some(why) = why {
self.enabled = false;
self.tip = Some(why);
}
self
}
pub fn key(mut self, k: &str) -> Item {
self.shortcut = k.into();
self
}
}
/// A command entry: label, icon, shortcut and enablement come from the registry.
pub(crate) fn cmd(app: &SolveApp, id: &str, label: &str) -> Item {
let info = solidcraft_engine::find_command(id).map(|c| c.info(&app.session));
@@ -150,7 +232,17 @@ pub(crate) fn cmd(app: &SolveApp, id: &str, label: &str) -> Item {
/// A view action (`ui.*`).
pub(crate) fn act(id: &str, label: &str, icon: &str) -> Item {
Item::action(id, label, icon)
Item {
id: id.into(),
label: label.into(),
shortcut: String::new(),
enabled: true,
icon: icon.into(),
params: None,
children: Vec::new(),
swatch: None,
tip: None,
}
}
/// Menu state kept between frames. The anchor is `app.viewport.context_menu`.
@@ -2443,6 +2535,9 @@ fn list_place(c: Pos2, h: f32, screen: Rect) -> Pos2 {
pos2((c.x - RX - 12.0 - LIST_W).max(screen.left() + 4.0), y)
}
/// Width of list menus.
pub const LIST_W: f32 = 250.0;
/// Where a menu of `size` opened at `at` goes: right of and below the point (`gap` away) when it
/// fits in `screen`, flipped to the left or above it when it would run past that edge, and
/// against the far edge when it fits on neither side. A flipped menu ends at the cursor, so it
+1 -1
View File
@@ -790,7 +790,7 @@ fn revolve_layout_fixture_builds_a_valid_solid() {
#[test]
fn new_cards_settle_before_the_first_click_in_both_layouts() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for command in ["solid.box", "solid.revolve", "solid.shell", "joint.create"] {
let mut h = fixture();
h.call("ui.set", json!({"layout":mode}));
+2 -2
View File
@@ -1202,7 +1202,7 @@ mod tests {
#[test]
fn common_forms_fit_both_layouts_and_scales_and_pin_footer_at_small_heights() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for scale in [1.0, 2.0] {
for height in [950.0, 720.0] {
let mut h = Harness::new();
@@ -1244,7 +1244,7 @@ mod tests {
#[test]
fn tiny_windows_do_not_panic_or_publish_non_finite_cards() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for size in [(1.0, 1.0), (40.0, 80.0), (180.0, 240.0)] {
let mut h = Harness::new();
h.call("ui.set", json!({"layout": mode}));
+29 -29
View File
@@ -7,7 +7,7 @@ use crate::Services;
use crate::dialogs::{apply_commands, for_feature as edit_dialog};
/// A 100 x 60 base flange on XY (2.5 mm steel).
fn plate(mode: crate::ui_kit::LayoutMode) -> SolveApp {
fn plate(mode: crate::shell::LayoutMode) -> SolveApp {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY", "name": "Base"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [100, 60]})).unwrap();
@@ -21,7 +21,7 @@ fn plate(mode: crate::ui_kit::LayoutMode) -> SolveApp {
/// dialog's height, angle and bend position; Edit Feature brings the edges back.
#[test]
fn flange_follows_the_picks() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
let sk = app.session.doc.features[0].id;
app.session.selection = vec![Sel::Profile { sketch: sk, index: 0 }];
@@ -95,7 +95,7 @@ fn a_click_on_the_bottom_edge_makes_an_edge_flange() {
/// Hem takes edges and an optional gap; rules make and pick a rule with its expressions.
#[test]
fn hem_and_rules() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.session.selection = edge_sel(&app.session, Vec3::new(50.0, 0.0, 2.5)).into_iter().collect();
@@ -133,7 +133,7 @@ fn hem_and_rules() {
#[test]
fn flat_exports_use_the_save_picker() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
use std::sync::{Arc, Mutex};
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
@@ -157,7 +157,7 @@ fn flat_exports_use_the_save_picker() {
#[test]
fn shallow_pocket_overlay_labels_depth_without_drawing_a_through_void() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sketch.create", json!({"plane": "XY", "name": "Pocket"})).unwrap();
@@ -225,7 +225,7 @@ fn flat_pattern_shows_each_side_of_the_flat_size_whole() {
#[test]
fn flat_pattern_islands_fit_before_the_actual_command_panel() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sketch.create", json!({"plane": "XY", "name": "Retain"})).unwrap();
@@ -247,7 +247,7 @@ fn flat_pattern_islands_fit_before_the_actual_command_panel() {
let viewport = Rect::from_min_max(pos2(250.0, crate::toolbar::APP_BAR_HEIGHT + crate::toolbar::RIBBON_HEIGHT), pos2(1400.0, 832.0));
let browser = Rect::from_min_max(viewport.min, viewport.min + vec2(250.0, 450.0));
let history = Rect::from_min_max(pos2(viewport.left(), viewport.bottom() - 40.0), viewport.max);
if mode == crate::ui_kit::LayoutMode::Floating {
if mode == crate::shell::LayoutMode::Floating {
app.shell.content = Some(Rect::from_min_max(pos2(browser.right(), viewport.top()), pos2(viewport.right(), history.top())));
}
let ctx = crate::fonts::test_context();
@@ -286,7 +286,7 @@ fn flat_pattern_islands_fit_before_the_actual_command_panel() {
outlines.iter().flat_map(|p| &p.points).all(|p| p.x <= panel.left() - 19.0),
"both complete islands must remain exposed before the command panel: {panel:?}"
);
if mode == crate::ui_kit::LayoutMode::Floating {
if mode == crate::shell::LayoutMode::Floating {
assert!(
outlines.iter().flat_map(|p| &p.points).all(|p| !browser.contains(*p) && !history.contains(*p)),
"flat pattern fits clear of the shell cards"
@@ -297,7 +297,7 @@ fn flat_pattern_islands_fit_before_the_actual_command_panel() {
#[test]
fn coincident_top_and_bottom_pocket_depths_share_a_readable_annotation() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch":"Base"})).unwrap();
app.run("sketch.create", json!({"plane":"XY","name":"Retain"})).unwrap();
@@ -329,7 +329,7 @@ fn coincident_top_and_bottom_pocket_depths_share_a_readable_annotation() {
#[test]
fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = crate::scenario::Harness::new();
h.call("ui.set", json!({"layout":mode}));
for (command, params) in [
@@ -366,7 +366,7 @@ fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() {
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn bend_table_picker_writes_the_current_body() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})).unwrap();
@@ -394,7 +394,7 @@ fn bend_table_picker_writes_the_current_body() {
/// made with it, and the dialog shows it the next time.
#[test]
fn rules_dialog_picks_the_corner_relief_shape() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = crate::scenario::Harness::new();
h.call("ui.set", json!({"layout":mode}));
h.app
@@ -447,7 +447,7 @@ fn rules_dialog_picks_the_corner_relief_shape() {
/// of the part made with it, and the dialog shows it the next time.
#[test]
fn rules_dialog_sets_how_bend_angles_read() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = crate::scenario::Harness::new();
h.call("ui.set", json!({"layout":mode}));
h.app.run("sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "bend_radius": "1 mm", "active": true})).unwrap();
@@ -551,7 +551,7 @@ fn the_rule_manager_relieves_a_corner_that_is_not_square() {
/// a shape and a size reach the feature, Edit Feature shows them again, and Rule takes them back.
#[test]
fn flange_dialog_picks_a_corner_relief_of_its_own() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = crate::scenario::Harness::new();
h.call("ui.set", json!({"layout":mode}));
h.app
@@ -627,7 +627,7 @@ fn flange_dialog_picks_a_corner_relief_of_its_own() {
#[test]
fn pocket_depth_labels_follow_inches_without_changing_flat_data() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("document.units", json!({"units":"in"})).unwrap();
assert_eq!(super::pocket_label(&app, &json!({"side":"top","depth_mm":25.4*0.1234})).as_deref(), Some("Top pocket: 0.1234 in deep"));
@@ -640,7 +640,7 @@ fn pocket_depth_labels_follow_inches_without_changing_flat_data() {
/// brings them back and rebuilds the same flanges.
#[test]
fn edge_flanges_can_have_an_angle_and_a_height_of_their_own() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
let edges: Vec<Sel> = [[50.0, 0.0, 2.5], [50.0, 60.0, 2.5], [0.0, 30.0, 2.5], [100.0, 30.0, 2.5]]
@@ -703,7 +703,7 @@ fn edge_flanges_can_have_an_angle_and_a_height_of_their_own() {
/// one flange command, one body and one undo step, and a bend with no height is refused.
#[test]
fn further_bends_in_the_flange_dialog_make_one_chained_flap() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sheet.flange", json!({"edges": [[0, 30, 2.5]], "height": 30})).unwrap();
@@ -777,7 +777,7 @@ fn add_bend_and_remove_bend_buttons_work() {
/// reaches the feature, and Edit Feature shows it again.
#[test]
fn flange_dialog_measures_the_height_from_the_chosen_datum() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = crate::scenario::Harness::new();
h.call("ui.set", json!({"layout":mode}));
h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap();
@@ -901,7 +901,7 @@ fn convert_takes_the_fixed_face_and_the_bend_edges() {
/// and Edit Feature brings the edge and the path back.
#[test]
fn path_flange_dialog_follows_the_path() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sheet.flange", json!({"edges": [[0, 30, 2.5]], "height": 30})).unwrap();
@@ -949,7 +949,7 @@ fn path_flange_dialog_follows_the_path() {
// ---- flat pattern navigation ----
fn flat_app(preset: &str, mode: crate::ui_kit::LayoutMode) -> SolveApp {
fn flat_app(preset: &str, mode: crate::shell::LayoutMode) -> SolveApp {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap();
@@ -989,7 +989,7 @@ fn frame(app: &mut SolveApp, ctx: &egui::Context, time: &mut f64, mods: egui::Mo
/// cursor. The flat view never orbits.
#[test]
fn flat_pattern_pans_and_zooms_like_the_design_viewport() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
use egui::{Event, Modifiers, PointerButton};
let none = Modifiers::NONE;
let chords = [none, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT];
@@ -1094,7 +1094,7 @@ impl IntoNav for egui::PointerButton {
/// camera and selection alone.
#[test]
fn design_camera_is_unchanged_while_the_flat_pattern_has_focus() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
use egui::{Event, Modifiers, PointerButton};
let none = Modifiers::NONE;
for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) {
@@ -1136,7 +1136,7 @@ fn design_camera_is_unchanged_while_the_flat_pattern_has_focus() {
/// Fit and Home frame the flat pattern (not the model), and leave the Design camera alone.
#[test]
fn fit_and_home_frame_the_flat_pattern() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let ctx = crate::fonts::test_context();
let mut app = flat_app("fusion", mode);
let mut t = 0.0;
@@ -1172,7 +1172,7 @@ fn fit_and_home_frame_the_flat_pattern() {
}
fn perforated_flat() -> (SolveApp, Dialog) {
let mut app = plate(crate::ui_kit::LayoutMode::Floating);
let mut app = plate(crate::shell::LayoutMode::Floating);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sheet.perforate", json!({"diameter": 3, "pitch": 6})).unwrap();
let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap();
@@ -1251,7 +1251,7 @@ fn flat_perforations_cull_offscreen_and_subpixel_holes() {
#[test]
fn hem_handle_runs_along_the_hem_to_its_tip() {
for flipped in [false, true] {
let mut app = plate(crate::ui_kit::LayoutMode::Floating);
let mut app = plate(crate::shell::LayoutMode::Floating);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
let body = app.session.model.state().sheets[0].body.clone();
app.session.selection = vec![edge_sel(&app.session, Vec3::new(50.0, 0.0, 2.5)).unwrap()];
@@ -1296,7 +1296,7 @@ fn inch_sheet_rule_edit_preserves_stored_lengths_and_dimensionless_k_factor() {
#[test]
fn perforate_toolbar_opens_without_running_empty_parameters() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut app = plate(mode);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
let features = app.session.doc.features.len();
@@ -1328,7 +1328,7 @@ fn perforate_toolbar_opens_without_running_empty_parameters() {
#[test]
fn perforate_ok_targets_the_picked_sheet_and_updates_flat_data() {
for face in [false, true] {
let mut app = plate(crate::ui_kit::LayoutMode::Docked);
let mut app = plate(crate::shell::LayoutMode::Docked);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
let body = app.session.model.state().sheets[0].body.clone();
app.session.selection =
@@ -1349,7 +1349,7 @@ fn perforate_ok_targets_the_picked_sheet_and_updates_flat_data() {
#[test]
fn perforate_edit_restores_dimensions_pattern_and_target() {
let mut app = plate(crate::ui_kit::LayoutMode::Docked);
let mut app = plate(crate::shell::LayoutMode::Docked);
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
let body = app.session.model.state().sheets[0].body.clone();
app.run("sheet.perforate", json!({"body": body, "diameter": "4 mm", "pitch": "9 mm", "margin": "5 mm", "pattern": "square"})).unwrap();
@@ -1366,7 +1366,7 @@ fn perforate_edit_restores_dimensions_pattern_and_target() {
#[test]
fn flat_pattern_navigation_slots_hide_orbit_controls_and_keep_fit_home() {
let ctx = crate::fonts::test_context();
let mut app = flat_app("fusion", crate::ui_kit::LayoutMode::Floating);
let mut app = flat_app("fusion", crate::shell::LayoutMode::Floating);
app.preferences.navigation_bar = serde_json::from_value(json!({
"slots":["orbit","spin","look_at","projection","visual_style","pan","zoom","fit","home","display"]
}))
+1 -1
View File
@@ -753,7 +753,7 @@ fn label_marked(k: &ConstraintKind, radius_mark: bool, expr: Option<&str>, drive
let plain = expr.is_none_or(|e| {
let e = e.trim();
let e = ["mm", "cm", "in", "ft", "m", "deg", "rad", "°"].iter().find_map(|unit| e.strip_suffix(unit)).unwrap_or(e);
crate::ui_kit::parse_number(e).is_some()
crate::prefs::parse_number(e).is_some()
});
let text = if plain { num } else { format!("fx: {num}") };
if driven { format!("({text})") } else { text }
+1 -1
View File
@@ -1188,7 +1188,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
crate::context_menu::menu_frame(ui, |ui| {
let mut chosen = None;
for (id, label) in items {
let mut item = crate::ui_kit::Item::action(id, label, "");
let mut item = crate::context_menu::Item::action(id, label, "");
if id == "ui.closeTab" && i == app.docs.active {
item = item.key("Ctrl+W");
}
@@ -9,7 +9,6 @@ use syn::{Expr, Item, Lit};
const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
("dialogs.rs", 2, "preview spinners: graphic diameter, not typography"),
("viewport.rs", 1, "viewport status spinner: graphic diameter, not typography"),
("ui_kit/settings_search.rs", 2, "hidden off-screen areas that measure search matches: never drawn"),
];
fn test_only(attrs: &[syn::Attribute]) -> bool {
+4 -4
View File
@@ -101,7 +101,7 @@ pub fn drag_dock_named(ui: &Ui, handle: &str, band: egui::Rect, key: &str, moved
mod tests {
#[test]
fn feature_forms_use_available_height_and_keep_actions_visible() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
use crate::scenario::Harness;
use serde_json::json;
for (width, height, scale) in [(1400.0, 900.0, 1.0), (1400.0, 950.0, 2.0)] {
@@ -167,7 +167,7 @@ mod tests {
use crate::scenario::Harness;
use serde_json::json;
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.call("ui.set", json!({"layout": mode}));
h.call("ui.resize", json!({"width": 2400, "height": 900}));
@@ -214,7 +214,7 @@ mod tests {
/// drags it; reopening keeps its position.
#[test]
fn a_docked_dialog_keeps_its_size_under_the_pointer_and_moves_by_its_title() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
use crate::scenario::Harness;
use serde_json::json;
let mut h = Harness::new();
@@ -254,7 +254,7 @@ mod tests {
fn command_dialogs_keep_the_dragged_position_after_closing() {
use crate::scenario::Harness;
use serde_json::json;
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.call("ui.set", json!({"layout":mode}));
h.call("ui.resize", json!({"width":1400.0,"height":900.0}));
+2 -2
View File
@@ -147,14 +147,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
.into_iter()
.filter(|(id, ..)| *id != "install_cli" || cfg!(target_os = "macos"))
{
let item = crate::ui_kit::Item::action(id, label, icon).on_if(available(app, id));
let item = crate::context_menu::Item::action(id, label, icon).on_if(available(app, id));
if crate::context_menu::list_row(ui, &item).is_some() {
pick = Some(id);
}
// A layout without the version in the application bar lists it here.
if id == "about" && app.header.version == crate::toolbar::VersionPlace::HelpMenu {
let label = crate::i18n::format("help.version", &[("version", crate::update::current_version())]);
if crate::context_menu::list_row(ui, &crate::ui_kit::Item::action("version", &label, "update")).is_some() {
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action("version", &label, "update")).is_some() {
pick = Some("version");
}
}
+1 -2
View File
@@ -9,8 +9,7 @@ use crate::test_support::sample_app;
use std::collections::BTreeSet;
use crate::browser;
use crate::context_menu::{self, Target};
use crate::ui_kit::Item;
use crate::context_menu::{self, Item, Target};
use crate::{Services, SolveApp};
use serde_json::json;
use solidcraft_engine::Session;
+17 -4
View File
@@ -28,6 +28,20 @@ const MAX_URL: usize = 6000;
/// past about 7,600 characters GitHub drops it and the form comes back empty after signing in.
const MAX_SIGN_IN_URL: usize = 7000;
thread_local! {
static CAD_INPUT_BLOCKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Tests set the barrier by hand to see what ignores it.
#[cfg(test)]
pub(crate) fn set_cad_input_blocked_for_test(blocked: bool) {
CAD_INPUT_BLOCKED.with(|cell| cell.set(blocked));
}
pub(crate) fn cad_input_blocked() -> bool {
CAD_INPUT_BLOCKED.with(std::cell::Cell::get)
}
/// Draw CAD behind a report or native picker without delivering input or focus to editors.
/// Fresh focus history also prevents inline editors from committing on report focus loss.
pub(crate) struct CadInput {
@@ -71,15 +85,14 @@ impl CadInput {
*m = neutral;
saved
});
let previous_blocked = crate::ui_kit::input_blocked();
crate::ui_kit::set_input_blocked(true);
let previous_blocked = CAD_INPUT_BLOCKED.with(|blocked| blocked.replace(true));
Some(Self { ctx: ctx.clone(), input: Some(input), memory: Some(memory), previous_blocked })
}
}
impl Drop for CadInput {
fn drop(&mut self) {
crate::ui_kit::set_input_blocked(self.previous_blocked);
CAD_INPUT_BLOCKED.with(|blocked| blocked.set(self.previous_blocked));
if let Some(input) = self.input.take() {
self.ctx.input_mut(|i| *i = input);
}
@@ -1823,7 +1836,7 @@ mod tests {
});
output.textures_delta.clear();
app.issue_report.draft = None;
assert!(!crate::ui_kit::input_blocked());
assert!(!cad_input_blocked());
}
#[test]
+12 -12
View File
@@ -578,7 +578,7 @@ pub fn reset(app: &mut SolveApp, id: Option<&str>) {
/// reset; a key another command uses is reported and taken over only on request.
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let filtering = crate::ui_kit::settings_search::querying(ui);
let filtering = crate::prefs_search::querying(ui);
// A Preferences search lists the matching shortcuts too, and one recorded from there takes
// the key like one on this page (#10043). Only a search field that has the keyboard keeps
// its keys: going back to it to type stops the recording.
@@ -708,8 +708,8 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
let label = app.tr_label(c.label);
let group = shortcut_group(c);
let hit = if filtering {
crate::ui_kit::settings_search::shows_all(ui)
|| crate::ui_kit::settings_search::matches_text(
crate::prefs_search::shows_all(ui)
|| crate::prefs_search::matches_text(
&q,
&[c.label, c.id, label, &group]
.into_iter()
@@ -761,8 +761,8 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::section_heading(ui, group);
let heading = crate::scenario::rect_of(&format!("dialog:heading:{group}"));
if let Some(rect) = heading {
crate::ui_kit::settings_search::remember(ui, group, rect);
crate::ui_kit::settings_search::follow(ui, group, rect);
crate::prefs_search::remember(ui, group, rect);
crate::prefs_search::follow(ui, group, rect);
}
for (command, keys, label) in rows {
if let Some(chosen) = shortcut_row(app, ui, command, keys, label, filtering) {
@@ -777,9 +777,9 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
// Collapsed matches still participate in Up/Down and Enter. Enter lands on
// the real group or command, where follow() reveals its complete row.
for (group, rows) in &groups {
crate::ui_kit::settings_search::remember(ui, group, rect);
crate::prefs_search::remember(ui, group, rect);
for (command, keys, label) in rows {
crate::ui_kit::settings_search::remember(ui, &shortcut_search_label(command, label, keys), rect);
crate::prefs_search::remember(ui, &shortcut_search_label(command, label, keys), rect);
}
}
}
@@ -915,13 +915,13 @@ fn shortcut_row(
let height = if beside { label_height.max(control_height) } else { label_height + control_height + t.space_4 } + t.space_8;
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
let search_label = shortcut_search_label(command, label, keys);
crate::ui_kit::settings_search::follow(ui, &search_label, rect);
crate::prefs_search::follow(ui, &search_label, rect);
if !filtering && !capturing && !ui.is_rect_visible(rect) {
return None;
}
if filtering {
crate::ui_kit::settings_search::reveal(ui);
crate::ui_kit::settings_search::push_force(ui);
crate::prefs_search::reveal(ui);
crate::prefs_search::push_force(ui);
}
let mut action = None;
ui.push_id(command.id, |ui| {
@@ -999,10 +999,10 @@ fn shortcut_row(
});
}
});
crate::ui_kit::settings_search::remember(ui, &search_label, rect);
crate::prefs_search::remember(ui, &search_label, rect);
t.secondary_hline(ui.painter(), rect.x_range(), rect.bottom(), t.dialog_rule);
if filtering {
crate::ui_kit::settings_search::pop_force(ui);
crate::prefs_search::pop_force(ui);
}
action
}
+11 -17
View File
@@ -7,7 +7,7 @@
//! right of the viewport, the command palette and a bottom or left-sidebar feature timeline.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use crate::ui_kit::{Container, Surface, recent_colors};
use crate::ui_kit::{Container, Surface};
pub mod access;
pub mod agent_chat;
@@ -209,6 +209,7 @@ pub(crate) mod plugins;
pub mod point_clouds;
pub mod prefs;
pub mod prefs_notice;
mod prefs_search;
#[cfg(test)]
mod preselect_tests;
pub mod preview;
@@ -221,6 +222,7 @@ mod print_face_ui;
pub(crate) mod quick_measure;
#[cfg(test)]
mod quick_measure_tests;
pub(crate) mod recent_colors;
pub mod recovery_ui;
pub mod ref_images;
pub(crate) mod refused_part;
@@ -347,7 +349,7 @@ pub const ORTHO_FACE_TOLERANCE: f64 = 1.0_f64.to_radians();
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
/// Effective shell mode; automation can override the saved preference for this session.
pub layout: ui_kit::LayoutMode,
pub layout: shell::LayoutMode,
/// Ribbon tab; SHEET METAL and the contextual SKETCH join the seven design tabs.
pub tab: String,
/// The toolbar tab a sketch was started from; finishing the sketch goes back to it (#3250).
@@ -835,12 +837,6 @@ impl Drop for SolveApp {
}
impl SolveApp {
/// Publish this frame's tab accent from the open tab, the sketch being edited and the active
/// tool or dialog, the three values [`theme::TabAccent::publish`] takes.
pub(crate) fn publish_tab_accent(&self, ctx: &egui::Context) {
theme::TabAccent::publish(&self.ui.tab, self.session.active_sketch.is_some(), self.tool.is_some() || self.dialog.is_some(), ctx);
}
pub fn new(mut session: Session, services: Services) -> Self {
#[cfg(test)]
let image_host = Some(solidcraft_engine::render::job::CpuHost::cooperative());
@@ -1489,7 +1485,7 @@ impl SolveApp {
}
if id == "view.layout" {
let layout =
if v.get("mode").and_then(Value::as_str) == Some("compact") { ui_kit::LayoutMode::Docked } else { ui_kit::LayoutMode::Floating };
if v.get("mode").and_then(Value::as_str) == Some("compact") { shell::LayoutMode::Docked } else { shell::LayoutMode::Floating };
self.preferences.layout = layout;
self.ui.layout = layout;
}
@@ -1970,7 +1966,7 @@ impl SolveApp {
return;
}
if id == "view.layout" {
let mode = if self.ui.layout == ui_kit::LayoutMode::Floating { "compact" } else { "floating" };
let mode = if self.ui.layout == shell::LayoutMode::Floating { "compact" } else { "floating" };
let _ = self.run(id, json!({"mode":mode}));
return;
}
@@ -3231,7 +3227,6 @@ impl SolveApp {
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
params_dialog::install_expressions(ctx);
// Shortcuts run here, before `ui` installs the language scope: a tool started by its
// key must build its translated hint in the user's language, not in English.
let _language = crate::i18n::scope(&self.preferences.language);
@@ -3281,7 +3276,7 @@ impl SolveApp {
theme::apply(ctx);
self.styled = true;
}
self.publish_tab_accent(ctx);
theme::TabAccent::publish(self, ctx);
if !self.fitted {
self.fit_view();
self.fitted = true;
@@ -3408,7 +3403,6 @@ impl SolveApp {
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
params_dialog::install_expressions(ui.ctx());
prefs::prepare_blur(self, ui.ctx());
render_workspace::displayed::begin(self);
let blur_source = !self.home.open && !render_workspace::result_active(self);
@@ -3463,14 +3457,14 @@ impl SolveApp {
fn cad_ui(&mut self, ui: &mut egui::Ui, report_input: &mut Option<issue_report::CadInput>) {
if self.embedded {
self.ui.layout = ui_kit::LayoutMode::Docked;
self.ui.layout = shell::LayoutMode::Docked;
self.home.open = false;
self.ui.show_browser = true;
self.ui.show_timeline = true;
}
if self.embed_editor {
// The host shows one design and has its own way to start one.
self.ui.layout = ui_kit::LayoutMode::Docked;
self.ui.layout = shell::LayoutMode::Docked;
self.home.open = false;
}
self.ui.layout.publish(ui.ctx());
@@ -3526,7 +3520,7 @@ impl SolveApp {
if !floating && !self.embedded {
toolbar::toolbar(self, ui);
}
self.publish_tab_accent(ui.ctx());
theme::TabAccent::publish(self, ui.ctx());
browser::publish_row_prefs(self, ui.ctx());
if !floating {
agent_chat::show(self, ui);
@@ -3579,7 +3573,7 @@ impl SolveApp {
self.show_workspace(&mut viewport_ui);
shell::paint_top_corners(ui.painter(), full, theme::Tokens::get().app_bar);
toolbar::toolbar(self, ui);
self.publish_tab_accent(ui.ctx());
theme::TabAccent::publish(self, ui.ctx());
}
if floating && show_timeline && !left_timeline {
timeline::timeline(self, ui);
+1 -1
View File
@@ -292,7 +292,7 @@ mod tests {
(app, component, occurrence, folder, moved)
}
fn menu(app: &SolveApp, target: Target) -> Option<crate::ui_kit::Item> {
fn menu(app: &SolveApp, target: Target) -> Option<context_menu::Item> {
context_menu::entries(app, &target).into_iter().find(|i| i.id == "ui.relinkSource")
}
+1 -1
View File
@@ -2,8 +2,8 @@
use crate::{
dialogs,
scenario::{self, Harness},
shell::LayoutMode,
theme::Tokens,
ui_kit::LayoutMode,
};
use serde_json::json;
use solidcraft_engine::Sel;
+1 -1
View File
@@ -313,7 +313,7 @@ impl PropertyDraft {
}
fn number(value: &str, field: &str) -> Result<f64, String> {
crate::ui_kit::parse_number(value).ok_or_else(|| format!("{field}: {}", tr("material.editor.number_required")))
crate::prefs::parse_number(value).ok_or_else(|| format!("{field}: {}", tr("material.editor.number_required")))
}
fn field(ui: &mut egui::Ui, label: &str, id: &str, value: &mut String, unit: &str) {
+2 -3
View File
@@ -7,8 +7,7 @@ use solidcore_record::Vec3;
use solidcraft_engine::Session;
use solidcraft_engine::geom::prelude::*;
use crate::context_menu::{self, Target};
use crate::ui_kit::Item;
use crate::context_menu::{self, Item, Target};
use crate::viewport::Hit;
use crate::{Services, SolveApp};
@@ -2054,7 +2053,7 @@ fn a_flyout_inside_a_flyout_that_opened_to_the_left_opens_to_the_left() {
use crate::scenario::rect_of;
let (mut h, c) = component_menu();
let right = h.ctx.content_rect().right();
context_menu::open_for(&mut h.app, pos2(right - crate::ui_kit::LIST_W - 50.0, 60.0), Target::Component { id: c });
context_menu::open_for(&mut h.app, pos2(right - context_menu::LIST_W - 50.0, 60.0), Target::Component { id: c });
h.frames(2);
h.step(&json!({"move":{"handle":"menu:ui.group.component"}})).unwrap();
h.frames(2);
+1 -2
View File
@@ -27,9 +27,8 @@ use egui::{Align2, Color32, FontId, Mesh, Painter, Pos2, Rect, Sense, Shape, Str
use serde::{Deserialize, Serialize};
use crate::SolveApp;
use crate::context_menu::{act, cmd};
use crate::context_menu::{Item, act, cmd};
use crate::theme::Tokens;
use crate::ui_kit::Item;
/// Most tools one ring may hold.
pub(crate) const MAX_TOOLS: usize = 12;
+17 -2
View File
@@ -21,6 +21,22 @@ use crate::prefs::{OrbitAround, pivot_under};
use crate::space_mouse::{BridgeMotion, Motion};
use crate::viewport::{Hit, NavMode, axis_rect, nearest_quarter_turn, pick, pick_cached, projection};
/// Take a predominantly horizontal wheel gesture for a hovered scroll viewport.
/// Leave vertical input for its enclosing page, and leave horizontal input
/// unconsumed when this viewport is already at the requested end.
pub(crate) fn take_horizontal_wheel(ui: &egui::Ui, offset: f32, max_offset: f32) -> f32 {
ui.input_mut(|input| {
let delta = input.smooth_scroll_delta;
if delta.x == 0.0 || delta.x.abs() <= delta.y.abs() {
return 0.0;
}
if (offset - delta.x).clamp(0.0, max_offset) != offset {
input.smooth_scroll_delta.x = 0.0;
}
delta.x
})
}
/// What a page's camera may do.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct NavCaps {
@@ -2046,8 +2062,7 @@ mod tests {
];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let dirs = [root.join("src"), root.join("../../apps/solidcraft/src"), root.join("../../apps/solidcraft-web/src")];
// The kit's wheel.rs scrolls strips (a horizontal wheel over a hovered scroll area), not cameras.
let allowed = ["navigation.rs", "wheel.rs"];
let allowed = ["navigation.rs"];
let mut offenders = Vec::new();
let mut scanned = 0;
let mut stack: Vec<std::path::PathBuf> = dirs.iter().filter(|d| d.is_dir()).cloned().collect();
+55 -17
View File
@@ -1,9 +1,5 @@
//! Stored navigation slots and their catalogue. Painting lives in `navigation_bar_view`.
use crate::{
SolveApp,
navigation::NavCaps,
ui_kit::{NavigationEdge, NavigationIdle, NavigationKind, NavigationSize},
};
use crate::{SolveApp, navigation::NavCaps};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -38,9 +34,18 @@ pub(crate) fn visual_style_icon(style: u8) -> &'static str {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Kind {
Action,
Tool,
Toggle,
Menu,
Separator,
}
pub(crate) struct Slot {
pub id: &'static str,
pub kind: NavigationKind,
pub kind: Kind,
pub icon: &'static str,
pub label: &'static str,
pub description: &'static str,
@@ -51,7 +56,7 @@ macro_rules! slot {
($id:literal, $kind:ident, $icon:literal, $command:expr) => {
Slot {
id: $id,
kind: NavigationKind::$kind,
kind: Kind::$kind,
icon: $icon,
label: concat!("navbar.", $id),
description: concat!("navbar.description.", $id),
@@ -119,6 +124,30 @@ pub enum Position {
Right,
}
/// The edge of the 3D view the bar sits on (#8183): the bottom, or the top, under the ribbon.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Edge {
#[default]
Bottom,
Top,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Size {
Compact,
#[default]
Regular,
}
impl Size {
// Logical points: egui's UI zoom applies the preference's scale to these dimensions.
pub(crate) fn points(self) -> f32 {
crate::ui_kit::NavigationMetrics::for_size(self).button
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Labels {
@@ -127,6 +156,15 @@ pub enum Labels {
Labels,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Idle {
Always,
#[default]
Fade,
Hide,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MenuDisplay {
@@ -145,10 +183,10 @@ pub struct NavigationBar {
pub slots: Vec<String>,
pub slot_options: std::collections::BTreeMap<String, SlotOptions>,
pub position: Position,
pub edge: NavigationEdge,
pub size: NavigationSize,
pub edge: Edge,
pub size: Size,
pub labels: Labels,
pub idle: NavigationIdle,
pub idle: Idle,
}
impl Default for NavigationBar {
@@ -160,10 +198,10 @@ impl Default for NavigationBar {
.collect(),
slot_options: Default::default(),
position: Position::default(),
edge: NavigationEdge::default(),
size: NavigationSize::default(),
edge: Edge::default(),
size: Size::default(),
labels: Labels::default(),
idle: NavigationIdle::default(),
idle: Idle::default(),
}
}
}
@@ -195,7 +233,7 @@ impl<'de> Deserialize<'de> for NavigationBar {
}
if let Some(options) = value.get("slot_options").and_then(Value::as_object) {
for (id, option) in options.iter().take(MAX_SLOTS) {
if id.len() <= 128 && find(id).is_some_and(|slot| slot.kind == NavigationKind::Menu) {
if id.len() <= 128 && find(id).is_some_and(|slot| slot.kind == Kind::Menu) {
let shows = option.get("shows").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default();
bar.slot_options.insert(id.clone(), SlotOptions { shows });
}
@@ -236,10 +274,10 @@ pub(crate) fn validate(value: &Value) -> Result<(), String> {
})
}),
"position" => serde_json::from_value::<Position>(value.clone()).is_ok(),
"edge" => serde_json::from_value::<NavigationEdge>(value.clone()).is_ok(),
"size" => serde_json::from_value::<NavigationSize>(value.clone()).is_ok(),
"edge" => serde_json::from_value::<Edge>(value.clone()).is_ok(),
"size" => serde_json::from_value::<Size>(value.clone()).is_ok(),
"labels" => serde_json::from_value::<Labels>(value.clone()).is_ok(),
"idle" => serde_json::from_value::<NavigationIdle>(value.clone()).is_ok(),
"idle" => serde_json::from_value::<Idle>(value.clone()).is_ok(),
_ => return Err(format!("unknown navigation_bar field `{key}`")),
};
if !valid {
+20 -22
View File
@@ -38,7 +38,7 @@ fn slot_catalogue_uses_registered_commands_and_existing_icons() {
Some(command) => assert!(solidcraft_engine::find_command(command).is_some(), "{}", slot.id),
None => assert!(["projection", "display", "separator"].contains(&slot.id)),
}
if slot.kind != NavigationKind::Separator {
if slot.kind != Kind::Separator {
assert!(!crate::icon_svg::resolve(slot.icon).placeholder, "{}: {}", slot.id, slot.icon);
}
}
@@ -136,7 +136,7 @@ fn measured_bar_drops_labels_then_overflows_in_order_without_compass_overlap() {
#[test]
fn measured_bar_honours_size_position_and_ignores_unknown_slots() {
let canvas = Rect::from_min_size(pos2(20.0, 30.0), vec2(700.0, 500.0));
for size in [NavigationSize::Compact, NavigationSize::Regular] {
for size in [Size::Compact, Size::Regular] {
for position in [Position::Centre, Position::Left, Position::Right] {
let prefs: NavigationBar = serde_json::from_value(json!({"slots":["future", "pan", "fit"], "size":size, "position":position})).unwrap();
let bar = measure(canvas, &prefs, NavCaps::MODEL, |_| 0.0);
@@ -156,20 +156,20 @@ fn a_bar_on_the_top_edge_sits_under_the_top_and_keeps_the_corner_controls_below_
use crate::ui_kit::NAV_BOTTOM;
// Older settings have no edge and keep the bar at the bottom; a bad value recovers the same way.
let old: NavigationBar = serde_json::from_value(json!({"position": "right"})).unwrap();
assert_eq!(old.edge, NavigationEdge::Bottom);
assert_eq!(old.edge, Edge::Bottom);
let bad: NavigationBar = serde_json::from_value(json!({"edge": "sideways"})).unwrap();
assert_eq!(bad.edge, NavigationEdge::Bottom);
assert_eq!(bad.edge, Edge::Bottom);
assert!(validate(&json!({"edge": "sideways"})).is_err());
assert!(validate(&json!({"edge": "top"})).is_ok());
let canvas = Rect::from_min_size(pos2(20.0, 30.0), vec2(900.0, 600.0));
for position in [Position::Centre, Position::Left, Position::Right] {
for edge in [NavigationEdge::Bottom, NavigationEdge::Top] {
for edge in [Edge::Bottom, Edge::Top] {
let prefs: NavigationBar = serde_json::from_value(json!({"position": position, "edge": edge})).unwrap();
assert_eq!(serde_json::to_value(&prefs).unwrap()["edge"], if edge == NavigationEdge::Top { "top" } else { "bottom" });
assert_eq!(serde_json::to_value(&prefs).unwrap()["edge"], if edge == Edge::Top { "top" } else { "bottom" });
let bar = measure(canvas, &prefs, NavCaps::MODEL, |_| 50.0);
match edge {
NavigationEdge::Bottom => assert_eq!(bar.rect.bottom(), canvas.bottom() - NAV_BOTTOM),
NavigationEdge::Top => assert_eq!(bar.rect.top(), canvas.top() + NAV_BOTTOM),
Edge::Bottom => assert_eq!(bar.rect.bottom(), canvas.bottom() - NAV_BOTTOM),
Edge::Top => assert_eq!(bar.rect.top(), canvas.top() + NAV_BOTTOM),
}
for corner in [egui::Vec2::ZERO, vec2(120.0, 32.0)] {
let layout =
@@ -187,7 +187,7 @@ fn a_bar_on_the_top_edge_sits_under_the_top_and_keeps_the_corner_controls_below_
#[test]
fn navigation_slot_visuals_never_use_focus_as_a_fill() {
use crate::ui_kit::{NavigationFill, navigation_visual};
for kind in [NavigationKind::Action, NavigationKind::Tool, NavigationKind::Toggle, NavigationKind::Menu] {
for kind in [Kind::Action, Kind::Tool, Kind::Toggle, Kind::Menu] {
for keyboard_focus in [false, true] {
assert_eq!(
navigation_visual(kind, false, false, false, keyboard_focus),
@@ -202,8 +202,8 @@ fn navigation_slot_visuals_never_use_focus_as_a_fill() {
);
}
let active_fill = match kind {
NavigationKind::Action => NavigationFill::None,
NavigationKind::Menu => NavigationFill::Press,
Kind::Action => NavigationFill::None,
Kind::Menu => NavigationFill::Press,
_ => NavigationFill::Active,
};
assert_eq!(
@@ -236,7 +236,7 @@ fn navigation_slot_pointer_clicks_release_focus_and_leave_no_fill() {
fill
}
for kind in [NavigationKind::Action, NavigationKind::Tool, NavigationKind::Toggle] {
for kind in [Kind::Action, Kind::Tool, Kind::Toggle] {
let ctx = crate::fonts::test_context();
let bounds = Rect::from_min_size(pos2(20.0, 30.0), vec2(26.0, 26.0));
let draw = |events, selected| {
@@ -271,7 +271,7 @@ fn navigation_slot_pointer_clicks_release_focus_and_leave_no_fill() {
assert!(!released.1, "{kind:?}: pointer activation must surrender focus");
let away = draw(vec![Event::PointerMoved(pos2(200.0, 200.0))], false);
assert_eq!(fill(&away.0, bounds), None, "{kind:?}: no lingering fill");
if kind != NavigationKind::Action {
if kind != Kind::Action {
let active = draw(vec![], true);
assert_eq!(fill(&active.0, bounds), Some(Tokens::get().selected_bg));
draw(crate::test_support::pointer(bounds.center(), true), true);
@@ -399,7 +399,7 @@ fn navigation_preview_and_viewport_share_the_row_painter() {
&& live != preview
{
let first = live.vertices.first().unwrap().pos;
assert!(bar.slots.iter().any(|(slot, rect)| slot.kind == NavigationKind::Menu && rect.contains(first)));
assert!(bar.slots.iter().any(|(slot, rect)| slot.kind == Kind::Menu && rect.contains(first)));
let delta = preview.vertices.first().unwrap().pos - first;
assert_eq!(delta.y, 0.0);
assert_eq!(delta.x.abs(), crate::ui_kit::NAV_BORDER, "editing menus include their CSS transparent border");
@@ -484,10 +484,8 @@ fn navigation_context_menus_customise_hide_reset_and_remove() {
#[test]
fn navigation_board_metrics_and_state_colors_are_shared_in_both_themes() {
use crate::ui_kit::*;
assert_eq!(NavigationSize::default(), NavigationSize::Regular);
for (size, button, icon, height, radius, button_radius) in
[(NavigationSize::Regular, 32.0, 18.0, 40.0, 10, 7), (NavigationSize::Compact, 26.0, 16.0, 34.0, 9, 6)]
{
assert_eq!(Size::default(), Size::Regular);
for (size, button, icon, height, radius, button_radius) in [(Size::Regular, 32.0, 18.0, 40.0, 10, 7), (Size::Compact, 26.0, 16.0, 34.0, 9, 6)] {
let metrics = NavigationMetrics::for_size(size);
assert_eq!(
(metrics.button, metrics.icon, metrics.height(), metrics.radius, metrics.button_radius),
@@ -515,7 +513,7 @@ fn navigation_board_metrics_and_state_colors_are_shared_in_both_themes() {
assert_eq!(frame.shadow, Container::menu_frame().shadow, "navigation inherits the shared popup elevation");
}
assert_eq!((NAV_POPUP_WIDTH, NAV_CONTEXT_WIDTH, NAV_MORE_WIDTH, NAV_POPUP_GAP), (244.0, 232.0, 200.0, 6.0));
for (idle, opacity) in [(NavigationIdle::Always, 1.0), (NavigationIdle::Fade, 0.45), (NavigationIdle::Hide, 0.0)] {
for (idle, opacity) in [(Idle::Always, 1.0), (Idle::Fade, 0.45), (Idle::Hide, 0.0)] {
assert_eq!(navigation_opacity(idle, false), opacity);
assert_eq!(navigation_opacity(idle, true), 1.0);
}
@@ -587,12 +585,12 @@ fn navigation_board_position_aliases_migrate_and_editor_alignment_follows_positi
let bar: NavigationBar = serde_json::from_value(json!({"position":legacy,"slots":["orbit","pan","fit","look_at","visual_style"]})).unwrap();
assert_eq!(bar.position, position);
assert_eq!(serde_json::to_value(&bar).unwrap()["position"], current);
for size in [NavigationSize::Compact, NavigationSize::Regular] {
for size in [Size::Compact, Size::Regular] {
let bar = NavigationBar { size, ..bar.clone() };
let preview = editor_layout(stage, &bar, |_| 0.0);
let live = measure(stage, &bar, NavCaps::MODEL, |_| 0.0);
assert_eq!(preview.rect.bottom(), stage.bottom() - NAV_BORDER - NAV_STAGE_INSET);
let top = editor_layout(stage, &NavigationBar { edge: NavigationEdge::Top, ..bar.clone() }, |_| 0.0);
let top = editor_layout(stage, &NavigationBar { edge: crate::navigation_bar::Edge::Top, ..bar.clone() }, |_| 0.0);
assert_eq!(top.rect.top(), stage.top() + NAV_BORDER + NAV_STAGE_INSET);
assert_eq!(preview.rect.height(), NavigationMetrics::for_size(size).height());
match position {
@@ -603,7 +601,7 @@ fn navigation_board_position_aliases_migrate_and_editor_alignment_follows_positi
assert_eq!(preview.slots.iter().map(|(slot, _)| slot.id).collect::<Vec<_>>(), bar.slots);
for ((slot, rect), (live_slot, live_rect)) in preview.slots.iter().zip(&live.slots) {
assert_eq!(slot.id, live_slot.id);
assert_eq!(rect.width(), live_rect.width() + if slot.kind == NavigationKind::Menu { 2.0 * NAV_BORDER } else { 0.0 });
assert_eq!(rect.width(), live_rect.width() + if slot.kind == Kind::Menu { 2.0 * NAV_BORDER } else { 0.0 });
}
}
}
+24 -24
View File
@@ -1,9 +1,9 @@
//! Measured single-row navigation bar. Commands own all view changes.
use crate::navigation::NavCaps;
use crate::navigation_bar::{self, Labels, MenuDisplay, NavigationBar, Position, Slot};
use crate::navigation_bar::{self, Edge, Idle, Kind, Labels, MenuDisplay, NavigationBar, Position, Slot};
use crate::theme::Tokens;
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{self, Container, NavigationEdge, NavigationIdle, NavigationKind, Surface};
use crate::ui_kit::{self, Container, Surface};
use crate::{SolveApp, viewport::NavMode};
use egui::{Align2, Rect, pos2, vec2};
use serde_json::json;
@@ -39,9 +39,9 @@ fn measure_row(content: Rect, prefs: &NavigationBar, caps: NavCaps, label_width:
return result;
}
let width = |slot: &Slot, labels, values| {
if slot.kind == NavigationKind::Separator {
if slot.kind == Kind::Separator {
NAV_SEPARATOR
} else if slot.kind == NavigationKind::Menu {
} else if slot.kind == Kind::Menu {
metrics.icon
+ if editing { 2.0 * NAV_BORDER } else { 0.0 }
+ NAV_MENU_LEFT
@@ -91,8 +91,8 @@ fn measure_row(content: Rect, prefs: &NavigationBar, caps: NavCaps, label_width:
};
// The same gap from either edge of the view.
let top = match prefs.edge {
NavigationEdge::Bottom => content.bottom() - height - NAV_BOTTOM,
NavigationEdge::Top => content.top() + NAV_BOTTOM,
navigation_bar::Edge::Bottom => content.bottom() - height - NAV_BOTTOM,
navigation_bar::Edge::Top => content.top() + NAV_BOTTOM,
};
result.rect = Rect::from_min_size(pos2(left, top), vec2(row_width, height));
let mut x = left + inset;
@@ -122,8 +122,8 @@ pub(crate) fn editor_layout(stage: Rect, prefs: &NavigationBar, label_width: imp
};
// The same inset from the top or bottom edge as from the side, as in the live view.
let y = match prefs.edge {
NavigationEdge::Top => stage.top() + ui_kit::NAV_BORDER + ui_kit::NAV_STAGE_INSET,
NavigationEdge::Bottom => stage.bottom() - ui_kit::NAV_BORDER - ui_kit::NAV_STAGE_INSET - bar.rect.height(),
navigation_bar::Edge::Top => stage.top() + ui_kit::NAV_BORDER + ui_kit::NAV_STAGE_INSET,
navigation_bar::Edge::Bottom => stage.bottom() - ui_kit::NAV_BORDER - ui_kit::NAV_STAGE_INSET - bar.rect.height(),
};
let delta = pos2(x, y) - bar.rect.min;
bar.rect = bar.rect.translate(delta);
@@ -249,7 +249,7 @@ fn activate(app: &mut SolveApp, slot: &Slot) {
_ => {
if let Some(command) = slot.command {
let params = match slot.kind {
NavigationKind::Tool if command == "view.tool" => json!({"tool": if selected(app, slot.id) { "none" } else { slot.id }}),
Kind::Tool if command == "view.tool" => json!({"tool": if selected(app, slot.id) { "none" } else { slot.id }}),
_ if slot.id == "ambient_occlusion" => json!({"enabled": !app.preferences.ambient_occlusion}),
_ => json!({}),
};
@@ -259,7 +259,7 @@ fn activate(app: &mut SolveApp, slot: &Slot) {
}
}
fn tooltip(app: &SolveApp, response: egui::Response, slot: &Slot, hidden: Option<&str>, along: Rect, edge: NavigationEdge) -> egui::Response {
fn tooltip(app: &SolveApp, response: egui::Response, slot: &Slot, hidden: Option<&str>, along: Rect, edge: Edge) -> egui::Response {
let key = slot.command.and_then(|id| crate::keymap::display_for(app, id));
let label = if let Some(hidden) = hidden {
app.tr(hidden).to_owned()
@@ -299,7 +299,7 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|| !open.is_empty();
let opacity = ui_kit::navigation_opacity(app.preferences.navigation_bar.idle, active);
let background = ui.interact(bar.rect, ui.scope_id().with("navigation_bar_context"), egui::Sense::click());
let opacity = if app.preferences.navigation_bar.idle == NavigationIdle::Hide {
let opacity = if app.preferences.navigation_bar.idle == Idle::Hide {
ui.ctx().animate_value_with_time(menu_id.with("opacity"), opacity, if active { ui_kit::NAV_FADE_SECONDS } else { 0.0 })
} else {
opacity
@@ -311,8 +311,8 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let bounds = response.rect;
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), bounds);
context_menu(app, &response, Some(index));
if response.clicked() && slot.kind != NavigationKind::Separator {
if slot.kind == NavigationKind::Menu {
if response.clicked() && slot.kind != Kind::Separator {
if slot.kind == Kind::Menu {
open = if open == slot.id { String::new() } else { slot.id.to_owned() };
} else {
activate(app, slot);
@@ -331,7 +331,7 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
bar.more,
ui_kit::NavigationButton {
id: "more",
kind: NavigationKind::Menu,
kind: Kind::Menu,
icon: "dots",
label: app.tr("navbar.more"),
selected: open == "more" || bar.overflow.iter().any(|slot| slot.id == open),
@@ -353,8 +353,8 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let ctx = ui.ctx().clone();
// Menus open into the view: up from a bar at the bottom, down from one at the top.
let (pivot, at) = match app.preferences.navigation_bar.edge {
NavigationEdge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
NavigationEdge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
Edge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
Edge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
};
let menu = Container::new(&ctx, Surface::Popup).area(menu_id.with("popup")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(at).show(
&ctx,
@@ -426,7 +426,7 @@ pub(crate) fn paint_row(
} else if editing.is_none() && slot.id == "projection" && projection_locked(app) {
ui.set_opacity(ui.visuals().disabled_alpha);
}
if slot.kind == NavigationKind::Separator {
if slot.kind == Kind::Separator {
ui_kit::navigation_separator(ui, *bounds);
ui.interact(
*bounds,
@@ -440,14 +440,14 @@ pub(crate) fn paint_row(
icon: slot.current_icon(app.ui.visual_style),
label: slot_text(app, slot),
selected: if editing.is_some() { false } else { selected(app, slot.id) || open == slot.id },
labels: if slot.kind == NavigationKind::Menu {
labels: if slot.kind == Kind::Menu {
bar.values
&& slot.id == "visual_style"
&& app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconAndValue
} else {
bar.labels
},
menu: slot.kind == NavigationKind::Menu,
menu: slot.kind == Kind::Menu,
};
if editing.is_some() {
ui_kit::navigation_button_editable(ui, *bounds, button, true)
@@ -472,7 +472,7 @@ pub(crate) fn paint_row(
});
}
// The settings' preview keeps its tips above, wherever the bar sits in the view.
let edge = if editing.is_some() { NavigationEdge::Bottom } else { app.preferences.navigation_bar.edge };
let edge = if editing.is_some() { Edge::Bottom } else { app.preferences.navigation_bar.edge };
responses.push((index, *slot, tooltip(app, response, slot, hidden, bar.rect, edge)));
}
responses
@@ -534,7 +534,7 @@ fn style_menu(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
fn overflow_menu(app: &mut SolveApp, ui: &mut egui::Ui, slots: &[&Slot], open: &mut String) {
for slot in slots {
if slot.kind == NavigationKind::Separator {
if slot.kind == Kind::Separator {
let start = ui.next_widget_position();
ui_kit::navigation_menu_separator(ui);
let bounds = Rect::from_min_max(start, pos2(start.x + ui.available_width(), ui.next_widget_position().y));
@@ -542,7 +542,7 @@ fn overflow_menu(app: &mut SolveApp, ui: &mut egui::Ui, slots: &[&Slot], open: &
continue;
}
let mut item = ui_kit::NavigationMenuRow::new(slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style));
if matches!(slot.kind, NavigationKind::Tool | NavigationKind::Toggle) {
if matches!(slot.kind, Kind::Tool | Kind::Toggle) {
item.selected = selected(app, slot.id);
}
let response = item.show(ui);
@@ -552,8 +552,8 @@ fn overflow_menu(app: &mut SolveApp, ui: &mut egui::Ui, slots: &[&Slot], open: &
if slot.id == "display" {
crate::scenario::publish_handle("nav:view", response.rect.center());
}
if tooltip(app, response.clone(), slot, None, response.rect, NavigationEdge::Bottom).clicked() {
if slot.kind == NavigationKind::Menu {
if tooltip(app, response.clone(), slot, None, response.rect, Edge::Bottom).clicked() {
if slot.kind == Kind::Menu {
*open = slot.id.to_owned();
} else {
activate(app, slot);
+1 -1
View File
@@ -975,7 +975,7 @@ impl crate::SolveApp {
self.shell.history_dock_space = None;
self.shell.history_folding = None;
self.shell.history_fold_motion = None;
self.publish_tab_accent(ui.ctx());
crate::theme::TabAccent::publish(self, ui.ctx());
crate::browser::publish_row_prefs(self, ui.ctx());
let mut placement = render_domain;
self.shell.status_bar = if crate::status_bar::shown(self) {
+7 -7
View File
@@ -1273,7 +1273,7 @@ fn allocation_invisible_physical_edges_and_measured_tab_halves_preserve_rtl_and_
#[test]
fn allocation_shared_shell_gap_controls_allocation_and_keeps_compact_sashes_and_free_origins() {
for mode in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for mode in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
for direction in [Direction::Ltr, Direction::Rtl] {
for zone in [Zone::Left, Zone::Right] {
let gap = crate::ui_kit::Container::for_layout(mode, crate::ui_kit::Surface::Panel).gap();
@@ -3190,13 +3190,13 @@ fn raw_host_wider_perimeters_win_over_cards_tabs_and_clear_below_without_fallbac
// Exercise both actual shell gaps without multiplying all
// selection cases by an unrelated rendering-mode matrix.
let mode = if matches!(case, "side-edge" | "bottom-clear" | "empty") {
crate::ui_kit::LayoutMode::Docked
crate::shell::LayoutMode::Docked
} else {
crate::ui_kit::LayoutMode::Floating
crate::shell::LayoutMode::Floating
};
mode.publish(&raw.ctx);
let gap = crate::ui_kit::Container::for_layout(mode, crate::ui_kit::Surface::Panel).gap();
assert_eq!(gap, if mode == crate::ui_kit::LayoutMode::Docked { 0.0 } else { 7.0 });
assert_eq!(gap, if mode == crate::shell::LayoutMode::Docked { 0.0 } else { 7.0 });
let mut options = priority_host_options();
options.metrics.gap = gap;
options.metrics.inline_max_height = 0.0;
@@ -3434,7 +3434,7 @@ fn raw_host_perimeter_anchors_survive_source_detach_and_commit_the_exact_preview
#[test]
fn raw_host_drag_and_pair_resize_dispatch_once_even_when_egui_repeats_the_release_pass() {
for (mode, resize) in
[(crate::ui_kit::LayoutMode::Floating, false), (crate::ui_kit::LayoutMode::Floating, true), (crate::ui_kit::LayoutMode::Docked, true)]
[(crate::shell::LayoutMode::Floating, false), (crate::shell::LayoutMode::Floating, true), (crate::shell::LayoutMode::Docked, true)]
{
let h = host_logs(2, Zone::Left);
let mut raw = RawPanelHost::new(h, 2.0);
@@ -12384,7 +12384,7 @@ fn app_atomic_workspace_replacements_accept_distinct_incoming_contexts_and_match
#[test]
fn captured_renderer_alpha_reaches_new_sections_and_alpha_only_drift_invalidates_owned_geometry() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let (mut h, _, _) = boundary_app();
h.app.ui.layout = mode;
h.frames(2);
@@ -12402,7 +12402,7 @@ fn captured_renderer_alpha_reaches_new_sections_and_alpha_only_drift_invalidates
assert_eq!(card.paint_alpha, Some(255), "Classic's default opaque policy is preserved");
}
let gap = crate::ui_kit::Container::for_layout(mode, crate::ui_kit::Surface::Panel).gap();
assert_eq!(gap, if mode == crate::ui_kit::LayoutMode::Floating { 7.0 } else { 0.0 });
assert_eq!(gap, if mode == crate::shell::LayoutMode::Floating { 7.0 } else { 0.0 });
assert_eq!(painted.metrics.gap, gap);
assert_eq!(painted.allocation_environment.gap, gap);
let domain = egui_rect(painted.render_domain);
-26
View File
@@ -1024,32 +1024,6 @@ pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) ->
crate::expression_complete::complete(ui, resp, text) || crate::value_step::step(ui, resp, text)
}
/// SolidCraft's expression language for the kit's value fields: literals and units as
/// `doc::expr` reads them, completion and stepping by [`complete`].
pub(crate) struct DocExpressions;
impl ui_kit::Expressions for DocExpressions {
fn is_literal(&self, text: &str) -> bool {
solidcraft_engine::doc::expr::is_literal(text)
}
fn is_unit(&self, unit: &str) -> bool {
solidcraft_engine::doc::expr::unit_info(unit).is_some()
}
fn complete(&self, ui: &mut egui::Ui, response: &egui::Response, text: &mut String) -> bool {
complete(ui, response, text)
}
}
/// Give the kit's value fields in `ctx` SolidCraft's expressions, unless they have them already.
/// The app calls this at the start of each frame entry point, so every context it draws into has them.
pub(crate) fn install_expressions(ctx: &egui::Context) {
if ui_kit::expressions(ctx).is_none() {
ui_kit::install_expressions(ctx, Arc::new(DocExpressions));
}
}
#[cfg(test)]
mod tests {
use super::*;
+15 -15
View File
@@ -157,7 +157,7 @@ fn page_in_dir(app: &mut SolveApp, ui: &mut egui::Ui, dir: Option<&Path>) {
};
let t = crate::theme::Tokens::get();
let path = dir.display().to_string();
let searching = crate::ui_kit::settings_search::querying(ui);
let searching = crate::prefs_search::querying(ui);
// The page's own box is for browsing Plugins. The dialog search is the filter while it is active.
if !searching {
crate::ui_kit::search_field(
@@ -176,26 +176,26 @@ fn page_in_dir(app: &mut SolveApp, ui: &mut egui::Ui, dir: Option<&Path>) {
crate::i18n::tr("prefs.plugins_installed"),
Some(&plugins.len().to_string()),
|ui| {
if plugins.is_empty() && (!searching || crate::ui_kit::settings_search::shows_all(ui)) {
if plugins.is_empty() && (!searching || crate::prefs_search::shows_all(ui)) {
crate::ui_kit::text(ui, crate::i18n::tr("ui.plugins_empty"), crate::ui_kit::Text::Hint("plugins_empty"));
}
ui.spacing_mut().item_spacing.y = t.space_8;
let mut shown = 0usize;
for plugin in plugins.iter().filter(|plugin| searching || matches_query(plugin, &query)) {
if searching
&& !crate::ui_kit::settings_search::shows_all(ui)
&& !crate::ui_kit::settings_search::texts_hit(ui, &[&plugin.name, &plugin.id, &plugin.description, &plugin.version])
&& !crate::prefs_search::shows_all(ui)
&& !crate::prefs_search::texts_hit(ui, &[&plugin.name, &plugin.id, &plugin.description, &plugin.version])
{
continue;
}
shown += 1;
if searching {
crate::ui_kit::settings_search::reveal(ui);
crate::ui_kit::settings_search::push_force(ui);
crate::prefs_search::reveal(ui);
crate::prefs_search::push_force(ui);
}
plugin_card(app, ui, dir, plugin);
if searching {
crate::ui_kit::settings_search::pop_force(ui);
crate::prefs_search::pop_force(ui);
}
}
if !searching && !plugins.is_empty() && shown == 0 {
@@ -257,11 +257,11 @@ fn plugin_card(app: &mut SolveApp, ui: &mut egui::Ui, dir: &Path, plugin: &Plugi
}
crate::scenario::publish_rect(&format!("prefs:plugin:{}:card", plugin.id), response.rect);
let texts = [&plugin.name, &plugin.id, &plugin.description, &plugin.version];
if let Some(text) = texts.iter().find(|text| crate::ui_kit::settings_search::texts_hit(ui, &[*text])) {
crate::ui_kit::settings_search::remember(ui, text, response.rect);
if let Some(text) = texts.iter().find(|text| crate::prefs_search::texts_hit(ui, &[*text])) {
crate::prefs_search::remember(ui, text, response.rect);
}
for text in texts {
crate::ui_kit::settings_search::follow(ui, text, response.rect);
crate::prefs_search::follow(ui, text, response.rect);
}
}
@@ -458,17 +458,17 @@ mod tests {
crate::scenario::clear_handles();
let mut output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
ui.set_width(646.0);
let _search = crate::ui_kit::settings_search::begin(ui, query);
crate::ui_kit::settings_search::begin_page(ui, crate::prefs::PAGE_PLUGINS, "Extend > Plugins");
let _search = crate::prefs_search::begin(ui, query);
crate::prefs_search::begin_page(ui, crate::prefs::PAGE_PLUGINS, "Extend > Plugins");
crate::ui_kit::scope(ui, |ui| page_in_dir(&mut h.app, ui, Some(&dir)));
crate::ui_kit::settings_search::store_nav(ui);
crate::prefs_search::store_nav(ui);
});
output.textures_delta.clear();
}
let (_, _, hits) = crate::ui_kit::settings_search::nav(&ctx);
let (_, _, hits) = crate::prefs_search::nav(&ctx);
assert_eq!(hits.len(), 1, "{query}: one real plugin result");
assert_eq!(hits[0].page, crate::prefs::PAGE_PLUGINS);
crate::ui_kit::settings_search::set_reveal(&ctx, Some(crate::ui_kit::settings_search::reveal_for(hits[0].page, &hits[0].label)));
crate::prefs_search::set_reveal(&ctx, Some(crate::prefs_search::reveal_for(hits[0].page, &hits[0].label)));
page_frame(&mut h.app, &ctx, &dir, vec![]);
let card = crate::scenario::rect_of("prefs:plugin:public-one:card").unwrap();
assert!(crate::scenario::handle_at("prefs:revealed").unwrap().distance(card.center()) <= 1.0);
+68 -61
View File
@@ -467,11 +467,11 @@ fn search_header(ui: &mut egui::Ui, app: &mut SolveApp) {
}
crate::scenario::publish_handle("prefs:search", field.rect.center());
if field.has_focus() {
crate::ui_kit::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
settle_search(app);
crate::ui_kit::settings_search::set_reveal(ui.ctx(), app.prefs_window.reveal.clone());
crate::ui_kit::settings_search::set_force_scroll(ui.ctx(), !app.prefs_window.filter.trim().is_empty());
crate::prefs_search::set_reveal(ui.ctx(), app.prefs_window.reveal.clone());
crate::prefs_search::set_force_scroll(ui.ctx(), !app.prefs_window.filter.trim().is_empty());
}
/// Keys for the search field. Shortcut capture keeps the key for itself.
@@ -484,7 +484,7 @@ fn drive_search(ui: &mut egui::Ui, app: &mut SolveApp) -> bool {
if ctx.input_mut(|input| input.consume_key(egui::Modifiers::COMMAND, egui::Key::F)) {
ctx.memory_mut(|memory| memory.request_focus(id));
}
let sink = crate::ui_kit::settings_search::key_sink(&ctx);
let sink = crate::prefs_search::key_sink(&ctx);
let mut typed = false;
if !sink {
let modifiers = ctx.input(|input| input.modifiers);
@@ -511,20 +511,20 @@ fn drive_search(ui: &mut egui::Ui, app: &mut SolveApp) -> bool {
if query.is_empty() {
return typed;
}
let (nav_query, cursor, hits) = crate::ui_kit::settings_search::nav(&ctx);
let (nav_query, cursor, hits) = crate::prefs_search::nav(&ctx);
let fresh = nav_query == query;
let cursor = if fresh { cursor.min(hits.len().saturating_sub(1)) } else { 0 };
if ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)) && fresh && !hits.is_empty() {
crate::ui_kit::settings_search::set_cursor(&ctx, (cursor + 1).min(hits.len() - 1));
crate::prefs_search::set_cursor(&ctx, (cursor + 1).min(hits.len() - 1));
} else if ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)) && fresh && !hits.is_empty() {
crate::ui_kit::settings_search::set_cursor(&ctx, cursor.saturating_sub(1));
crate::prefs_search::set_cursor(&ctx, cursor.saturating_sub(1));
} else if fresh && !hits.is_empty() && ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
if let Some(hit) = hits.get(cursor) {
open_result(app, hit.page, &hit.label);
ctx.memory_mut(|memory| memory.surrender_focus(id));
}
} else {
crate::ui_kit::settings_search::set_cursor(&ctx, cursor);
crate::prefs_search::set_cursor(&ctx, cursor);
}
if !app.prefs_window.filter.trim().is_empty() && ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
app.prefs_window.filter.clear();
@@ -551,7 +551,7 @@ fn open_result(app: &mut SolveApp, page: usize, label: &str) {
app.prefs_window.search_from = None;
app.prefs_window.section = page;
app.prefs_window.land_on = Some(page);
app.prefs_window.reveal = Some(crate::ui_kit::settings_search::reveal_for(page, label));
app.prefs_window.reveal = Some(crate::prefs_search::reveal_for(page, label));
}
/// An empty field returns to the page that was up when the search started.
@@ -1297,7 +1297,7 @@ preference_settings! {
/// on every start (#10138). A saved language keeps winning, so existing users don't change.
language: String = "system".into(), 0;
/// Shared workspace composition; system windows and popups retain their purpose.
layout: crate::ui_kit::LayoutMode = Default::default(), PAGE_LAYOUT;
layout: crate::shell::LayoutMode = Default::default(), PAGE_LAYOUT;
/// Ordered view controls and their appearance.
#[serde(default)]
navigation_bar: crate::navigation_bar::NavigationBar = Default::default(), PAGE_LAYOUT;
@@ -1802,7 +1802,7 @@ pub struct PrefsWindow {
/// Set when a result is chosen, so the page draw does not put the previous tab back.
land_on: Option<usize>,
/// The row to scroll to after a result is chosen.
reveal: Option<crate::ui_kit::settings_search::Reveal>,
reveal: Option<crate::prefs_search::Reveal>,
/// Where the theme Import tile was drawn last frame: a file dropped there is a theme (#8226).
import_tile: Option<egui::Rect>,
}
@@ -1902,7 +1902,7 @@ struct Snapshot {
/// With no record yet, keep the small deferred legacy import state too.
panel_layout_deferred: Option<crate::panel_runtime::PanelLayoutPreferences>,
dark: bool,
layout: crate::ui_kit::LayoutMode,
layout: crate::shell::LayoutMode,
grid: bool,
navigation_bar: bool,
tool_cursor: bool,
@@ -2525,6 +2525,16 @@ pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2
if b.is_empty() { app.cam.target } else { b.center() }
}
/// A plain number typed into a field, with a decimal point or a decimal comma (#1347): "2.5"
/// and "2,5" are both 2.5, as a numeric keypad types either depending on the locale. Spaces are
/// ignored and "−" is a minus, as in egui's own parser. A comma beside a point ("1.234,5") or a
/// second comma is not guessed at: the text is not a number.
pub fn parse_number(text: &str) -> Option<f64> {
let t: String = text.chars().filter(|c| !c.is_whitespace()).map(|c| if c == '\u{2212}' { '-' } else { c }).collect();
let t = if !t.contains('.') && t.matches(',').count() == 1 { t.replacen(',', ".", 1) } else { t };
t.parse::<f64>().ok().filter(|v| v.is_finite())
}
// Existing callers retain their numeric-editor API; kit owns the widget.
/// A length typed into a field, in millimetres: a bare number is in the design's units (`scale`
@@ -2702,7 +2712,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.prefs_window.search_from = None;
app.prefs_window.land_on = None;
app.prefs_window.reveal = None;
crate::ui_kit::settings_search::set_reveal(ctx, None);
crate::prefs_search::set_reveal(ctx, None);
app.prefs_window.snapshot = None;
// A question left up when the dialog closed is not still asked when it opens again.
app.space_mouse.restore_pending = false;
@@ -2720,8 +2730,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
{
app.prefs_window.reveal = None;
}
let sink = crate::ui_kit::settings_search::shift_key_sink(ctx);
crate::ui_kit::settings_search::set_key_sink_prev(ctx, sink);
let sink = crate::prefs_search::shift_key_sink(ctx);
crate::prefs_search::set_key_sink_prev(ctx, sink);
let mut button: Option<&str> = None;
let before = app.preferences.clone();
let capturing = crate::keymap::capturing(app);
@@ -2894,38 +2904,38 @@ fn panel_restore_banner(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
panel_restore_banner(app, ui, t);
let _search = crate::ui_kit::settings_search::begin(ui, &app.prefs_window.filter);
if crate::ui_kit::settings_search::querying(ui) {
let _search = crate::prefs_search::begin(ui, &app.prefs_window.filter);
if crate::prefs_search::querying(ui) {
// Paint only the pages that match. A first pass is invisible, so the 3D mouse rows and the
// agent list cannot draw on top of the setting that did match.
let mut keep = vec![false; WINDOW_PAGES.len()];
for (index, _) in WINDOW_PAGES.iter().enumerate() {
crate::ui_kit::settings_search::begin_page(ui, index, &page_path(index));
let before = crate::ui_kit::settings_search::hits(ui);
let mut probe = crate::ui_kit::settings_search::detached_child(ui, ("prefs-search-probe", index), true);
crate::prefs_search::begin_page(ui, index, &page_path(index));
let before = crate::prefs_search::hits(ui);
let mut probe = crate::prefs_search::detached_child(ui, ("prefs-search-probe", index), true);
draw_page(app, &mut probe, index, t);
keep[index] = crate::ui_kit::settings_search::hits(ui) > before;
keep[index] = crate::prefs_search::hits(ui) > before;
}
crate::ui_kit::settings_search::rewind_search_layout(ui);
crate::prefs_search::rewind_search_layout(ui);
for (index, _) in WINDOW_PAGES.iter().enumerate() {
if !keep.get(index).copied().unwrap_or(false) {
continue;
}
crate::ui_kit::settings_search::begin_page(ui, index, &page_path(index));
let before = crate::ui_kit::settings_search::hits(ui);
let mut child = crate::ui_kit::settings_search::detached_child(ui, ("prefs-search-page", index), false);
crate::prefs_search::begin_page(ui, index, &page_path(index));
let before = crate::prefs_search::hits(ui);
let mut child = crate::prefs_search::detached_child(ui, ("prefs-search-page", index), false);
draw_page(app, &mut child, index, t);
crate::ui_kit::settings_search::commit_search_child(ui, &child, before);
crate::prefs_search::commit_search_child(ui, &child, before);
}
if crate::ui_kit::settings_search::hits(ui) == 0 {
if crate::prefs_search::hits(ui) == 0 {
ui.label(egui::RichText::new(crate::i18n::tr("prefs.search_empty")).size(t.font_body).color(t.form_dim));
}
} else {
let label = page_path(app.prefs_window.section);
crate::ui_kit::settings_search::begin_page(ui, app.prefs_window.section, &label);
crate::prefs_search::begin_page(ui, app.prefs_window.section, &label);
draw_page(app, ui, app.prefs_window.section, t);
}
crate::ui_kit::settings_search::store_nav(ui);
crate::prefs_search::store_nav(ui);
}
fn draw_page(app: &mut SolveApp, ui: &mut egui::Ui, index: usize, t: &Tokens) {
@@ -4639,8 +4649,7 @@ mod tests {
#[test]
fn navigation_bar_preferences_round_trip_and_recover_hostile_fields() {
use crate::navigation_bar::{Labels, NavigationBar, Position};
use crate::ui_kit::{NavigationEdge, NavigationIdle, NavigationSize};
use crate::navigation_bar::{Edge, Idle, Labels, NavigationBar, Position, Size};
use serde_json::json;
assert_eq!(super::lenient(&json!({})).navigation_bar, NavigationBar::default());
let p = super::lenient(&json!({"navigation_bar": {
@@ -4650,7 +4659,7 @@ mod tests {
assert_eq!(p.navigation_bar.slots, ["pan", "future_slot", "separator", "separator", "separator", "separator", "fit"]);
assert_eq!(
(p.navigation_bar.position, p.navigation_bar.edge, p.navigation_bar.size, p.navigation_bar.labels, p.navigation_bar.idle),
(Position::Left, NavigationEdge::Top, NavigationSize::Regular, Labels::Labels, NavigationIdle::Hide)
(Position::Left, Edge::Top, Size::Regular, Labels::Labels, Idle::Hide)
);
let saved = serde_json::to_value(&p).unwrap();
assert_eq!(serde_json::from_value::<super::Prefs>(saved).unwrap(), p);
@@ -4660,10 +4669,10 @@ mod tests {
let mut expected = p.clone();
match field {
"position" => expected.navigation_bar.position = Position::default(),
"edge" => expected.navigation_bar.edge = NavigationEdge::default(),
"size" => expected.navigation_bar.size = NavigationSize::default(),
"edge" => expected.navigation_bar.edge = Edge::default(),
"size" => expected.navigation_bar.size = Size::default(),
"labels" => expected.navigation_bar.labels = Labels::default(),
_ => expected.navigation_bar.idle = NavigationIdle::default(),
_ => expected.navigation_bar.idle = Idle::default(),
}
assert_eq!(super::lenient(&value), expected, "{field}");
value["navigation_bar"][field] = json!("unknown");
@@ -4727,8 +4736,7 @@ mod tests {
#[test]
fn navigation_bar_editor_buttons_and_appearance_update_preferences() {
use crate::navigation_bar::{Labels, MenuDisplay, Position};
use crate::ui_kit::{NavigationIdle, NavigationSize};
use crate::navigation_bar::{Idle, Labels, MenuDisplay, Position, Size};
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.call("ui.resize", json!({"width":1600,"height":1100}));
@@ -4745,20 +4753,20 @@ mod tests {
assert_eq!(h.app.preferences.navigation_bar.menu_display("visual_style"), MenuDisplay::IconAndValue);
navbar_click(&mut h, "prefs:navbar:anchor:0");
assert_eq!(h.app.preferences.navigation_bar.position, Position::Left);
assert_eq!(h.app.preferences.navigation_bar.edge, crate::ui_kit::NavigationEdge::Top);
assert_eq!(h.app.preferences.navigation_bar.edge, crate::navigation_bar::Edge::Top);
navbar_click(&mut h, "prefs:navbar:anchor:5");
assert_eq!(h.app.preferences.navigation_bar.position, Position::Right);
assert_eq!(h.app.preferences.navigation_bar.edge, crate::ui_kit::NavigationEdge::Bottom);
assert_eq!(h.app.preferences.navigation_bar.edge, crate::navigation_bar::Edge::Bottom);
navbar_click(&mut h, "prefs:navbar:size:0");
assert_eq!(h.app.preferences.navigation_bar.size, NavigationSize::Compact);
assert_eq!(h.app.preferences.navigation_bar.size, Size::Compact);
navbar_click(&mut h, "prefs:navbar:size:1");
assert_eq!(h.app.preferences.navigation_bar.size, NavigationSize::Regular);
assert_eq!(h.app.preferences.navigation_bar.size, Size::Regular);
navbar_click(&mut h, "prefs:navbar:buttons:1");
assert_eq!(h.app.preferences.navigation_bar.labels, Labels::Labels);
navbar_click(&mut h, "prefs:navbar:idle:0");
assert_eq!(h.app.preferences.navigation_bar.idle, NavigationIdle::Always);
assert_eq!(h.app.preferences.navigation_bar.idle, Idle::Always);
navbar_click(&mut h, "prefs:navbar:idle:2");
assert_eq!(h.app.preferences.navigation_bar.idle, NavigationIdle::Hide);
assert_eq!(h.app.preferences.navigation_bar.idle, Idle::Hide);
let before = h.app.preferences.navigation_bar.clone();
navbar_click(&mut h, "prefs:navbar:preview_as:1");
assert_eq!(h.app.preferences.navigation_bar, before, "canvas preview is transient");
@@ -4974,19 +4982,18 @@ mod tests {
#[test]
fn navigation_bar_preview_stage_follows_anchor_live() {
use crate::navigation_bar::Position;
use crate::ui_kit::NavigationEdge;
use crate::navigation_bar::{Edge, Position};
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.call("ui.resize", json!({"width":1600,"height":1100}));
h.call("ui.prefs", json!({"open":"Interface/Navigation bar","navigation_bar":{"slots":["pan","fit","display"]}}));
for (index, edge, position) in [
(0, NavigationEdge::Top, Position::Left),
(1, NavigationEdge::Top, Position::Centre),
(2, NavigationEdge::Top, Position::Right),
(3, NavigationEdge::Bottom, Position::Left),
(4, NavigationEdge::Bottom, Position::Centre),
(5, NavigationEdge::Bottom, Position::Right),
(0, Edge::Top, Position::Left),
(1, Edge::Top, Position::Centre),
(2, Edge::Top, Position::Right),
(3, Edge::Bottom, Position::Left),
(4, Edge::Bottom, Position::Centre),
(5, Edge::Bottom, Position::Right),
] {
navbar_click(&mut h, &format!("prefs:navbar:anchor:{index}"));
h.frames(3);
@@ -4997,11 +5004,11 @@ mod tests {
assert_eq!(stage.height(), crate::ui_kit::NAV_STAGE_HEIGHT);
let inset = crate::ui_kit::NAV_BORDER + crate::ui_kit::NAV_STAGE_INSET;
match edge {
NavigationEdge::Top => {
Edge::Top => {
assert_eq!(bar.top(), stage.top() + inset);
assert!(count.top() >= bar.bottom(), "the count sits below a top bar");
}
NavigationEdge::Bottom => {
Edge::Bottom => {
assert_eq!(bar.bottom(), stage.bottom() - inset);
assert!(count.bottom() <= bar.top(), "the count sits above a bottom bar");
}
@@ -6590,7 +6597,7 @@ mod tests {
app.autosave_minutes = 9.0;
app.ui.dark = false;
app.prefs_window.follow.launch = Some("theme.json".into());
app.ui.layout = crate::ui_kit::LayoutMode::Docked;
app.ui.layout = crate::shell::LayoutMode::Docked;
app.ui.show_navigation_bar = false;
app.ui.tool_cursor = true;
app.ui.visual_style = 2;
@@ -6605,7 +6612,7 @@ mod tests {
assert_eq!(app.autosave_minutes, if page == PAGE_GENERAL { 5.0 } else { 9.0 });
assert_eq!(app.ui.dark, page == PAGE_APPEARANCE);
assert_eq!(app.prefs_window.follow.launch.is_none(), page == PAGE_APPEARANCE);
assert_eq!(app.ui.layout == crate::ui_kit::LayoutMode::Floating, page == PAGE_LAYOUT);
assert_eq!(app.ui.layout == crate::shell::LayoutMode::Floating, page == PAGE_LAYOUT);
assert_eq!(app.ui.show_navigation_bar, page == PAGE_LAYOUT);
assert_eq!(app.ui.tool_cursor, page != PAGE_TOOLS);
assert_eq!(app.ui.visual_style, if page == PAGE_GRAPHICS { 0 } else { 2 });
@@ -8636,13 +8643,13 @@ mod tests {
#[test]
fn number_fields_take_a_decimal_point_or_a_decimal_comma() {
// #1347
assert_eq!(crate::ui_kit::parse_number("2.5"), Some(2.5));
assert_eq!(crate::ui_kit::parse_number("2,5"), Some(2.5));
assert_eq!(crate::ui_kit::parse_number(" -0,25 "), Some(-0.25));
assert_eq!(crate::ui_kit::parse_number("\u{2212}1,5"), Some(-1.5));
assert_eq!(crate::ui_kit::parse_number("12"), Some(12.0));
assert_eq!(super::parse_number("2.5"), Some(2.5));
assert_eq!(super::parse_number("2,5"), Some(2.5));
assert_eq!(super::parse_number(" -0,25 "), Some(-0.25));
assert_eq!(super::parse_number("\u{2212}1,5"), Some(-1.5));
assert_eq!(super::parse_number("12"), Some(12.0));
for bad in ["1.234,5", "1,2,3", ",", "abc", "", "inf", "NaN"] {
assert_eq!(crate::ui_kit::parse_number(bad), None, "{bad}");
assert_eq!(super::parse_number(bad), None, "{bad}");
}
}
+3 -3
View File
@@ -94,7 +94,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let response = setting(app, ui).backing_on_hover_text(app.tr(help));
crate::scenario::publish_target(&format!("prefs:{target}"), response.rect);
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::help_icon(ui, app.tr(help));
}
});
@@ -141,7 +141,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
let response = setting::offer_stand_up(app, ui).backing_on_hover_text(app.tr("prefs.stand_up_note"));
crate::scenario::publish_target("prefs:offer_stand_up", response.rect);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::help_icon(ui, app.tr("prefs.stand_up_note"));
}
});
@@ -179,7 +179,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let response = setting::double_click_components(app, ui).backing_on_hover_text(app.tr("prefs.design_tools.double_click_help"));
crate::scenario::publish_target("prefs:double_click_components", response.rect);
crate::scenario::publish_target("dialog:Double-click a component to activate it, or to edit a linked one in place", response.rect);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::help_icon(ui, app.tr("prefs.design_tools.double_click_help"));
}
});
+1 -1
View File
@@ -434,7 +434,7 @@ fn paint_monitor(p: &egui::Painter, rect: egui::Rect, ink: egui::Color32) {
/// shows the rows without it, like every other card.
fn computer_card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get();
if crate::ui_kit::settings_search::suppress_chrome(ui) {
if crate::prefs_search::suppress_chrome(ui) {
add(ui);
return;
}
+4 -4
View File
@@ -229,7 +229,7 @@ pub(super) fn layout(app: &mut SolveApp, ui: &mut egui::Ui) {
|ui| {
ui_kit::settings_form(ui, t.form_label_width + t.space_16, |ui| {
ui_kit::row(ui, app.tr("ui.panel_layout.panel_style"), |ui| {
let mut selected = usize::from(app.ui.layout == crate::ui_kit::LayoutMode::Docked);
let mut selected = usize::from(app.ui.layout == crate::shell::LayoutMode::Docked);
if ui_kit::segmented(
ui,
&mut selected,
@@ -283,7 +283,7 @@ pub(super) fn layout(app: &mut SolveApp, ui: &mut egui::Ui) {
layout_check(ui, app.tr("prefs.status_bar"), app.tr("prefs.status_bar_note"), &mut app.preferences.status_bar, "status_bar");
});
ui_kit::LayoutPreview {
compact: app.ui.layout == crate::ui_kit::LayoutMode::Docked,
compact: app.ui.layout == crate::shell::LayoutMode::Docked,
labels: app.preferences.ribbon_labels,
history: history_choice(app),
translucent: app.preferences.browser_float,
@@ -346,7 +346,7 @@ pub(super) fn layout(app: &mut SolveApp, ui: &mut egui::Ui) {
app.tr("prefs.browser"),
&options,
usize::from(app.preferences.browser_float),
app.ui.layout == crate::ui_kit::LayoutMode::Floating,
app.ui.layout == crate::shell::LayoutMode::Floating,
) {
app.preferences.browser_float = selected == 1;
}
@@ -659,7 +659,7 @@ fn browser_visibility(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_form(ui, 0.0, |ui| {
ui_kit::row(ui, app.tr("prefs.browser_shown"), |ui| {
ui.spacing_mut().item_spacing.x = Tokens::get().space_6;
crate::ui_kit::settings_search::note_current(ui, app.tr("prefs.layout_page.on_hover"));
crate::prefs_search::note_current(ui, app.tr("prefs.layout_page.on_hover"));
ui.add_enabled_ui(!crate::timeline_left::stacked(app), |ui| {
let mut selected = usize::from(app.preferences.browser_show == BrowserShow::Hover);
if ui_kit::settings_segments(
+4 -4
View File
@@ -163,7 +163,7 @@ fn orbit(app: &mut SolveApp, ui: &mut egui::Ui) {
let width = ui_kit::ContentWidth::label(ui, label) + Tokens::get().space_16 + Tokens::get().space_4;
ui_kit::settings_form(ui, width, |ui| {
ui_kit::settings_row_help(ui, label, help, |ui| {
crate::ui_kit::settings_search::note_choices(ui, &orbits.each_ref().map(String::as_str));
crate::prefs_search::note_choices(ui, &orbits.each_ref().map(String::as_str));
let mut selected = usize::from(!app.preferences.space_mouse_model_pivot);
let response = ui.scope(|ui| {
ui_kit::segmented(
@@ -180,11 +180,11 @@ fn orbit(app: &mut SolveApp, ui: &mut egui::Ui) {
if response.inner {
app.preferences.space_mouse_model_pivot = selected == 0;
}
if !crate::ui_kit::settings_search::texts_hit(ui, &[label]) {
if !crate::prefs_search::texts_hit(ui, &[label]) {
for option in &orbits {
if let Some(rect) = crate::scenario::rect_of(&format!("dialog:{label}:{option}")) {
crate::ui_kit::settings_search::remember(ui, option, rect);
crate::ui_kit::settings_search::follow(ui, option, rect);
crate::prefs_search::remember(ui, option, rect);
crate::prefs_search::follow(ui, option, rect);
}
}
}
+17 -22
View File
@@ -1,9 +1,8 @@
//! Navigation bar editor. The shared viewport painter owns the preview's appearance.
use crate::navigation::NavCaps;
use crate::navigation_bar::{self, Labels, MenuDisplay, NavigationBar, Position, SlotOptions};
use crate::navigation_bar::{self, Edge, Idle, Kind, Labels, MenuDisplay, NavigationBar, Position, Size, SlotOptions};
use crate::navigation_bar_view;
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{NavigationEdge, NavigationIdle, NavigationKind, NavigationSize};
use crate::{SolveApp, scenario, theme::Tokens, ui_kit};
use egui::{Rect, Sense, vec2};
use serde_json::json;
@@ -64,7 +63,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
});
});
scenario::publish_rect("prefs:navbar:header", header.response.rect);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
preview(app, ui, &mut state, width);
if state.selected.is_some() {
let card = ui_kit::settings_card(ui, |ui| inspector(app, ui, &mut state));
@@ -241,8 +240,8 @@ fn slot_count(app: &SolveApp, ui: &mut egui::Ui, stage: Rect, bar: Rect) {
Position::Right => stage.right() - ui_kit::NAV_BORDER - ui_kit::NAV_STAGE_INSET - ui_kit::NAV_PADDING - size.x,
};
let y = match app.preferences.navigation_bar.edge {
NavigationEdge::Top => bar.bottom() + t.space_8,
NavigationEdge::Bottom => bar.top() - t.space_8 - size.y,
Edge::Top => bar.bottom() + t.space_8,
Edge::Bottom => bar.top() - t.space_8 - size.y,
};
let rect = Rect::from_min_size(egui::pos2(x, y), size);
ui.painter().with_clip_rect(stage).galley(rect.min, galley, view.text_dim);
@@ -284,7 +283,7 @@ fn drop_on_preview(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State, tar
selected: false,
kind: slot.kind,
labels: false,
menu: slot.kind == NavigationKind::Menu,
menu: slot.kind == Kind::Menu,
},
app.preferences.navigation_bar.size,
);
@@ -438,7 +437,7 @@ fn available(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State) {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(Tokens::get().space_8, Tokens::get().space_8);
for slot in navigation_bar::CATALOGUE {
if slot.kind == NavigationKind::Separator || app.preferences.navigation_bar.slots.iter().any(|id| id == slot.id) {
if slot.kind == Kind::Separator || app.preferences.navigation_bar.slots.iter().any(|id| id == slot.id) {
continue;
}
let dragging = egui::DragAndDrop::payload::<Drag>(ui.ctx()).is_some_and(|payload| payload.from_menu && payload.id.slot == slot.id);
@@ -481,13 +480,9 @@ fn option_grid(bar: &mut NavigationBar, ui: &mut egui::Ui, first: usize) {
ui.allocate_ui_with_layout(vec2(width.max(0.0), t.control_height), egui::Layout::top_down(egui::Align::Min), |ui| match index {
0 => anchor_option(bar, ui),
1 => {
option(
ui,
"size",
&mut usize::from(bar.size == NavigationSize::Regular),
&["navbar.custom.compact", "navbar.custom.regular"],
|i| bar.size = if i == 1 { NavigationSize::Regular } else { NavigationSize::Compact },
);
option(ui, "size", &mut usize::from(bar.size == Size::Regular), &["navbar.custom.compact", "navbar.custom.regular"], |i| {
bar.size = if i == 1 { Size::Regular } else { Size::Compact }
});
}
2 => {
option(
@@ -503,16 +498,16 @@ fn option_grid(bar: &mut NavigationBar, ui: &mut egui::Ui, first: usize) {
ui,
"idle",
&mut match bar.idle {
NavigationIdle::Always => 0,
NavigationIdle::Fade => 1,
NavigationIdle::Hide => 2,
Idle::Always => 0,
Idle::Fade => 1,
Idle::Hide => 2,
},
&["navbar.custom.always", "navbar.custom.fade", "navbar.custom.hide_idle"],
|i| {
bar.idle = match i {
0 => NavigationIdle::Always,
2 => NavigationIdle::Hide,
_ => NavigationIdle::Fade,
0 => Idle::Always,
2 => Idle::Hide,
_ => Idle::Fade,
}
},
);
@@ -525,7 +520,7 @@ fn option_grid(bar: &mut NavigationBar, ui: &mut egui::Ui, first: usize) {
}
fn anchor_option(bar: &mut NavigationBar, ui: &mut egui::Ui) {
let selected = usize::from(bar.edge == NavigationEdge::Bottom) * 3
let selected = usize::from(bar.edge == Edge::Bottom) * 3
+ match bar.position {
Position::Left => 0,
Position::Centre => 1,
@@ -541,7 +536,7 @@ fn anchor_option(bar: &mut NavigationBar, ui: &mut egui::Ui) {
]
.map(crate::i18n::tr);
if let Some(index) = ui_kit::settings_anchor_field(ui, crate::i18n::tr("navbar.custom.position"), selected, &names) {
bar.edge = if index >= 3 { NavigationEdge::Bottom } else { NavigationEdge::Top };
bar.edge = if index >= 3 { Edge::Bottom } else { Edge::Top };
bar.position = match index % 3 {
0 => Position::Left,
2 => Position::Right,
+4 -4
View File
@@ -177,7 +177,7 @@ fn orbit(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
ui_kit::settings_form(ui, width, |ui| {
ui_kit::settings_row_help(ui, label, help, |ui| {
let options = [crate::i18n::tr("prefs.navigation.turntable"), crate::i18n::tr("prefs.navigation.free")];
crate::ui_kit::settings_search::note_choices(ui, &options);
crate::prefs_search::note_choices(ui, &options);
let mut selected = usize::from(app.preferences.free_orbit);
let response = ui.scope(|ui| {
ui_kit::segmented(
@@ -190,11 +190,11 @@ fn orbit(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
if response.inner {
app.preferences.free_orbit = selected == 1;
}
if !crate::ui_kit::settings_search::texts_hit(ui, &[label]) {
if !crate::prefs_search::texts_hit(ui, &[label]) {
for option in options {
if let Some(rect) = crate::scenario::rect_of(&format!("dialog:{label}:{option}")) {
crate::ui_kit::settings_search::remember(ui, option, rect);
crate::ui_kit::settings_search::follow(ui, option, rect);
crate::prefs_search::remember(ui, option, rect);
crate::prefs_search::follow(ui, option, rect);
}
}
}
+7 -7
View File
@@ -111,7 +111,7 @@ fn workspace_selector(app: &mut SolveApp, ui: &mut Ui, view: &LayoutPreferencesV
let title = app.tr("ui.panel_layout.layout_workspace").to_owned();
ui_kit::settings_form(ui, 0.0, |ui| {
ui_kit::sized_row(ui, &title, t.control_height, |ui| {
crate::ui_kit::settings_search::note_choices(ui, &options);
crate::prefs_search::note_choices(ui, &options);
ui.horizontal(|ui| {
let reset_label = app.tr("ui.panel_layout.reset_short");
let reset_width = ui_kit::ContentWidth::name(ui, reset_label) + t.space_24 + t.space_16;
@@ -239,8 +239,8 @@ fn tiles(app: &mut SolveApp, ui: &mut Ui, view: &LayoutPreferencesView, state: &
Tile::Save => app.tr("ui.panel_layout.save_as_tile"),
})
.collect();
crate::ui_kit::settings_search::note_choices(ui, &names);
crate::ui_kit::settings_search::note_choices(
crate::prefs_search::note_choices(ui, &names);
crate::prefs_search::note_choices(
ui,
&[app.tr("ui.panel_layout.save"), app.tr("ui.panel_layout.replace"), app.tr("ui.panel_layout.reset")],
);
@@ -464,7 +464,7 @@ fn tile_menu(app: &SolveApp, ui: &mut Ui, state: &mut State, tile: &Response, en
},
);
if popup.inner.is_some() {
crate::ui_kit::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
}
@@ -535,8 +535,8 @@ fn popover(app: &mut SolveApp, ui: &mut Ui, view: &LayoutPreferencesView, state:
ui_kit::settings_form(ui, 0.0, |ui| {
ui.set_width(width);
ui.spacing_mut().item_spacing = vec2(t.space_8, t.space_8);
crate::ui_kit::settings_search::push_force(ui);
crate::ui_kit::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::push_force(ui);
crate::prefs_search::note_key_sink(ui.ctx());
match &mut popup.kind {
PopoverKind::Save { name, chosen } => {
let users: Vec<_> = view.entries.iter().filter(|entry| entry.user).collect();
@@ -609,7 +609,7 @@ fn popover(app: &mut SolveApp, ui: &mut Ui, view: &LayoutPreferencesView, state:
}
}
trap_focus(ui, &focus_ids);
crate::ui_kit::settings_search::pop_force(ui);
crate::prefs_search::pop_force(ui);
})
})
});
+21 -21
View File
@@ -25,7 +25,7 @@ fn snapping(app: &mut SolveApp, ui: &mut egui::Ui) {
let controls_width = if available >= figure_width + t.space_14 + labels_width { available - figure_width - t.space_14 } else { available };
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), |ui| {
ui.spacing_mut().item_spacing.x = t.space_14;
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(figure_width.min(available), t.space_24 * 2.0 + t.space_16), egui::Sense::hover());
ui_kit::paint_sketch_guides(&ui.painter_at(rect), rect);
}
@@ -102,7 +102,7 @@ fn drawing(app: &mut SolveApp, ui: &mut egui::Ui) {
.map(|labels| labels.iter().map(|id| check_width(ui, app.tr(id), true)).fold(0.0, f32::max));
let available = ui.available_width();
let gap = t.space_24 + t.space_8;
let two_columns = available >= t.space_24 * 20.0 + gap && !crate::ui_kit::settings_search::suppress_chrome(ui);
let two_columns = available >= t.space_24 * 20.0 + gap && !crate::prefs_search::suppress_chrome(ui);
let left = if two_columns { (available - gap) * natural[0] / (natural[0] + natural[1]).max(1.0) } else { available };
let right = if two_columns { available - gap - left } else { available };
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), |ui| {
@@ -214,8 +214,8 @@ fn opening(app: &mut SolveApp, ui: &mut egui::Ui) {
);
picture_group(ui, app.tr("prefs.sketch_exit_view"), |ui| {
let label = app.tr("prefs.sketch_exit_view");
let mut indexed = crate::ui_kit::settings_search::texts_hit(ui, &[label]);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
let mut indexed = crate::prefs_search::texts_hit(ui, &[label]);
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::section_heading(ui, label);
}
let cards = ui.horizontal_wrapped(|ui| {
@@ -260,19 +260,19 @@ fn opening(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::scenario::publish_target("dialog:When a sketch ends", response.rect);
}
let option = app.tr(label_id);
if !indexed && crate::ui_kit::settings_search::texts_hit(ui, &[option]) {
crate::ui_kit::settings_search::remember(ui, option, response.rect);
if !indexed && crate::prefs_search::texts_hit(ui, &[option]) {
crate::prefs_search::remember(ui, option, response.rect);
indexed = true;
}
crate::ui_kit::settings_search::follow(ui, option, response.rect);
crate::prefs_search::follow(ui, option, response.rect);
if response.clicked() {
app.preferences.sketch_exit_view = choice;
}
}
});
crate::scenario::publish_rect("prefs:sketch_exit_view:choices", cards.response.rect);
crate::ui_kit::settings_search::remember(ui, label, cards.response.rect);
crate::ui_kit::settings_search::follow(ui, label, cards.response.rect);
crate::prefs_search::remember(ui, label, cards.response.rect);
crate::prefs_search::follow(ui, label, cards.response.rect);
});
});
}
@@ -342,8 +342,8 @@ fn selecting(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_section(ui, "selection", app.tr("prefs.selecting"), |ui| {
picture_group(ui, app.tr("prefs.sketch.click_selects"), |ui| {
let label = app.tr("prefs.sketch.click_selects");
let mut indexed = crate::ui_kit::settings_search::texts_hit(ui, &[label]);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
let mut indexed = crate::prefs_search::texts_hit(ui, &[label]);
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::section_heading(ui, label);
}
let cards = ui.horizontal_wrapped(|ui| {
@@ -373,19 +373,19 @@ fn selecting(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::scenario::publish_target("dialog:Click selects whole bodies", response.rect);
}
let option = app.tr(label);
if !indexed && crate::ui_kit::settings_search::texts_hit(ui, &[option]) {
crate::ui_kit::settings_search::remember(ui, option, response.rect);
if !indexed && crate::prefs_search::texts_hit(ui, &[option]) {
crate::prefs_search::remember(ui, option, response.rect);
indexed = true;
}
crate::ui_kit::settings_search::follow(ui, option, response.rect);
crate::prefs_search::follow(ui, option, response.rect);
if response.clicked() {
app.ui.pick_bodies = bodies;
}
}
});
crate::scenario::publish_rect("prefs:pick_bodies:choices", cards.response.rect);
crate::ui_kit::settings_search::remember(ui, label, cards.response.rect);
crate::ui_kit::settings_search::follow(ui, label, cards.response.rect);
crate::prefs_search::remember(ui, label, cards.response.rect);
crate::prefs_search::follow(ui, label, cards.response.rect);
});
check(
ui,
@@ -434,8 +434,8 @@ fn selecting(app: &mut SolveApp, ui: &mut egui::Ui) {
/// Natural-height picture groups own their search entries without allocating a form label column.
fn picture_group(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
let gate = crate::ui_kit::settings_search::block_gate(ui, label, None);
let before = crate::ui_kit::settings_search::hits(ui);
let gate = crate::prefs_search::block_gate(ui, label, None);
let before = crate::prefs_search::hits(ui);
let show = gate.as_ref().is_none_or(|gate| gate.show);
let content = |ui: &mut egui::Ui| {
let group = ui.scope(|ui| ui_kit::named_control(ui, label, add));
@@ -444,10 +444,10 @@ fn picture_group(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)
if show {
content(ui);
} else {
crate::ui_kit::settings_search::run_hidden(ui, label, content);
crate::prefs_search::run_hidden(ui, label, content);
}
if let Some(gate) = &gate {
crate::ui_kit::settings_search::block_finish(ui, gate, show, before);
crate::prefs_search::block_finish(ui, gate, show, before);
}
}
@@ -461,7 +461,7 @@ fn check(ui: &mut egui::Ui, key: &str, label: &str, help: Option<&str>, value: &
let help = help.map(crate::i18n::tr);
ui.horizontal_top(|ui| {
let t = Tokens::get();
let show_help = help.is_some() && !crate::ui_kit::settings_search::suppress_chrome(ui);
let show_help = help.is_some() && !crate::prefs_search::suppress_chrome(ui);
let width = (ui.available_width() - if show_help { t.space_8 + t.space_12 + 1.0 } else { 0.0 }).max(0.0).min(check_width(
ui,
crate::i18n::tr(label),
+2 -2
View File
@@ -130,7 +130,7 @@ pub(super) fn choose(app: &mut SolveApp, night: bool, id: &str) {
}
pub(super) fn row(app: &mut SolveApp, ui: &mut egui::Ui) {
if crate::ui_kit::settings_search::suppress_chrome(ui) {
if crate::prefs_search::suppress_chrome(ui) {
row_contents(app, ui);
return;
}
@@ -152,7 +152,7 @@ pub(super) fn row(app: &mut SolveApp, ui: &mut egui::Ui) {
fn row_contents(app: &mut SolveApp, ui: &mut egui::Ui) {
let key = ui.make_persistent_id("theme_picker_state");
let mut state = ui.data(|d| d.get_temp::<State>(key)).unwrap_or_default();
let search = crate::ui_kit::settings_search::suppress_chrome(ui);
let search = crate::prefs_search::suppress_chrome(ui);
let mut action = None;
let mut action_theme = None;
let t = Tokens::get();
+17 -17
View File
@@ -23,7 +23,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let response = setting(app, ui).backing_on_hover_text(app.tr(help));
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::help_icon(ui, app.tr(help));
}
});
@@ -42,7 +42,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
let response = setting(app, ui).backing_on_hover_text(app.tr(help));
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::help_icon(ui, app.tr(help));
}
});
@@ -60,7 +60,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
let response = setting::placement_handles(app, ui).backing_on_hover_text(app.tr("ui.placement_handles_tip"));
crate::scenario::publish_target("prefs:placement_handles", response.rect);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::help_icon(ui, app.tr("ui.placement_handles_tip"));
}
});
@@ -76,7 +76,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let response = setting::assembly_move_handles(app, ui).backing_on_hover_text(app.tr("joint_move.legacy_hint"));
crate::scenario::publish_target("prefs:assembly_move_handles", response.rect);
crate::scenario::publish_target("dialog:Use joint motion handles for assembly Move", response.rect);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::help_icon(ui, app.tr("joint_move.legacy_hint"));
}
});
@@ -86,7 +86,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let tip = app.tr("prefs.move_drag_highlights_tip");
let response = setting::move_drag_highlights(app, ui).backing_on_hover_text(tip);
crate::scenario::publish_target("prefs:move_drag_highlights", response.rect);
if !crate::ui_kit::settings_search::suppress_chrome(ui) {
if !crate::prefs_search::suppress_chrome(ui) {
ui_kit::help_icon(ui, tip);
}
});
@@ -162,12 +162,12 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
/// A single original figure beside a tool's complete setting stack. The columns wrap together
/// with their translated labels; search uses the same block gate as other compound preferences.
fn tool_group(ui: &mut egui::Ui, label: &str, figure: fn(&egui::Painter, egui::Rect), add: impl FnOnce(&mut egui::Ui)) {
let gate = crate::ui_kit::settings_search::block_gate(ui, label, None);
let before = crate::ui_kit::settings_search::hits(ui);
let gate = crate::prefs_search::block_gate(ui, label, None);
let before = crate::prefs_search::hits(ui);
if gate.as_ref().is_some_and(|gate| !gate.show) {
crate::ui_kit::settings_search::run_hidden(ui, label, add);
crate::prefs_search::run_hidden(ui, label, add);
if let Some(gate) = &gate {
crate::ui_kit::settings_search::block_finish(ui, gate, false, before);
crate::prefs_search::block_finish(ui, gate, false, before);
}
return;
}
@@ -190,14 +190,14 @@ fn tool_group(ui: &mut egui::Ui, label: &str, figure: fn(&egui::Painter, egui::R
ui.allocate_ui_with_layout(egui::vec2(controls_width, 0.0), egui::Layout::top_down(egui::Align::Min), add);
});
if let Some(gate) = &gate {
crate::ui_kit::settings_search::block_finish(ui, gate, true, before);
crate::prefs_search::block_finish(ui, gate, true, before);
}
}
/// The Create section's card: one border around a row per tool.
fn create_card(ui: &mut egui::Ui, rows: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get();
if crate::ui_kit::settings_search::suppress_chrome(ui) {
if crate::prefs_search::suppress_chrome(ui) {
rows(ui);
return;
}
@@ -215,17 +215,17 @@ fn create_card(ui: &mut egui::Ui, rows: impl FnOnce(&mut egui::Ui)) {
/// One tool in the Create card: its icons in tiles and its name on the left, its settings on the
/// right, a rule above every row but the first. Search uses the same block gate as `tool_group`.
fn create_row(ui: &mut egui::Ui, label: &str, icons: &[&str], first: bool, add: impl FnOnce(&mut egui::Ui)) {
let gate = crate::ui_kit::settings_search::block_gate(ui, label, None);
let before = crate::ui_kit::settings_search::hits(ui);
let gate = crate::prefs_search::block_gate(ui, label, None);
let before = crate::prefs_search::hits(ui);
if gate.as_ref().is_some_and(|gate| !gate.show) {
crate::ui_kit::settings_search::run_hidden(ui, label, add);
crate::prefs_search::run_hidden(ui, label, add);
if let Some(gate) = &gate {
crate::ui_kit::settings_search::block_finish(ui, gate, false, before);
crate::prefs_search::block_finish(ui, gate, false, before);
}
return;
}
let t = Tokens::get();
let chrome = !crate::ui_kit::settings_search::suppress_chrome(ui);
let chrome = !crate::prefs_search::suppress_chrome(ui);
let margin = egui::Margin::symmetric(t.space_14 as i8, t.space_10 as i8);
let row = egui::Frame::NONE.inner_margin(margin).show(ui, |ui| {
ui.set_width(ui.available_width());
@@ -260,6 +260,6 @@ fn create_row(ui: &mut egui::Ui, label: &str, icons: &[&str], first: bool, add:
ui.painter().hline(row.response.rect.x_range(), row.response.rect.top(), egui::Stroke::new(1.0, t.dialog_rule));
}
if let Some(gate) = &gate {
crate::ui_kit::settings_search::block_finish(ui, gate, true, before);
crate::prefs_search::block_finish(ui, gate, true, before);
}
}
@@ -1,8 +1,7 @@
//! Search across a settings page the kit draws; the Preferences window is SolidCraft's use of it.
//! The field is the Keyboard Shortcuts magnifier. A match is the control's own label, its
//! section, its page, or the value a dropdown is showing, scored the same way as command search.
//! English counts too, from the message catalog, so a new setting is found by being drawn —
//! nothing is registered for search.
//! Search across Preferences. The field is the Keyboard Shortcuts magnifier. A match is the
//! control's own label, its section, its page, or the value a dropdown is showing, scored the
//! same way as command search. English counts too, from the message catalog, so a new setting
//! is found by being drawn — nothing is registered for search.
use std::collections::{HashMap, HashSet};
@@ -802,7 +801,7 @@ pub(crate) fn remember(ui: &mut Ui, label: &str, rect: Rect) {
if scroll {
ui.scroll_to_rect(rect, Some(egui::Align::Center));
}
solidcraft_ui_registry::publish_handle("prefs:search:current", rect.center());
crate::scenario::publish_handle("prefs:search:current", rect.center());
}
pub(crate) fn set_reveal(ctx: &Context, reveal: Option<Reveal>) {
@@ -816,13 +815,13 @@ pub(crate) fn set_reveal(ctx: &Context, reveal: Option<Reveal>) {
}
pub(crate) fn reveal_for(page: usize, label: &str) -> Reveal {
Reveal { page, label: label.to_string(), until: now_ms() + REVEAL_MS }
Reveal { page, label: label.to_string(), until: crate::now_ms() + REVEAL_MS }
}
/// Scroll the opened setting into view and outline it until [`Reveal::until`].
pub(crate) fn follow(ui: &Ui, label: &str, rect: Rect) {
let Some(reveal) = ui.data(|data| data.get_temp::<Reveal>(Id::unique(REVEAL_ID))) else { return };
if now_ms() > reveal.until || fold(label) != fold(&reveal.label) {
if crate::now_ms() > reveal.until || fold(label) != fold(&reveal.label) {
return;
}
let page = with(ui, |state| state.page_index);
@@ -832,13 +831,7 @@ pub(crate) fn follow(ui: &Ui, label: &str, rect: Rect) {
ui.scroll_to_rect(rect, Some(egui::Align::Center));
let t = Tokens::get();
ui.painter().rect_stroke(rect, t.radius_sm, egui::Stroke::new(t.space_2, t.form_primary), egui::StrokeKind::Outside);
solidcraft_ui_registry::publish_handle("prefs:revealed", rect.center());
}
/// Wall clock in milliseconds, for how long a reveal stays outlined.
fn now_ms() -> f64 {
use web_time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
crate::scenario::publish_handle("prefs:revealed", rect.center());
}
#[cfg(test)]
@@ -1,6 +1,6 @@
//! The colours picked last in any colour field, newest first (#9234). The host keeps the list in
//! its saved settings and hands it to the colour pickers every frame through egui memory with
//! [`share`]; the kit's colour popover shows it and adds finished picks.
//! The colours picked last in any colour field, newest first (#9234). The app keeps the list in
//! its saved settings (`UiState::recent_colors`) and hands it to the colour pickers every frame
//! through egui memory; the shared `ui_kit` colour popover shows it and adds finished picks.
/// How many colours the recent list keeps.
pub(crate) const MAX_RECENT_COLORS: usize = 12;
+4 -5
View File
@@ -395,9 +395,8 @@ pub(crate) fn preview_scene(scene: &mut ProductScene, preview: Option<&Preview>)
Ok(())
}
pub(crate) fn menu(app: &SolveApp, body: &str) -> crate::ui_kit::Item {
use crate::context_menu::cmd;
use crate::ui_kit::Item;
pub(crate) fn menu(app: &SolveApp, body: &str) -> crate::context_menu::Item {
use crate::context_menu::{Item, cmd};
let selected_face = app
.render_workspace
.view
@@ -530,8 +529,8 @@ pub(crate) fn selection_bar(app: &mut SolveApp, ui: &mut egui::Ui, free: egui::R
})
.collect::<Vec<_>>();
let anchor = egui::pos2(
crate::ui_kit::within(screen.center().x, free.left(), free.right()),
crate::ui_kit::within(screen.top() - crate::theme::Tokens::get().space_12, free.top(), free.bottom()),
crate::shell::within(screen.center().x, free.left(), free.right()),
crate::shell::within(screen.top() - crate::theme::Tokens::get().space_12, free.top(), free.bottom()),
);
let ctx = ui.ctx().clone();
let pill = ui_kit::Container::selection_card(&ctx, egui::Id::unique("render_selection"), anchor, free, |ui| {
+1 -1
View File
@@ -17,7 +17,7 @@ pub(crate) fn focused(app: &SolveApp) -> bool {
}
pub(crate) fn compact(app: &SolveApp, width: f32) -> bool {
width < 1100.0 || app.ui.layout == crate::ui_kit::LayoutMode::Docked
width < 1100.0 || app.ui.layout == crate::shell::LayoutMode::Docked
}
pub(crate) fn focus_shortcut(app: &mut SolveApp, ui: &mut egui::Ui) {
+2 -2
View File
@@ -3,9 +3,9 @@ use super::{ImageMethod, RenderSelection, cpu, hdr, inspector, setups, studio};
use crate::{
SolveApp,
browser::{self, Row},
context_menu,
context_menu::{self, Item},
theme::Tokens,
ui_kit::{self, Item},
ui_kit,
};
use egui::Color32;
use serde_json::json;
+1 -1
View File
@@ -2757,7 +2757,7 @@ fn preview_error(app: &mut SolveApp, ui: &mut egui::Ui, rect: egui::Rect, error:
ui.scope_builder(egui::UiBuilder::new().max_rect(bounded), |ui| {
ui.set_clip_rect(ui.clip_rect().intersect(bounded));
ui.set_max_width(bounded.width().min(t.form_card_width * 2.0));
crate::ui_kit::Panel::feature_frame(ui.ctx(), crate::ui_kit::LayoutMode::Floating, crate::frame::Side::Left).inner_margin(t.space_8).show(
crate::ui_kit::Panel::feature_frame(ui.ctx(), crate::shell::LayoutMode::Floating, crate::frame::Side::Left).inner_margin(t.space_8).show(
ui,
|ui| {
crate::ui_kit::text(ui, error, crate::ui_kit::Text::Error("render.preview_failure"));
@@ -259,7 +259,7 @@ fn render_workspace_shot_lens_saves_only_lens_and_orthographic_keeps_faded_contr
fn inspector_dispatch_has_one_editor_per_selection_independent_of_every_stage() {
use super::super::{RenderSelection, Stage};
for dark in [false, true] {
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
let mut h = fixture();
h.app.preferences.theme = if dark { "dark" } else { "light" }.into();
h.app.ui.layout = layout;
@@ -313,7 +313,7 @@ fn inspector_dispatch_has_one_editor_per_selection_independent_of_every_stage()
assert!(h.app.render_workspace.view.job.is_none());
assert!(h.app.render_workspace.view.completed.is_none());
}
if !dark && layout == crate::ui_kit::LayoutMode::Floating {
if !dark && layout == crate::shell::LayoutMode::Floating {
appearance_inspector_capture(&mut h, &format!("dispatch-{kind}"));
}
}
@@ -514,12 +514,12 @@ fn stage_catalogs_fill_a_290_by_530_panel_and_keep_two_line_labels_inside_tiles(
crate::theme::set_dark(dark);
h.ctx.set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() });
// A resizable 290 pt card needs the floating, wide-screen policy.
h.app.ui.layout = crate::ui_kit::LayoutMode::Floating;
h.app.ui.layout = crate::shell::LayoutMode::Floating;
h.app.preferences.render_browser_width = 290.0;
h.app.preferences.render_browser_side = "left".into();
super::super::select_image_method(&mut h.app, method);
library::open_category(&mut h.app, kind);
h.app.publish_tab_accent(&h.ctx);
crate::theme::TabAccent::publish(&h.app, &h.ctx);
let document = h.app.session.doc.to_json();
let history = (h.app.session.undo.len(), h.app.session.redo.len());
// Panel::workspace reserves an 8 pt bottom gap in these bounds.
@@ -1032,7 +1032,7 @@ fn ribbon_navigation_reuses_inspector_and_preserves_exact_unsaved_intent() {
#[test]
fn render_stage_bar_keeps_actions_and_personal_preferences_in_both_layouts() {
assert!(crate::workspace::panels("RENDER").is_empty());
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
let mut h = fixture();
h.app.ui.layout = layout;
h.app.toolbar_custom.panels.insert("DESIGN/RENDER/RESULT".into(), vec!["render.export".into()]);
@@ -1299,7 +1299,7 @@ fn wear_selection_precedes_output_page_and_appearance_selection_chrome() {
"inspector title has missing, empty or out-of-header bounds: {context}, bounds={bounds:?}"
);
}
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
let mut h = fixture();
h.app.ui.layout = layout;
h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Subject".into() }];
+12 -12
View File
@@ -4915,7 +4915,7 @@ fn render_workspace_stage_chips_dispatch_without_mutations_and_remember_each_doc
#[test]
fn render_workspace_stage_shell_degrades_without_overlap_at_small_window_sizes() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
enter(&mut h.app, true).unwrap();
h.app.ui.layout = mode;
@@ -5342,7 +5342,7 @@ fn render_workspace_shots_toggle_persists_and_design_roundtrip_preserves_state()
#[test]
fn render_workspace_canvas_controls_and_fit_respect_panels_and_shots_in_both_layouts() {
for layout in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.app.ui.layout = layout;
h.call("ui.resize", json!({"width":1600,"height":1000}));
@@ -5427,7 +5427,7 @@ fn assert_product_fits_canvas(app: &SolveApp) {
#[test]
fn render_workspace_hosts_float_and_shield_input_in_both_ribbon_layouts() {
for (layout, page) in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked]
for (layout, page) in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked]
.into_iter()
.flat_map(|layout| [inspector::Editor::Scene, inspector::Editor::Appearance].map(|page| (layout, page)))
{
@@ -5516,7 +5516,7 @@ fn render_workspace_hosts_float_and_shield_input_in_both_ribbon_layouts() {
#[test]
fn render_workspace_assignment_footer_fits_stacked_card_with_shots_shown_and_hidden() {
for layout in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.app.ui.layout = layout;
h.call("ui.resize", json!({"width":1600,"height":1000}));
@@ -5539,7 +5539,7 @@ fn render_workspace_assignment_footer_fits_stacked_card_with_shots_shown_and_hid
#[test]
fn render_workspace_card_grips_swap_sides_and_resize_without_design_edits() {
for layout in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.app.ui.layout = layout;
h.call("ui.resize", json!({"width":1600,"height":1000}));
@@ -5559,7 +5559,7 @@ fn render_workspace_card_grips_swap_sides_and_resize_without_design_edits() {
h.step(&json!({"drag":[[rail.x,rail.y],[rail.x+50.0,rail.y]],"steps":8})).unwrap();
h.frames(3);
// A compact dock keeps its minimum width and leaves the remembered width alone.
if layout == crate::ui_kit::LayoutMode::Docked {
if layout == crate::shell::LayoutMode::Docked {
assert_eq!(h.app.preferences.render_inspector_width, width);
assert_eq!(h.app.shell.workspace_panels["render:inspector"].0.width(), card_width);
} else {
@@ -5985,7 +5985,7 @@ fn render_workspace_browser_look_lens_counts_distinct_objects_and_selects_them()
#[test]
fn render_workspace_dock_command_swaps_sides_without_design_edits() {
for layout in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.app.ui.layout = layout;
h.call("ui.resize", json!({"width":1600,"height":1000}));
@@ -6027,7 +6027,7 @@ fn render_workspace_dock_command_swaps_sides_without_design_edits() {
#[test]
fn render_workspace_dock_grip_menus_dispatch_the_other_side() {
for layout in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.app.ui.layout = layout;
h.call("ui.resize", json!({"width":1600,"height":1000}));
@@ -6601,7 +6601,7 @@ fn render_workspace_shots_heading_matches_design_history_typography_and_strip_ba
}
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.ui.layout = crate::ui_kit::LayoutMode::Docked;
h.app.ui.layout = crate::shell::LayoutMode::Docked;
h.app.preferences.timeline_left = false;
h.app.ui.show_timeline = true;
h.frames(3);
@@ -11392,7 +11392,7 @@ fn cpu_general_glass_path_availability_uses_reviewed_unsaved_saved_optics_withou
fn render_ui_at_world_points_hit_the_painted_surface_and_letterbox_does_not_select() {
use solidcore_record::Vec3;
use solidcraft_engine::Sel;
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
for side in ["left", "right"] {
let mut h = Harness::new();
h.app.run("solid.box", json!({"corner":[-10,-10,-10],"length":20,"width":20,"height":20,"body_name":"Pick target"})).unwrap();
@@ -11471,7 +11471,7 @@ fn render_ui_at_world_points_hit_the_painted_surface_and_letterbox_does_not_sele
#[test]
fn rendered_camera_layout_cache_and_fit_share_the_exact_frame_without_moving_outer_controls() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for side in ["left", "right"] {
let mut h = Harness::new();
h.app.ui.layout = mode;
@@ -11672,7 +11672,7 @@ fn light_stage_describes_world_coordinates_without_inert_frame_settings_or_edits
let preview = light::preview(&h.app).unwrap();
assert!(!lights.is_empty());
let lights = serde_json::to_value(lights).unwrap();
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
h.app.ui.layout = layout;
for width in [800.0, 1440.0] {
let output = draw(&mut h, width, vec![]);
+1 -1
View File
@@ -143,7 +143,7 @@ fn plastic_rule_editor_contract() {
#[test]
fn sheet_rule_editor_delete_reloads_and_plastic_has_no_delete() {
for layout in [crate::ui_kit::LayoutMode::Docked, crate::ui_kit::LayoutMode::Floating] {
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
let mut h = scenario::Harness::new();
h.app.home.open = false;
h.call("ui.set", json!({"layout":layout}));
+66 -7
View File
@@ -53,17 +53,76 @@ use solidcraft_engine::Session;
use crate::control::{ControlRequest, Outcome, handle};
use crate::{Services, SolveApp};
// The widget registry (handles, rects and counts drawn this frame) lives in its own crate so UI
// components can publish without depending on the app; re-exported here for the scenario runner.
pub use solidcraft_ui_registry::{
clear_handles, count_of, handle_at, handles, publish_count, publish_handle, publish_rect, publish_target, publish_target_as, rect_of, rects,
};
thread_local! {
/// Drag handles drawn this frame (name, screen point): manipulators publish theirs so
/// scenarios (and the control channel's `ui.at {handle}`) can grab them.
static HANDLES: RefCell<Vec<(String, egui::Pos2)>> = const { RefCell::new(Vec::new()) };
/// Painted parts with no widget of their own (the toolbar, the workspace switcher…): rects
/// and counts by name, for checks.
static RECTS: RefCell<Vec<(String, egui::Rect)>> = const { RefCell::new(Vec::new()) };
static COUNTS: RefCell<Vec<(String, f64)>> = const { RefCell::new(Vec::new()) };
/// Widths a scenario's `same_width` checks remembered (by rect name).
static WIDTHS: RefCell<Vec<(String, f32)>> = const { RefCell::new(Vec::new()) };
}
/// A manipulator's handle drawn this frame at `at` (screen), by name: "arrow" (a dialog's value
/// arrow), "triad_x"… Call it from the code that draws the handle, every frame it shows.
pub fn publish_handle(name: &str, at: egui::Pos2) {
// The latest wins (lookups search from the end), so publishing is cheap for browser rows.
HANDLES.with(|h| h.borrow_mut().push((name.to_string(), at)));
}
/// Publish a rectangle and its centered interaction handle with the same explicit identity.
pub(crate) fn publish_target(name: &str, rect: egui::Rect) {
publish_target_as(name, name, rect);
}
/// Publish centered geometry with explicit, independently named handle and rectangle aliases.
pub(crate) fn publish_target_as(handle: &str, rect_name: &str, rect: egui::Rect) {
publish_handle(handle, rect.center());
publish_rect(rect_name, rect);
}
/// Where a handle was drawn last frame.
pub fn handle_at(name: &str) -> Option<egui::Pos2> {
HANDLES.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
/// Every handle drawn last frame whose name starts with `prefix`.
pub fn handles(prefix: &str) -> Vec<(String, egui::Pos2)> {
HANDLES.with(|h| h.borrow().iter().filter(|(n, _)| n.starts_with(prefix)).cloned().collect())
}
/// A painted part's rect this frame, by name ("toolbar", "workspace_switcher").
pub fn publish_rect(name: &str, r: egui::Rect) {
RECTS.with(|h| h.borrow_mut().push((name.to_string(), r)));
}
pub fn rect_of(name: &str) -> Option<egui::Rect> {
RECTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
#[cfg(test)]
pub(crate) fn rects(prefix: &str) -> Vec<(String, egui::Rect)> {
RECTS.with(|r| r.borrow().iter().filter(|(name, _)| name.starts_with(prefix)).cloned().collect())
}
/// A number about this frame's drawing, by name ("axis_view_highlights": the axis view's highlighted tips).
pub fn publish_count(name: &str, v: f64) {
COUNTS.with(|h| h.borrow_mut().push((name.to_string(), v)));
}
pub fn count_of(name: &str) -> Option<f64> {
COUNTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
/// Forget last frame's handles, rects and counts (called as a frame starts).
pub fn clear_handles() {
HANDLES.with(|h| h.borrow_mut().clear());
RECTS.with(|h| h.borrow_mut().clear());
COUNTS.with(|h| h.borrow_mut().clear());
}
/// `v` with "$DIR" in its strings replaced by `dir`.
fn with_dir(v: Value, dir: &str) -> Value {
match v {
@@ -612,7 +671,7 @@ impl Harness {
eprintln!("widgets: {texts:?}");
}
if s["debug"] == json!("handles") {
let names: Vec<String> = handles("").iter().map(|(n, p)| format!("{n} @{:.0},{:.0}", p.x, p.y)).collect();
let names: Vec<String> = HANDLES.with(|h| h.borrow().iter().map(|(n, p)| format!("{n} @{:.0},{:.0}", p.x, p.y)).collect());
eprintln!("handles: {names:?}");
}
return Ok(());
+1 -1
View File
@@ -630,7 +630,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut picked = None;
let screen = ctx.content_rect();
let margins = crate::ui_kit::Menu::frame().total_margin().sum();
let width = crate::ui_kit::LIST_W.min(screen.width());
let width = crate::context_menu::LIST_W.min(screen.width());
let w = (width - margins.x).max(0.0);
let header = 20.0;
let gap = vec2(12.0, 12.0);
+2 -2
View File
@@ -664,7 +664,7 @@ pub(crate) fn section(app: &mut SolveApp, ui: &mut egui::Ui) {
// Searching for "profiles" must keep this entry on the results page; without a hit here the
// whole section was dropped and the name field could not be reached with a query active.
let entry = [app.tr("prefs.general.profile_name"), app.tr("prefs.general.save_new"), app.tr("prefs.general.import")];
if crate::ui_kit::settings_search::visible(ui, &entry) {
if crate::prefs_search::visible(ui, &entry) {
ui.horizontal_wrapped(|ui| {
let mut draft = std::mem::take(&mut app.settings_profiles.draft);
let enter = ui_kit::named_control(ui, app.tr("prefs.general.profile_name"), |ui| {
@@ -759,7 +759,7 @@ pub(crate) fn rename_popup(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
let Some(mut rename) = app.settings_profiles.rename.take() else { return };
crate::ui_kit::settings_search::note_key_sink(ctx);
crate::prefs_search::note_key_sink(ctx);
let mut done = None;
let popup = ui_kit::Container::new(ctx, ui_kit::Surface::Popup)
.area(egui::Id::unique("sc_profile_rename"))
+59 -3
View File
@@ -1,8 +1,32 @@
//! Transient shell geometry shared by automation, drawing and viewport occlusion.
use crate::ui_kit::{Container, LayoutMode, SHELL_GAP, Surface, within};
use egui::{Rect, pos2};
use crate::ui_kit::{Container, Surface};
use egui::{Rect, pos2, vec2};
use serde::{Deserialize, Serialize};
use crate::{SolveApp, theme::Tokens};
use crate::{SolveApp, theme::Tokens, toolbar};
/// Saved workspace layout; automation may override it for a session.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LayoutMode {
#[default]
Floating,
#[serde(rename = "compact", alias = "docked")]
Docked,
}
impl LayoutMode {
pub(crate) fn publish(self, ctx: &egui::Context) {
ctx.data_mut(|data| data.insert_temp(egui::Id::unique("sc_container_layout"), self));
}
pub(crate) fn current(ctx: &egui::Context) -> Self {
ctx.data(|data| data.get_temp(egui::Id::unique("sc_container_layout"))).unwrap_or_default()
}
}
/// The gap from the viewport edge to the floating cards and between the cards.
pub(crate) const SHELL_GAP: f32 = 7.0;
/// The viewport's top corners are rounded concentrically with the cards: the card margin plus the
/// card radius.
@@ -38,6 +62,32 @@ pub(crate) fn paint_top_corners(painter: &egui::Painter, viewport: Rect, color:
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct Cards {
pub workspace: Rect,
pub ribbon: Rect,
pub bottom: f32,
}
impl Cards {
pub fn new(available: Rect, mode: LayoutMode, ribbon_height: f32) -> Self {
let margin = Container::for_layout(mode, Surface::Chrome).gap();
let gap = margin;
let left = available.left() + margin;
let top = available.top() + margin;
let right = (available.right() - margin).max(left);
let workspace = Rect::from_min_size(
pos2(left, top),
vec2(
toolbar::WORKSPACE_WIDTH.min((right - left).max(0.0)),
ribbon_height - if !Container::for_layout(mode, Surface::Chrome).floating() { 1.0 } else { 0.0 },
),
);
let ribbon = Rect::from_min_max(pos2((workspace.right() + gap).min(right), top), pos2(right, top + ribbon_height));
Self { workspace, ribbon, bottom: ribbon.bottom() + margin }
}
}
/// Shell panels measured after drawing the full-bleed viewport. No design or preferences data.
#[derive(Default)]
pub(crate) struct State {
@@ -205,6 +255,12 @@ pub(crate) fn content_rect(app: &SolveApp, viewport: Rect) -> Rect {
if content.is_positive() { content } else { viewport }
}
/// `v` kept within `lo..=hi`, or `lo` when that range is empty: `f32::clamp` panics on
/// `lo > hi`, which a rect squeezed to nothing between cards gives.
pub(crate) fn within(v: f32, lo: f32, hi: f32) -> f32 {
if lo <= hi { v.clamp(lo, hi) } else { lo }
}
/// Free anchor space shared by both layouts. Compact edge hosts have already consumed
/// their allocation; content-hugging Browser and floating overlays reserve their measured span.
pub(crate) fn workspace_content(app: &SolveApp, mut content: Rect) -> Rect {
+1 -2
View File
@@ -1,6 +1,5 @@
use super::*;
use crate::scenario::{Harness, rect_of};
use crate::toolbar;
use serde_json::json;
use solidcraft_engine::panels::ActionSink as _;
@@ -2531,7 +2530,7 @@ fn status_bar_mouse_mapping_opens_the_mappings_and_picks_one_as_the_preferences_
assert_eq!(h.app.session.undo.len(), undo);
// An unknown mapping changes nothing.
let before = serde_json::to_value(&h.app.preferences).unwrap();
let hostile = crate::ui_kit::Item::action("ui.navPreset", "Nope", "").with(json!({"preset": "nope"}));
let hostile = crate::context_menu::Item::action("ui.navPreset", "Nope", "").with(json!({"preset": "nope"}));
crate::context_menu::run_item(&mut h.app, &hostile, egui::pos2(0.0, 0.0));
assert_eq!(serde_json::to_value(&h.app.preferences).unwrap(), before);
// A second click on the button closes the menu it opened.
+2 -2
View File
@@ -614,10 +614,10 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod, doc: &Document) -> bool {
// The size in millimetres follows the factor and the other way round; the aspect is kept.
if let Some((_, size)) = frame {
for (label, natural) in [("Width", size[0]), ("Height", size[1])] {
let k = crate::ui_kit::parse_number(factor.trim()).filter(|k| *k > 0.0).unwrap_or(1.0);
let k = crate::prefs::parse_number(factor.trim()).filter(|k| *k > 0.0).unwrap_or(1.0);
let mut shown = format!("{} mm", crate::sketch_transform_ui::plain(natural * k));
if number(ui, label, &mut shown)
&& let Some(mm) = crate::ui_kit::parse_number(shown.trim_end_matches("mm").trim())
&& let Some(mm) = crate::prefs::parse_number(shown.trim_end_matches("mm").trim())
&& mm > 0.0
&& natural > 1e-9
{
+2 -2
View File
@@ -820,7 +820,7 @@ mod tests {
/// most visibly docked against the window's edge (#9457).
#[test]
fn pane_stays_still_while_the_pointer_moves_at_fractional_scale() {
for layout in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for (scale, width, height) in [(1.0, 1600.0, 1000.0), (1.25, 1536.0, 921.6), (1.25, 1522.4, 904.8), (1.5, 1280.0, 800.0)] {
let mut h = crate::scenario::Harness::new();
h.app.ui.layout = layout;
@@ -878,7 +878,7 @@ mod tests {
/// it sits at the left is not squeezed to nothing beside it.
#[test]
fn the_header_drags_the_palette_and_it_keeps_its_place() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = crate::scenario::Harness::new();
h.call("ui.set", json!({"layout":mode}));
h.call("ui.resize", json!({"width":1400,"height":900}));
+8 -8
View File
@@ -101,7 +101,7 @@ pub struct Offer {
/// enable switch (`app.space_mouse.device`, `app.preferences`); the readers present
/// (`space_mouse.drivers`); `ctx.input` focus, time and pointer position; `app.status`, `computing_since`,
/// `file_picker_busy`, `file_load.busy`, `prefs_window.open`, `space_mouse.calibrating`,
/// `ui_kit::input_blocked`.
/// `issue_report::cad_input_blocked`.
/// - Writes, only its own: `space_mouse.offer_since`, `space_mouse.offer_hidden`;
/// `space_mouse_offered` and `space_mouse_offer_never` (preferences) when the user closes it (×),
/// or clicks Calibrate or Never.
@@ -183,7 +183,7 @@ fn input_blocked(app: &SolveApp, ctx: &egui::Context) -> bool {
!ctx.input(|i| i.focused)
|| app.file_picker_busy()
|| app.file_load.busy()
|| crate::ui_kit::input_blocked()
|| crate::issue_report::cad_input_blocked()
|| app.prefs_window.open
|| app.space_mouse.calibrating.is_some()
|| app.home.open
@@ -463,10 +463,10 @@ mod preferences_tests {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.space_mouse.device = Some("hid 046d:c62b".into());
crate::ui_kit::set_input_blocked(true);
crate::issue_report::set_cad_input_blocked_for_test(true);
h.frames(6);
let hidden = h.app.space_mouse.offer_since.is_none() && crate::scenario::rect_of("toast:space_mouse_offer").is_none();
crate::ui_kit::set_input_blocked(false);
crate::issue_report::set_cad_input_blocked_for_test(false);
assert!(hidden, "not drawn while input is blocked");
assert!(h.app.space_mouse.calibrating.is_none() && h.app.preferences.space_mouse_offered.is_empty());
// And it is there again once the report is gone.
@@ -726,10 +726,10 @@ mod preferences_tests {
// Where the button was: once the guard hides the card, a name finds nothing (or another
// control), so a click by name would not test the click behind it.
let at = h.widget(widget).unwrap();
crate::ui_kit::set_input_blocked(true);
crate::issue_report::set_cad_input_blocked_for_test(true);
h.step(&serde_json::json!({"click": at})).unwrap();
h.frames(3);
crate::ui_kit::set_input_blocked(false);
crate::issue_report::set_cad_input_blocked_for_test(false);
assert!(h.app.space_mouse.calibrating.is_none() && !h.app.prefs_window.open, "{widget}: no wizard, no Preferences");
assert_eq!(h.app.preferences, before, "{widget}: nothing recorded or changed");
// And the card is back once the guard is gone, untouched.
@@ -768,7 +768,7 @@ mod preferences_tests {
let cases: [Stale; 7] = [
("the device changed", |h| h.app.space_mouse.device = Some("hid 256f:c63a".into())),
("the device went", |h| h.app.space_mouse.device = None),
("an issue report took the input", |_| crate::ui_kit::set_input_blocked(true)),
("an issue report took the input", |_| crate::issue_report::set_cad_input_blocked_for_test(true)),
("Preferences opened", |h| h.app.prefs_window.open = true),
("a status toast is up", |h| h.app.status = Some(("Done".into(), crate::now_ms(), false))),
("the device was dismissed elsewhere", |h| {
@@ -791,7 +791,7 @@ mod preferences_tests {
h.app.space_mouse.offer_hidden.clone(),
);
act(&mut h.app, &h.ctx, &offer, action);
crate::ui_kit::set_input_blocked(false);
crate::issue_report::set_cad_input_blocked_for_test(false);
assert_eq!(h.app.preferences, before, "{action:?}, {what}: nothing recorded or changed");
assert_eq!(h.app.space_mouse.calibrating.is_some(), calibrating, "{action:?}, {what}: no wizard");
assert_eq!(h.app.prefs_window.open, window, "{action:?}, {what}: Preferences untouched");
+29 -28
View File
@@ -20,7 +20,7 @@ thread_local! {
/// must not change one another's palette while a frame or icon texture is being built.
static DARK: Cell<bool> = const { Cell::new(true) };
/// The colour vision mode, per thread. Each app
/// sets it from its own preferences at the start of every frame (in its frame logic), on the
/// sets it from its own preferences at the start of every frame (`SolveApp::logic`), on the
/// thread that draws it. Tests run many apps side by side on their own threads in one process:
/// with one global, a test in a mode changed the colours under another one
/// (`hover_stays_off_during_spin_and_animated_views` failed that way). No worker thread reads
@@ -580,11 +580,7 @@ pub(crate) fn scoped_tokens(tokens: Tokens) -> impl Drop {
}
/// Temporarily bind a panel's paint palette without publishing a theme change.
///
/// `Tokens::get_in` returns these tokens first until the returned guard drops, which restores
/// the previous override. A host uses it to draw any component in another palette or accent
/// (for example another workspace's) without editing the component.
pub fn scoped_panel_tokens(tokens: Tokens) -> impl Drop {
pub(crate) fn scoped_panel_tokens(tokens: Tokens) -> impl Drop {
struct Restore {
previous: Option<Tokens>,
// The override belongs to this UI thread, so its guard must not move to another one.
@@ -795,13 +791,11 @@ impl TabAccent {
ctx.data(|d| d.get_temp::<Option<&'static str>>(egui::Id::unique("sc_tab_icon_accent"))).flatten()
}
/// Publish the accent of `tab` for this frame. `sketch_open` is whether a sketch is being
/// edited, `tool_active` whether a tool or dialog is open; both only steer focus painting.
pub fn publish(tab: &str, sketch_open: bool, tool_active: bool, ctx: &egui::Context) {
pub fn publish(app: &crate::SolveApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let (published, accent, fading, style_surfaces) = ctx.data_mut(|data| {
let state = data.get_temp_mut_or_default::<TabThemeState>(egui::Id::unique(TAB_STATE));
let (published, accent, fading) = state.publish(tab, now);
let (published, accent, fading) = state.publish(&app.ui.tab, now);
let style_surfaces = published.tab_surfaces || state.styled_surfaces;
state.styled_surfaces = published.tab_surfaces;
(published, accent, fading, style_surfaces)
@@ -824,9 +818,9 @@ impl TabAccent {
}
ctx.data_mut(|d| {
d.insert_temp(egui::Id::unique("sc_tab_accent"), accent);
d.insert_temp(egui::Id::unique("sc_tab_icon_accent"), icon_accent_for_tab(tab));
d.insert_temp(egui::Id::unique("sc_sketch_open"), sketch_open);
d.insert_temp(egui::Id::unique("sc_tool_accent"), tool_active);
d.insert_temp(egui::Id::unique("sc_tab_icon_accent"), icon_accent_for_tab(&app.ui.tab));
d.insert_temp(egui::Id::unique("sc_sketch_open"), app.session.active_sketch.is_some());
d.insert_temp(egui::Id::unique("sc_tool_accent"), app.tool.is_some() || app.dialog.is_some());
});
}
@@ -1964,13 +1958,15 @@ mod tests {
#[test]
fn tabs_publish_requests_repaints_only_during_fade_and_zero_is_instant() {
use crate::theme_files::OptionValue;
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
let ctx = crate::fonts::test_context();
let mut state = tabs_state("dark", BTreeMap::new());
state.publish("MODEL", 0.0);
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(TAB_STATE), state));
app.ui.tab = "SKETCH".into();
let frame = |time| {
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
TabAccent::publish("SKETCH", false, false, ui.ctx());
TabAccent::publish(&app, ui.ctx());
crate::gpu::paint_background(ui.painter(), ui.max_rect(), &Tokens::get());
});
let colors: Vec<_> = out
@@ -2179,7 +2175,7 @@ mod tests {
themes.extend(crate::theme_files::builtin().iter().map(|theme| (theme.name.as_str(), crate::theme_files::resolve(theme))));
let mut failures = Vec::new();
for (name, tokens) in themes {
for tab in super::TAB_NAMES.iter().copied() {
for tab in crate::workspace::TABS.iter().copied().chain(["SKETCH", "MESH", "FORM", "SHEET METAL", "RENDER"]) {
let accent = super::TabAccent::for_tab(&tokens, tab);
let ratio = super::contrast_ratio(accent.ink, accent.soft);
if ratio < 4.5 {
@@ -2213,16 +2209,20 @@ mod tests {
#[test]
fn tab_accent_context_is_per_app_and_does_not_change_widget_visuals() {
let mut harness = crate::scenario::Harness::new();
let app = &mut harness.app;
let first = crate::fonts::test_context();
let second = crate::fonts::test_context();
let before = first.style_of(egui::Theme::Dark).visuals.clone();
super::TabAccent::publish("SURFACE", false, false, &first);
app.ui.tab = "SURFACE".into();
super::TabAccent::publish(app, &first);
assert_eq!(super::TabAccent::current(&first).color, super::Tokens::get().surface_fill);
assert_eq!(super::TabAccent::current(&second).color, super::Tokens::get().accent);
assert_eq!(super::TabAccent::current_icon_accent(&first), Some("surface"));
assert_eq!(super::TabAccent::current_icon_accent(&second), None);
assert_eq!(first.style_of(egui::Theme::Dark).visuals, before, "dialog OK, checkboxes and status styles must not inherit the tab");
super::TabAccent::publish("MODEL", false, false, &first);
app.ui.tab = "MODEL".into();
super::TabAccent::publish(app, &first);
assert_eq!(super::TabAccent::current(&first).soft, super::Tokens::get().accent_soft);
assert_eq!(super::TabAccent::current_icon_accent(&first), None);
}
@@ -2232,6 +2232,7 @@ mod tests {
use crate::theme_files::{OptionValue, ThemeFile};
for base in ["dark", "light"] {
let ctx = crate::fonts::test_context();
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
let file = crate::theme_files::tab_fixture(base);
set_dark(file.base.is_dark());
set_vision(ColorVision::Off);
@@ -2246,19 +2247,19 @@ mod tests {
});
set_custom(custom)
};
let publish = |tab: &str, time| {
ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| TabAccent::publish(tab, false, false, ui.ctx()))
.textures_delta
.clear();
let publish = |app: &crate::SolveApp, time| {
ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| TabAccent::publish(app, ui.ctx())).textures_delta.clear();
};
prepare(&file, Some(true));
apply(&ctx);
let before = ctx.global_style().visuals.clone();
let base_custom = CUSTOM.get();
publish("MODEL", 0.0);
app.ui.tab = "MODEL".into();
publish(&app, 0.0);
assert_eq!(ctx.global_style().visuals, before);
publish("SKETCH", 1.0);
publish("SKETCH", 1.12);
app.ui.tab = "SKETCH".into();
publish(&app, 1.0);
publish(&app, 1.12);
let panel = Tokens::get().panel;
assert_ne!(panel, before.panel_fill, "exercise an actual animated panel fill");
let mut animated = before.clone();
@@ -2267,24 +2268,24 @@ mod tests {
assert_eq!(ctx.global_style().visuals, animated, "all non-surface widget/selection/status visuals stay unchanged");
// Repair either owned fill independently, even when the panel already matches.
ctx.global_style_mut(|style| style.visuals.window_fill = before.window_fill);
publish("SKETCH", 1.12);
publish(&app, 1.12);
assert_eq!(ctx.global_style().visuals, animated);
assert!(!prepare(&file, Some(false)), "toggling surfaces does not change the base Custom tokens");
assert_eq!(CUSTOM.get(), base_custom);
// Deliberately omit apply(): a full restyle would conceal stale animated fills.
publish("SKETCH", 1.13);
publish(&app, 1.13);
assert_eq!(ctx.global_style().visuals, before, "turning surfaces off must restore both base fills");
assert!(!prepare(&file, Some(true)));
publish("SKETCH", 2.0);
publish(&app, 2.0);
assert_ne!(ctx.global_style().visuals.panel_fill, before.panel_fill);
let mut plain = file.clone();
plain.tabs.clear();
plain.options.clear();
assert!(!prepare(&plain, None), "a no-option theme can retain the exact same base tokens");
assert_eq!(CUSTOM.get(), base_custom);
publish("SKETCH", 2.1);
publish(&app, 2.1);
assert_eq!(ctx.global_style().visuals, before, "restoring a no-option theme must release the surface fills");
}
}
+9 -9
View File
@@ -13,8 +13,8 @@ use solidcraft_engine::doc::{
use solidcraft_engine::schema::edit::PlaybackAction;
use crate::SolveApp;
use crate::context_menu::Item;
use crate::theme::Tokens;
use crate::ui_kit::Item;
/// Apply transport actions from the current marker; endpoints stay bounded, including an
/// empty design. All orientations reach the same rollback command.
@@ -52,14 +52,14 @@ pub(crate) fn transport_response(app: &mut SolveApp, control: crate::ui_kit::His
/// 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> {
pub(crate) fn transport_items(app: &SolveApp) -> Vec<crate::context_menu::Item> {
let show = !app.preferences.history_play_button;
let label = crate::i18n::tr(if show { "ui.history_play_button_show" } else { "ui.history_play_button_hide" });
let mut items = vec![crate::ui_kit::Item::action("ui.historyPlayButton", label, "timeline_play").with(json!({ "show": show }))];
let mut items = vec![crate::context_menu::Item::action("ui.historyPlayButton", label, "timeline_play").with(json!({ "show": show }))];
if app.session.doc.components.iter().any(|c| c.history_hidden) {
let show_hidden = !app.tree.history_show_hidden;
let label = crate::i18n::tr(if show_hidden { "ui.history_show_hidden" } else { "ui.history_hide_hidden" });
items.push(crate::ui_kit::Item::action("ui.historyShowHidden", label, "eye").with(json!({ "show": show_hidden })));
items.push(crate::context_menu::Item::action("ui.historyShowHidden", label, "eye").with(json!({ "show": show_hidden })));
}
items
}
@@ -1397,7 +1397,7 @@ mod tests {
assert!(handle_at("timeline:CopyBox").is_none(), "the hidden copy leaves the bar");
assert!(handle_at("timeline:PartBox").is_some(), "the rest of the design stays");
// The History controls' menu shows them anyway, and hides them again (#10393).
fn toggle(h: &Harness) -> crate::ui_kit::Item {
fn toggle(h: &Harness) -> context_menu::Item {
let items = context_menu::items(&h.app, &Target::HistoryControls);
items.into_iter().find(|i| i.id == "ui.historyShowHidden").expect("the bar offers to show hidden entries").clone()
}
@@ -1710,7 +1710,7 @@ mod tests {
#[test]
fn playback_shows_each_feature_in_every_history_layout() {
for layout in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for (dock, width) in
[("bottom", 130.0), ("left", 56.0), ("left", crate::ui_kit::history_width(true)), ("left", 320.0), ("under_browser", 170.0)]
{
@@ -2725,13 +2725,13 @@ mod tests {
#[test]
fn both_strips_keep_selection_reorder_marker_and_transport() {
use crate::scenario::{handle_at, rect_of};
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = two_boxes(false);
h.app.ui.layout = mode;
h.settle();
let viewport = h.app.viewport.rect.unwrap();
if mode == crate::ui_kit::LayoutMode::Floating {
assert_eq!(rect_of("history:card").unwrap().bottom(), viewport.bottom() - crate::ui_kit::SHELL_GAP);
if mode == crate::shell::LayoutMode::Floating {
assert_eq!(rect_of("history:card").unwrap().bottom(), viewport.bottom() - crate::shell::SHELL_GAP);
} else {
let strip = rect_of("history:strip").unwrap();
assert_eq!(strip.top(), viewport.bottom());
+13 -13
View File
@@ -463,7 +463,7 @@ pub(crate) fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response
}
Dock::Left => {
let mut rect = available;
let gap = if Container::for_layout(app.ui.layout, Surface::Panel).floating() { crate::ui_kit::SHELL_GAP } else { 0.0 };
let gap = if Container::for_layout(app.ui.layout, Surface::Panel).floating() { crate::shell::SHELL_GAP } else { 0.0 };
rect.min.x += gap;
rect.max.x = (rect.left() + strip_width(app)).min(available.right() - gap);
rect.max.y -= gap;
@@ -1726,7 +1726,7 @@ mod tests {
#[test]
fn docking_previews_match_all_three_destinations_and_stacked_collapse_shares_height() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.layout = mode;
@@ -1766,7 +1766,7 @@ mod tests {
"{mode:?} {dock}: preview={preview:?} card={card:?}"
);
assert_eq!(h.app.session.doc.to_json(), design);
if mode == crate::ui_kit::LayoutMode::Floating {
if mode == crate::shell::LayoutMode::Floating {
assert_eq!(h.app.viewport.rect, projection);
}
}
@@ -1806,13 +1806,13 @@ mod tests {
h.frames(5);
assert!(h.app.preferences.timeline_left_browser_collapsed && h.app.preferences.timeline_collapsed);
assert!((h.app.shell.browser.unwrap().top() - browser.top()).abs() < 1.0);
assert!(h.app.shell.history.unwrap().bottom() < browser.top() + header_outer_height() * 2.0 + crate::ui_kit::SHELL_GAP + 1.0);
assert!(h.app.shell.history.unwrap().bottom() < browser.top() + header_outer_height() * 2.0 + crate::shell::SHELL_GAP + 1.0);
}
}
#[test]
fn folding_cards_follow_their_bounds_through_the_last_animation_frame() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.layout = mode;
@@ -1846,7 +1846,7 @@ mod tests {
#[test]
fn all_four_transport_buttons_move_to_their_named_positions_in_every_layout() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for (dock, width) in
[("bottom", 130.0), ("left", 56.0), ("left", crate::ui_kit::history_width(true)), ("left", 320.0), ("under_browser", 170.0)]
{
@@ -1868,7 +1868,7 @@ mod tests {
for control in crate::ui_kit::HistoryTransport::ALL {
let button = crate::scenario::rect_of(&format!("history:{}", control.id())).unwrap();
let header = crate::scenario::rect_of(if dock == "bottom" {
if mode == crate::ui_kit::LayoutMode::Floating { "history:card" } else { "history:strip" }
if mode == crate::shell::LayoutMode::Floating { "history:card" } else { "history:strip" }
} else {
"history:header"
})
@@ -1952,7 +1952,7 @@ mod tests {
let previous = crate::theme::is_dark();
for dark in [false, true] {
crate::theme::set_dark(dark);
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for names in [false, true] {
let mut h = Harness::new();
h.app.ui.dark = dark;
@@ -1981,7 +1981,7 @@ mod tests {
#[test]
fn rendered_strip_content_never_exceeds_the_viewport_during_resize() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for width in [70.0, 100.0, 130.0, 200.0] {
for drag_width in [None, Some(60.0), Some(112.0), Some(160.0)] {
let ctx = crate::fonts::test_context();
@@ -2058,7 +2058,7 @@ mod tests {
#[test]
fn history_is_left_of_the_browser_in_both_layouts_widths_and_browser_states() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
for names in [false, true] {
for collapsed in [false, true] {
let mut h = Harness::new();
@@ -2138,7 +2138,7 @@ mod tests {
#[test]
fn the_seam_between_browser_and_history_drags_their_split_and_is_remembered() {
// #7371: under the browser, History took exactly half the column; the seam now moves it.
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.layout = mode;
@@ -2198,7 +2198,7 @@ mod tests {
(false, false, true, opaque),
] {
let mut h = Harness::new();
h.app.ui.layout = if floating { crate::ui_kit::LayoutMode::Floating } else { crate::ui_kit::LayoutMode::Docked };
h.app.ui.layout = if floating { crate::shell::LayoutMode::Floating } else { crate::shell::LayoutMode::Docked };
h.app.ui.show_timeline = true;
h.app.preferences.timeline_left = true;
h.app.preferences.timeline_under_browser = under_browser;
@@ -2210,7 +2210,7 @@ mod tests {
#[test]
fn floating_browser_sits_over_the_workspace_and_docks_again() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut harness = Harness::new();
harness.app.ui.layout = mode;
harness.app.preferences.browser_float = true;
+11 -11
View File
@@ -759,7 +759,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE;
ui.visuals_mut().widgets.hovered.weak_bg_fill = Tokens::get().hover;
let item_if = |ui: &mut egui::Ui, label: &str, key: &str, icon: &str, enabled: bool| {
crate::context_menu::list_row(ui, &crate::ui_kit::Item::action(label, label, icon).key(key).on_if(enabled)).is_some()
crate::context_menu::list_row(ui, &crate::context_menu::Item::action(label, label, icon).key(key).on_if(enabled)).is_some()
};
let item = |ui: &mut egui::Ui, label: &str, key: &str, icon: &str| item_if(ui, label, key, icon, true);
// Insert, Save and Export act on the design, which Home hides.
@@ -1520,15 +1520,15 @@ fn dropdown_arrow(painter: &egui::Painter, at: egui::Pos2, ink: Color32) {
crate::ui_kit::dropdown_indicator(painter, Rect::from_center_size(at, vec2(10.0, 10.0)), ink);
}
pub(crate) fn shell_layout(app: &SolveApp) -> crate::ui_kit::LayoutMode {
if crate::render_workspace::active(app) { crate::ui_kit::LayoutMode::Floating } else { app.ui.layout }
pub(crate) fn shell_layout(app: &SolveApp) -> crate::shell::LayoutMode {
if crate::render_workspace::active(app) { crate::shell::LayoutMode::Floating } else { app.ui.layout }
}
pub(crate) fn shell_cards(app: &SolveApp, available: Rect) -> crate::ui_kit::Cards {
pub(crate) fn shell_cards(app: &SolveApp, available: Rect) -> crate::shell::Cards {
if crate::render_workspace::active(app) {
crate::ui_kit::StageBar::cards(available)
} else {
crate::ui_kit::Cards::new(available, shell_layout(app), reserved_height(app), WORKSPACE_WIDTH)
crate::shell::Cards::new(available, shell_layout(app), reserved_height(app))
}
}
@@ -1549,7 +1549,7 @@ pub(crate) fn reserved_height(app: &SolveApp) -> f32 {
#[derive(Clone, Copy)]
struct RibbonShape {
floating: bool,
cards: crate::ui_kit::Cards,
cards: crate::shell::Cards,
r: Rect,
ws: Rect,
folded: bool,
@@ -1599,7 +1599,7 @@ fn open_ribbon(app: &mut SolveApp, ui: &mut egui::Ui, available: Rect, folded: R
let layout = shell_layout(app);
let floating = Container::for_layout(layout, Surface::Panel).floating();
let full = ribbon_height(app.preferences.ribbon_labels);
let cards = crate::ui_kit::Cards::new(available, layout, full, WORKSPACE_WIDTH);
let cards = crate::shell::Cards::new(available, layout, full);
let r = if floating {
Rect::from_min_max(cards.workspace.min, cards.ribbon.max)
} else {
@@ -4841,7 +4841,7 @@ mod dropdown_tests {
/// reached with the arrow keys and comes into view.
#[test]
fn a_tall_drop_down_scrolls_inside_a_short_window() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.call("ui.set", json!({"layout":mode}));
@@ -5125,12 +5125,12 @@ mod dropdown_tests {
/// button's own right-click menu takes it off again, with both options on.
#[test]
fn a_pinned_button_is_removed_from_its_own_menu() {
for mode in [crate::ui_kit::LayoutMode::Floating, crate::ui_kit::LayoutMode::Docked] {
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
a_pinned_button_is_removed_in(mode);
}
}
fn a_pinned_button_is_removed_in(mode: crate::ui_kit::LayoutMode) {
fn a_pinned_button_is_removed_in(mode: crate::shell::LayoutMode) {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.call("ui.set", json!({"layout":mode}));
@@ -5452,7 +5452,7 @@ mod dropdown_toggle_tests {
assert_eq!(h.app.ui.tab, "SURFACE");
assert!(h.app.ui.ribbon_open, "{layout}: the tab's panels open");
let open = rect_of("ribbon:open").unwrap();
assert!((open.height() - whole.height()).abs() <= 2.0 * crate::ui_kit::SHELL_GAP + 1.0, "{layout}: {open:?} vs {whole:?}");
assert!((open.height() - whole.height()).abs() <= 2.0 * crate::shell::SHELL_GAP + 1.0, "{layout}: {open:?} vs {whole:?}");
assert!(rect_of("panel:SURFACE:CREATE").is_some(), "{layout}: the panels are drawn");
if layout == "compact" {
let top = h.app.viewport.rect.unwrap().top();
+1 -1
View File
@@ -18,7 +18,7 @@ use std::collections::BTreeMap;
use serde_json::{Value, json};
use solidcraft_engine::{CommandSpec, command_specs};
use crate::ui_kit::Item;
use crate::context_menu::Item;
use crate::{SolveApp, ui_kit};
/// The only workspace so far.
+22 -22
View File
@@ -1,7 +1,7 @@
//! Actions elements for dense cards; see `docs/ui-kit.md` for tokens and usage.
use super::metrics::*;
use crate::ui_kit::BackingTooltipExt as _;
use crate::{fonts::RichTextExt as _, theme::Tokens};
use crate::{fonts::RichTextExt as _, scenario, theme::Tokens};
use egui::{Response, RichText, Ui, vec2};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -182,7 +182,7 @@ pub(crate) fn text_button(ui: &mut Ui, label: &str, enabled: bool) -> Response {
enabled,
egui::Button::new(RichText::new(label).size(t.font_label).color(t.form_ink)).frame(false).min_size(vec2(0.0, t.control_height)),
);
solidcraft_ui_registry::publish_target(&format!("dialog:action:{label}"), response.rect);
scenario::publish_target(&format!("dialog:action:{label}"), response.rect);
response
}
@@ -241,15 +241,15 @@ pub(super) fn footer_with_left(
t.dialog_footer,
);
t.secondary_hline(ui.painter(), band.x_range(), band.top(), t.dialog_rule);
solidcraft_ui_registry::publish_rect("dialog:footer", band);
solidcraft_ui_registry::publish_count("dialog:ok:enabled", f64::from(ok_enabled && ui.is_enabled()));
scenario::publish_rect("dialog:footer", band);
scenario::publish_count("dialog:ok:enabled", f64::from(ok_enabled && ui.is_enabled()));
let inner = band.shrink2(vec2(t.space_12, t.space_10));
solidcraft_ui_registry::publish_rect("dialog:footer:actions", inner);
scenario::publish_rect("dialog:footer:actions", inner);
let mut action = if ui.is_enabled() { keyboard(ui.ctx(), ok_enabled) } else { Action::None };
let look = ActionLook::new(ok_enabled, !nothing_to_apply, selected, danger);
if apply_label.is_some() {
let live = if confirm { !nothing_to_apply } else { look.live(Action::Apply) };
solidcraft_ui_registry::publish_count("dialog:apply:enabled", f64::from(live && ui.is_enabled()));
scenario::publish_count("dialog:apply:enabled", f64::from(live && ui.is_enabled()));
}
let mut actions_left = inner.right();
let row = ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
@@ -299,8 +299,8 @@ pub(super) fn footer_with_left(
ui.add(button)
});
pointer_on_hover(ui, &response);
solidcraft_ui_registry::publish_target("dialog:apply", response.rect);
solidcraft_ui_registry::publish_handle(&format!("dialog:{label}"), response.rect.center());
scenario::publish_target("dialog:apply", response.rect);
scenario::publish_handle(&format!("dialog:{label}"), response.rect.center());
if response.clicked() {
action = Action::Apply;
}
@@ -379,8 +379,8 @@ fn action_button(ui: &mut Ui, label: &str, kind: Action, look: &ActionLook, padd
Action::Apply => "dialog:apply",
_ => "dialog:cancel",
};
solidcraft_ui_registry::publish_target(key, response.rect);
solidcraft_ui_registry::publish_handle(&format!("dialog:{label}"), response.rect.center());
scenario::publish_target(key, response.rect);
scenario::publish_handle(&format!("dialog:{label}"), response.rect.center());
response
}
@@ -427,7 +427,7 @@ impl<'a> HeaderActions<'a> {
/// Draws the buttons; returns what was clicked, or what Enter and Esc ask for.
pub(crate) fn show(&self, ui: &mut Ui, ok_enabled: bool) -> Action {
let ok_enabled = ok_enabled && self.rect(Action::Ok).is_some();
solidcraft_ui_registry::publish_count("dialog:ok:enabled", f64::from(ok_enabled && ui.is_enabled()));
scenario::publish_count("dialog:ok:enabled", f64::from(ok_enabled && ui.is_enabled()));
let mut action = if ui.is_enabled() { keyboard(ui.ctx(), ok_enabled) } else { Action::None };
let look = ActionLook::new(ok_enabled, true, None, false);
let padding = Tokens::get().space_8;
@@ -442,7 +442,7 @@ impl<'a> HeaderActions<'a> {
}
}
if let Some(all) = self.slots.iter().map(|(_, _, rect)| *rect).reduce(|a, b| a.union(b)) {
solidcraft_ui_registry::publish_rect("dialog:header:actions", all);
scenario::publish_rect("dialog:header:actions", all);
}
action
}
@@ -500,7 +500,7 @@ pub(crate) fn button_pair(ui: &mut egui::Ui, key: &str, states: [bool; 2], butto
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, label));
t.button_bevel(ui, &response, on);
pointer_on_hover(ui, &response);
solidcraft_ui_registry::publish_target(&format!("{key}:{}", button.key), rect);
crate::scenario::publish_target(&format!("{key}:{}", button.key), rect);
if let Some(value) = clicked.get_mut(index) {
*value = response.backing_on_hover_text(label).clicked();
}
@@ -572,8 +572,8 @@ fn button_widget(label: egui::WidgetText, options: Button) -> impl egui::Widget
pub fn button(ui: &mut Ui, label: impl Into<egui::WidgetText>, options: Button) -> Response {
let label = label.into();
let key = label.text().to_owned();
if super::settings_search::suppress_chrome(ui) && !super::settings_search::texts_hit(ui, &[&key]) {
return super::settings_search::hidden_response(ui);
if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[&key]) {
return crate::prefs_search::hidden_response(ui);
}
let response = match options {
Button::Enabled(enabled) if super::settings_page_active(ui) => {
@@ -621,7 +621,7 @@ pub(crate) fn button_at(ui: &mut Ui, id: &str, rect: egui::Rect, label: impl Int
/// Accent action outside the footer, using the same shared button component.
pub(crate) fn primary_button(ui: &mut Ui, label: &str) -> Response {
let response = button(ui, crate::i18n::tr_label(label), Button::Primary);
solidcraft_ui_registry::publish_target(&format!("dialog:button:{label}"), response.rect);
scenario::publish_target(&format!("dialog:button:{label}"), response.rect);
response
}
@@ -649,13 +649,13 @@ mod tests;
// Additional identities must never replace an existing field handle in compound rows.
fn publish_button(ui: &Ui, response: &Response, label: &str) {
let key = format!("dialog:button:{}:{label}", super::layout::label(ui));
solidcraft_ui_registry::publish_target(&key, response.rect);
scenario::publish_target(&key, response.rect);
}
/// Nested action menu in a dense table. Tokens: scope control/font tokens.
pub(crate) fn action_menu<R>(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse<Option<R>> {
let response = super::Menu::button(ui, crate::i18n::tr_label(label), |ui| super::scope(ui, add));
solidcraft_ui_registry::publish_target(&format!("dialog:menu:{label}"), response.response.rect);
scenario::publish_target(&format!("dialog:menu:{label}"), response.response.rect);
response
}
@@ -747,7 +747,7 @@ pub(crate) fn selection_actions(ui: &mut Ui, name: &str, actions: &[super::Stage
let (rect, name_response) = ui.allocate_exact_size(vec2(width, t.control_height - t.space_2), egui::Sense::click());
name_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name));
super::text::paint_text(ui, rect.shrink2(vec2(t.space_6, 0.0)), name, font, accent.ink, false);
solidcraft_ui_registry::publish_target("selection:name", rect);
scenario::publish_target("selection:name", rect);
let mut clicked = name_response.clicked().then_some(actions.len());
for (index, action) in actions.iter().enumerate() {
let response = ui
@@ -756,7 +756,7 @@ pub(crate) fn selection_actions(ui: &mut Ui, name: &str, actions: &[super::Stage
icon_button_colored(ui, rect, action.icon.unwrap_or("inspect"), action.label, action.primary.then_some(accent.ink))
})
.inner;
solidcraft_ui_registry::publish_target(&format!("selection:action:{index}"), response.rect);
scenario::publish_target(&format!("selection:action:{index}"), response.rect);
if response.clicked() {
clicked = Some(index);
}
@@ -827,8 +827,8 @@ fn icon_label_presentation(ui: &mut Ui, label: &str, icon: Option<&str>, enabled
// A scope cannot reserve its natural width in the parent's wrapping row.
let response = if super::settings_page_active(ui) { ui.add_enabled(enabled, paint) } else { ui.add_enabled_ui(enabled, paint).inner };
let key = format!("dialog:button:{}:{label}", super::layout::label(ui));
solidcraft_ui_registry::publish_handle(&key, response.rect.center());
solidcraft_ui_registry::publish_rect(&key, response.rect);
scenario::publish_handle(&key, response.rect.center());
scenario::publish_rect(&key, response.rect);
response
}
+1 -1
View File
@@ -245,7 +245,7 @@ pub(crate) fn assistant_approval<R>(ui: &mut Ui, title: &str, body: impl FnOnce(
.inner
},
);
solidcraft_ui_registry::publish_rect("assistant:approval", output.response.rect);
crate::scenario::publish_rect("assistant:approval", output.response.rect);
output
}
+5 -5
View File
@@ -59,9 +59,9 @@ pub(crate) fn checkbox(ui: &mut Ui, label: &str, value: &mut bool) -> Response {
/// Mixed values become checked when activated; the caller owns whether selection is mixed.
/// Tokens: space_14/3/2/6, radius_sm, control_fill/border, form_primary/ink, hover, on_accent.
pub(crate) fn checkbox_indeterminate(ui: &mut Ui, label: &str, value: &mut bool, mixed: bool) -> Response {
super::settings_search::note_unlabelled(ui, &[label]);
if super::settings_search::querying(ui) && !super::settings_search::visible(ui, &[label]) {
return super::settings_search::hidden_response(ui);
crate::prefs_search::note_unlabelled(ui, &[label]);
if crate::prefs_search::querying(ui) && !crate::prefs_search::visible(ui, &[label]) {
return crate::prefs_search::hidden_response(ui);
}
let t = Tokens::get();
let font = egui::FontId::proportional(t.font_label);
@@ -75,8 +75,8 @@ pub(crate) fn checkbox_indeterminate(ui: &mut Ui, label: &str, value: &mut bool,
let width = (t.space_14 + gap + galley.size().x).max(ui.spacing().interact_size.y).min(super::metrics::dimension(ui.available_width()));
let height = ui.spacing().interact_size.y.max(t.space_14).max(galley.size().y);
let (rect, mut response) = ui.allocate_exact_size(vec2(width, height), egui::Sense::click());
super::settings_search::remember(ui, label, rect);
super::settings_search::follow(ui, label, rect);
crate::prefs_search::remember(ui, label, rect);
crate::prefs_search::follow(ui, label, rect);
if response.clicked() {
*value = mixed || !*value;
response.mark_changed();
+31 -31
View File
@@ -5,9 +5,9 @@ use super::{
metrics::*,
text::paint_text,
};
use crate::theme::Tokens;
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{Container, Surface};
use crate::{scenario, theme::Tokens};
use egui::{Ui, vec2};
/// Text segments retain mixed states and optional compact clipping instead of dropdown fallback.
@@ -36,12 +36,12 @@ pub(crate) struct Segments<'a> {
}
pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], config: Segments<'_>) -> bool {
super::settings_search::note_unlabelled(ui, options);
if super::settings_search::suppress_chrome(ui) && !super::settings_search::texts_hit(ui, options) {
crate::prefs_search::note_unlabelled(ui, options);
if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, options) {
return false;
}
if super::settings_search::querying(ui) && !super::settings_search::shows_all(ui) {
super::settings_search::reveal(ui);
if crate::prefs_search::querying(ui) && !crate::prefs_search::shows_all(ui) {
crate::prefs_search::reveal(ui);
}
let Segments {
focus,
@@ -194,11 +194,11 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con
);
t.button_bevel_with_radius(ui, &response, on, rounding);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, segment_enabled, on, *text));
solidcraft_ui_registry::publish_target(&format!("dialog:{}:{option}", label(ui)), segment);
scenario::publish_target(&format!("dialog:{}:{option}", label(ui)), segment);
if let Some(target) = targets.get(index) {
let visible = segment.intersect(ui.clip_rect());
if visible.is_positive() {
solidcraft_ui_registry::publish_target(target, visible);
scenario::publish_target(target, visible);
}
}
let response =
@@ -216,7 +216,7 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con
egui::StrokeKind::Inside,
);
}
solidcraft_ui_registry::publish_target_as(&format!("dialog:{}", label(ui)), &format!("dialog:segments:{}", label(ui)), rect);
scenario::publish_target_as(&format!("dialog:{}", label(ui)), &format!("dialog:segments:{}", label(ui)), rect);
clicked
}
@@ -280,8 +280,8 @@ pub(crate) fn segmented_icons(ui: &mut Ui, selected: &mut usize, options: &[Icon
}
t.button_bevel_with_radius(ui, &response, on, rounding);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, option.key));
solidcraft_ui_registry::publish_handle(&format!("dialog:{}:{}", label(ui), option.key), segment.center());
solidcraft_ui_registry::publish_target(option.key, segment);
scenario::publish_handle(&format!("dialog:{}:{}", label(ui), option.key), segment.center());
scenario::publish_target(option.key, segment);
let response = response.backing_on_hover_text(&option.tip);
if response.clicked() {
*selected = index;
@@ -291,7 +291,7 @@ pub(crate) fn segmented_icons(ui: &mut Ui, selected: &mut usize, options: &[Icon
if !catalog && t.borders.bevel.is_none() {
ui.painter().rect_stroke(rect, t.control_radius, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside);
}
solidcraft_ui_registry::publish_target_as(&format!("dialog:{}", label(ui)), &format!("dialog:segments:{}", label(ui)), rect);
scenario::publish_target_as(&format!("dialog:{}", label(ui)), &format!("dialog:segments:{}", label(ui)), rect);
clicked
}
@@ -316,7 +316,7 @@ pub(crate) fn checkbox_grid(ui: &mut Ui, items: &mut [(&str, &mut bool)], column
let text_rect = egui::Rect::from_min_max(egui::pos2((square.right() + t.space_6).min(rect.right()), rect.top()), rect.max);
paint_text(ui, text_rect, text, egui::FontId::proportional(t.font_body), t.form_ink, false);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), **value, translated));
solidcraft_ui_registry::publish_target_as(&format!("dialog:{label}"), &format!("dialog:checkbox:{label}"), rect);
scenario::publish_target_as(&format!("dialog:{label}"), &format!("dialog:checkbox:{label}"), rect);
}
});
}
@@ -344,7 +344,7 @@ pub(crate) fn checkbox_row(ui: &mut egui::Ui, key: &str, label: &str, on: bool)
.with_clip_rect(ui.clip_rect().intersect(egui::Rect::from_min_max(rect.min, egui::pos2(square.left() - t.space_6, rect.bottom()))));
painter.text(rect.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(t.font_label), t.form_ink);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), on, label));
solidcraft_ui_registry::publish_target(&format!("sketch_palette:toggle:{key}"), rect);
crate::scenario::publish_target(&format!("sketch_palette:toggle:{key}"), rect);
response.backing_on_hover_text(label).clicked()
}
@@ -364,7 +364,7 @@ pub(crate) fn text_emphasis(ui: &mut Ui, bold: &mut bool, italic: &mut bool, bol
response = response.backing_on_disabled_hover_text(crate::i18n::tr("sketch_text.no_bold"));
}
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled() && enabled, *value, crate::i18n::tr(id)));
solidcraft_ui_registry::publish_target_as(&format!("dialog:{key}"), &format!("dialog:emphasis:{key}"), response.rect);
scenario::publish_target_as(&format!("dialog:{key}"), &format!("dialog:emphasis:{key}"), response.rect);
}
}
@@ -398,7 +398,7 @@ pub(crate) fn tool_choices<'a>(ui: &mut Ui, label: &str, current: &str, choices:
);
crate::icons::paint_icon(ui.painter(), response.rect.shrink(t.space_6), choice.icon, f32::from(t.icon.a()) / 255.0);
t.button_bevel(ui, &response, on);
solidcraft_ui_registry::publish_target(&format!("sketch_palette:feature:{}", choice.key), response.rect);
scenario::publish_target(&format!("sketch_palette:feature:{}", choice.key), response.rect);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, on, choice.label));
if response.backing_on_hover_text(choice.label).clicked() && enabled {
start = Some(choice.key);
@@ -418,11 +418,11 @@ pub(crate) fn toggle_icon(ui: &mut Ui, row: &str, label: &str, selected: &mut bo
crate::icons::paint_icon(ui.painter(), response.rect.shrink(t.space_3), icon, f32::from(t.icon.a()) / 255.0);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, *selected, crate::i18n::tr_label(label)));
t.button_bevel(ui, &response, *selected);
solidcraft_ui_registry::publish_handle(&format!("dialog:{row}:{label}"), response.rect.center());
crate::scenario::publish_handle(&format!("dialog:{row}:{label}"), response.rect.center());
if response.clone().backing_on_hover_text(crate::i18n::tr_label(label)).clicked() {
*selected = !*selected;
}
solidcraft_ui_registry::publish_rect(&format!("dialog:{row}:{label}"), response.rect);
scenario::publish_rect(&format!("dialog:{row}:{label}"), response.rect);
response
}
@@ -435,15 +435,15 @@ mod tests;
pub(crate) fn list_picker(ui: &mut Ui, key: &str, text: &str, selected: bool) -> egui::Response {
let response =
ui.add_sized(egui::vec2(super::metrics::dimension(ui.available_width()), super::row_height()), egui::Button::selectable(selected, text));
solidcraft_ui_registry::publish_target(&format!("dialog:list:{key}"), response.rect);
crate::scenario::publish_target(&format!("dialog:list:{key}"), response.rect);
response
}
/// Intrinsic-width selection chip for horizontal lists, sharing list-picker styling.
pub(crate) fn choice_chip(ui: &mut Ui, key: &str, text: &str, selected: bool) -> egui::Response {
super::settings_search::note_unlabelled(ui, &[text]);
if super::settings_search::querying(ui) && !super::settings_search::visible(ui, &[text]) {
return super::settings_search::hidden_response(ui);
crate::prefs_search::note_unlabelled(ui, &[text]);
if crate::prefs_search::querying(ui) && !crate::prefs_search::visible(ui, &[text]) {
return crate::prefs_search::hidden_response(ui);
}
let t = Tokens::get();
if super::settings_page_active(ui) {
@@ -456,9 +456,9 @@ pub(crate) fn choice_chip(ui: &mut Ui, key: &str, text: &str, selected: bool) ->
.stroke(egui::Stroke::new(1.0, if selected { t.form_primary } else { t.control_border }))
.corner_radius(t.radius_xl),
);
super::settings_search::remember(ui, text, response.rect);
super::settings_search::follow(ui, text, response.rect);
solidcraft_ui_registry::publish_target(&format!("dialog:list:{key}"), response.rect);
crate::prefs_search::remember(ui, text, response.rect);
crate::prefs_search::follow(ui, text, response.rect);
crate::scenario::publish_target(&format!("dialog:list:{key}"), response.rect);
return response;
}
if super::layout::in_catalog(ui) {
@@ -475,13 +475,13 @@ pub(crate) fn choice_chip(ui: &mut Ui, key: &str, text: &str, selected: bool) ->
false,
);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, text));
solidcraft_ui_registry::publish_target(&format!("dialog:list:{key}"), rect);
crate::scenario::publish_target(&format!("dialog:list:{key}"), rect);
return response;
}
let width = (ui.painter().layout_no_wrap(text.to_owned(), crate::fonts::semibold(t.font_label), t.text).size().x + t.space_24)
.clamp(t.control_height * 2.0, t.form_card_width);
let response = ui.add_sized(vec2(width, super::row_height()), egui::Button::selectable(selected, text).truncate());
solidcraft_ui_registry::publish_target(&format!("dialog:list:{key}"), response.rect);
crate::scenario::publish_target(&format!("dialog:list:{key}"), response.rect);
response
}
@@ -558,7 +558,7 @@ pub(crate) fn thumbnail_choice(ui: &mut Ui, key: &str, text: &str, state: Thumbn
if state.modified {
let dot = egui::pos2(rect.right() - t.space_6, rect.top() + t.space_6);
ui.painter().circle_filled(dot, t.space_3, t.warning);
solidcraft_ui_registry::publish_rect(&format!("dialog:list:{key}:modified"), egui::Rect::from_center_size(dot, vec2(t.space_6, t.space_6)));
crate::scenario::publish_rect(&format!("dialog:list:{key}:modified"), egui::Rect::from_center_size(dot, vec2(t.space_6, t.space_6)));
}
if let Some(progress) = state.progress {
let center = thumb.center();
@@ -573,10 +573,10 @@ pub(crate) fn thumbnail_choice(ui: &mut Ui, key: &str, text: &str, state: Thumbn
})
.collect();
ui.painter().add(egui::Shape::line(points, egui::Stroke::new(t.space_2, accent.ink)));
solidcraft_ui_registry::publish_rect(&format!("dialog:list:{key}:progress"), thumb);
crate::scenario::publish_rect(&format!("dialog:list:{key}:progress"), thumb);
}
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), state.selected, text));
solidcraft_ui_registry::publish_target(&format!("dialog:list:{key}"), response.rect);
crate::scenario::publish_target(&format!("dialog:list:{key}"), response.rect);
response.backing_on_hover_text(text)
}
@@ -745,7 +745,7 @@ pub(crate) fn popup_list(
// Tip the full name first: long identifiers still clip inside a capped popup.
let tip = if option.detail.is_empty() { option.text.to_owned() } else { format!("{}\n{}", option.text, option.detail) };
let response = response.backing_on_hover_text(tip);
solidcraft_ui_registry::publish_handle(&format!("dialog:suggestion:{index}"), response.rect.center());
crate::scenario::publish_handle(&format!("dialog:suggestion:{index}"), response.rect.center());
if response.clicked() {
chosen = Some(index);
}
@@ -759,7 +759,7 @@ pub(crate) fn popup_list(
})
});
});
solidcraft_ui_registry::publish_rect("dialog:suggestions", area.response.rect);
crate::scenario::publish_rect("dialog:suggestions", area.response.rect);
ctx.data_mut(|d| d.insert_temp(id.with("rows"), rows));
chosen
}
+8 -8
View File
@@ -1,14 +1,14 @@
//! Document RGB and optional separate opacity editing. Interface paint uses tokens; gradients are colour data.
use super::recent_colors::{MAX_RECENT_COLORS, recent_colors, remember_color};
use super::{layout::label, metrics::dimension};
use crate::recent_colors::{MAX_RECENT_COLORS, recent_colors, remember_color};
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{Container, Surface};
use crate::{i18n, theme::Tokens};
use crate::{i18n, scenario, theme::Tokens};
use egui::{Color32, Response, Sense, Ui, ecolor::HsvaGamma, pos2, vec2};
fn publish(key: &str, response: &Response) {
solidcraft_ui_registry::publish_rect(key, response.rect);
solidcraft_ui_registry::publish_handle(key, response.rect.center());
scenario::publish_rect(key, response.rect);
scenario::publish_handle(key, response.rect.center());
}
/// Linear RGB swatch and precise channel fields. The preview is clipped to display gamut;
@@ -144,7 +144,7 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio
let rect =
egui::Rect::from_min_size(rect.min + vec2(index as f32 * rect.width() / 3.0, 0.0), vec2(rect.width() / 3.0, rect.height()));
ui.interact(rect, ui.scope_id().with(("format_tip", index)), Sense::hover()).backing_on_hover_text(i18n::tr(key));
solidcraft_ui_registry::publish_handle(&format!("color:format:{name}"), rect.center());
scenario::publish_handle(&format!("color:format:{name}"), rect.center());
}
});
hexadecimal = selected == 2;
@@ -296,7 +296,7 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio
}
});
// A colour joins the recent list once an edit is finished: a drag or a held step button is
// released, a typed value is entered. Every picker shares the list (`super::recent_colors`).
// released, a typed value is entered. Every picker shares the list (`crate::recent_colors`).
// While the popover stays open, fine-tuning replaces the colour it added rather than filling
// the list with each step; a colour taken from the list starts a new entry.
let session_id = id.with("recent_session");
@@ -320,7 +320,7 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio
d.insert_temp(hex_id, hex);
d.insert_temp(format_id, hexadecimal);
});
solidcraft_ui_registry::publish_rect("color:popover", ui.min_rect());
scenario::publish_rect("color:popover", ui.min_rect());
*rgb != before_rgb || opacity.as_deref().copied() != before_opacity || hsv != before || accepted_mixed
}
@@ -405,7 +405,7 @@ fn spin_channel(ui: &mut Ui, name: &str, tip: Option<&str>, value: &mut f64, spi
egui::DragValue::new(value)
.range(0.0..=max)
.speed(speed)
.custom_parser(super::parse_number)
.custom_parser(crate::prefs::parse_number)
.custom_formatter(move |v, _| format!("{v:.decimals$}")),
);
response.widget_info(|| {
+4 -5
View File
@@ -1,5 +1,4 @@
use super::*;
use crate::scenario;
use crate::ui_kit::test_support::pointer;
fn frame<R>(ctx: &egui::Context, events: Vec<egui::Event>, mut add: impl FnMut(&mut Ui) -> R) {
@@ -474,11 +473,11 @@ fn recent_list_is_capped_unique_and_synced_with_the_saved_one() {
assert_eq!(recent.iter().filter(|c| **c == [35, 0, 0]).count(), 1);
// Picks flow to the saved list once…
let mut saved = Vec::new();
crate::ui_kit::recent_colors::share(&ctx, &mut saved);
crate::recent_colors::share(&ctx, &mut saved);
assert_eq!(saved, recent);
// …and a list loaded from preferences (even an oversized one) is what the pickers show.
let mut loaded: Vec<[u8; 3]> = (0..100u8).map(|i| [0, i, 0]).collect();
crate::ui_kit::recent_colors::share(&ctx, &mut loaded);
crate::recent_colors::share(&ctx, &mut loaded);
assert_eq!(loaded.len(), MAX_RECENT_COLORS);
assert_eq!(recent_colors(&ctx), loaded);
}
@@ -493,9 +492,9 @@ fn recent_colours_are_saved_with_the_preferences_and_hostile_entries_dropped() {
assert_eq!(app.ui.recent_colors.len(), MAX_RECENT_COLORS);
assert_eq!(app.ui.recent_colors.first(), Some(&[1, 2, 3]));
let ctx = crate::fonts::test_context();
crate::ui_kit::recent_colors::share(&ctx, &mut app.ui.recent_colors);
crate::recent_colors::share(&ctx, &mut app.ui.recent_colors);
remember_color(&ctx, [9, 8, 7], None);
crate::ui_kit::recent_colors::share(&ctx, &mut app.ui.recent_colors);
crate::recent_colors::share(&ctx, &mut app.ui.recent_colors);
let mut restored = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default());
restored.load_prefs(&app.prefs());
assert_eq!(restored.ui.recent_colors.first(), Some(&[9, 8, 7]));
+4 -4
View File
@@ -1,5 +1,5 @@
//! Shared image-comparison divider and corner chips. Geometry and ink use kit tokens.
use crate::theme::Tokens;
use crate::{scenario, theme::Tokens};
use egui::{Rect, Sense, Ui, pos2, vec2};
pub(crate) fn comparison_position(position: f32) -> f32 {
@@ -21,7 +21,7 @@ pub(crate) fn comparison_divider(ui: &mut Ui, canvas: Rect, position: &mut f32,
}
comparison_divider_chrome(ui, canvas, *position);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), label));
solidcraft_ui_registry::publish_target("render:compare:divider", rail);
scenario::publish_target("render:compare:divider", rail);
response
}
@@ -37,7 +37,7 @@ pub(crate) fn comparison_divider_chrome(ui: &Ui, canvas: Rect, position: f32) {
for offset in [-t.space_6, 0.0, t.space_6] {
painter.circle_filled(handle.center() + vec2(0.0, offset), t.space_2, accent.ink);
}
solidcraft_ui_registry::publish_rect("render:compare:handle", handle);
scenario::publish_rect("render:compare:handle", handle);
}
/// Token-painted chip anchored inside either clipped half of the image canvas.
@@ -51,5 +51,5 @@ pub(crate) fn comparison_badge(ui: &mut Ui, bounds: Rect, label: &str, right: bo
painter.rect_filled(rect, t.control_radius, t.panel);
painter.rect_stroke(rect, t.control_radius, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside);
painter.with_clip_rect(rect).galley(pos2(rect.left() + t.space_8, rect.center().y - galley.size().y * 0.5), galley, t.form_ink);
solidcraft_ui_registry::publish_rect(if right { "render:compare:right-label" } else { "render:compare:left-label" }, rect);
scenario::publish_rect(if right { "render:compare:right-label" } else { "render:compare:left-label" }, rect);
}
+5 -5
View File
@@ -1,6 +1,6 @@
//! Modal confirmations share the Panel/Window frame and the single kit footer.
use super::{Action, Footer, Window, actions::footer_with_left, text::paint_text};
use crate::theme::Tokens;
use crate::{scenario, theme::Tokens};
use egui::{Ui, vec2};
#[derive(Clone, Copy)]
@@ -68,7 +68,7 @@ impl<'a> Confirm<'a> {
pub(crate) fn show(self, ctx: &egui::Context, valid: bool, message: impl FnOnce(&mut Ui)) -> (bool, Action) {
// Reviewed reports and native pickers temporarily own input. Keep the pending
// answer untouched and resume the modal when that boundary closes.
if super::input_blocked() {
if crate::issue_report::cad_input_blocked() {
return (false, Action::None);
}
self.window.surface(ctx, true, |ui| self.content(ui, valid, message))
@@ -104,7 +104,7 @@ impl<'a> Confirm<'a> {
} else {
crate::icons::paint_accented(ui.painter(), disc.shrink(t.space_8), self.icon, accent, 1.0);
}
solidcraft_ui_registry::publish_rect("confirm:disc", disc);
scenario::publish_rect("confirm:disc", disc);
ui.vertical(|ui| {
ui.set_width(ui.available_width().max(0.0));
let (heading, _) = ui.allocate_exact_size(vec2(ui.available_width().max(0.0), t.form_close_size), egui::Sense::hover());
@@ -118,8 +118,8 @@ impl<'a> Confirm<'a> {
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), self.title));
let response = super::icon_button_at(ui, close, "close", "Close");
closed = response.clicked();
solidcraft_ui_registry::publish_target("dialog:close", response.rect);
solidcraft_ui_registry::publish_rect("dialog:header", heading);
scenario::publish_target("dialog:close", response.rect);
scenario::publish_rect("dialog:header", heading);
message(ui);
if let Some(detail) = self.detail {
super::list_frame(ui, "confirm_detail", |ui| {
+5 -42
View File
@@ -2,9 +2,8 @@
//! A surface declares its purpose; the shell supplies the layout once per frame. Never infer
//! floating/docked presentation from a panel name or keep a second layout preference here.
use crate::frame::Side;
use crate::theme::Tokens;
use crate::{shell::LayoutMode, theme::Tokens};
use egui::{Color32, Context, Frame, Id, Stroke, WidgetText};
use serde::{Deserialize, Serialize};
pub(super) type Background = fn(&egui::Painter, Frame, egui::Rect, egui::Shape) -> egui::Shape;
@@ -465,45 +464,9 @@ impl DockEdges {
pub const ALL: Self = Self { left: true, right: true, top: true, bottom: true };
}
/// Saved workspace layout; automation may override it for a session.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LayoutMode {
#[default]
Floating,
#[serde(rename = "compact", alias = "docked")]
Docked,
}
impl LayoutMode {
pub(crate) fn publish(self, ctx: &egui::Context) {
ctx.data_mut(|data| data.insert_temp(egui::Id::unique("sc_container_layout"), self));
}
pub(crate) fn current(ctx: &egui::Context) -> Self {
ctx.data(|data| data.get_temp(egui::Id::unique("sc_container_layout"))).unwrap_or_default()
}
}
/// The gap from the viewport edge to the floating cards and between the cards.
pub(crate) const SHELL_GAP: f32 = 7.0;
thread_local! {
/// The areas built as [`Surface::Canvas`]: a value box, a dimension's editor, a readout.
static CANVAS_AREAS: std::cell::RefCell<std::collections::HashSet<Id>> = std::cell::RefCell::default();
/// Set by the host while something else owns the input over the CAD view (a reviewed issue
/// report, a native file picker): kit surfaces other than [`Surface::InputShield`] take none.
static INPUT_BLOCKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Does the host hold the CAD input back this frame? See [`set_input_blocked`].
pub(crate) fn input_blocked() -> bool {
INPUT_BLOCKED.with(std::cell::Cell::get)
}
/// The host marks the CAD input as blocked, or no longer blocked.
pub(crate) fn set_input_blocked(blocked: bool) {
INPUT_BLOCKED.with(|cell| cell.set(blocked));
}
/// Is `layer` a model-anchored editor or readout ([`Surface::Canvas`])? Such an area sits on the
@@ -620,7 +583,7 @@ impl Container {
tip.show(|ui| {
let body = |ui: &mut egui::Ui| {
ui.label(text);
solidcraft_ui_registry::publish_rect("kit:tooltip", ui.min_rect());
crate::scenario::publish_rect("kit:tooltip", ui.min_rect());
};
match slot {
Some(slot) => slot.body(ui, body),
@@ -644,7 +607,7 @@ impl Container {
tip.show(|ui| {
let body = |ui: &mut egui::Ui| {
add(ui);
solidcraft_ui_registry::publish_rect("kit:tooltip", ui.min_rect());
crate::scenario::publish_rect("kit:tooltip", ui.min_rect());
};
match slot {
Some(slot) => slot.body(ui, body),
@@ -666,7 +629,7 @@ impl Container {
/// Separation between adaptive workspace surfaces; compact surfaces meet their edge.
pub fn gap(self) -> f32 {
if self.floating() { SHELL_GAP } else { 0.0 }
if self.floating() { crate::shell::SHELL_GAP } else { 0.0 }
}
/// Tokenised outer chrome. Callers may supply content padding, not another card surface.
@@ -1122,6 +1085,6 @@ impl Container {
}
fn input_enabled(self) -> bool {
self.surface == Surface::InputShield || !input_blocked()
self.surface == Surface::InputShield || !crate::issue_report::cad_input_blocked()
}
}
+12 -12
View File
@@ -1,6 +1,6 @@
//! Description-driven details content. No tool, document or command knowledge lives here.
use crate::ui_kit::BackingTooltipExt as _;
use crate::{fonts::RichTextExt as _, theme::Tokens};
use crate::{fonts::RichTextExt as _, scenario, theme::Tokens};
use egui::{RichText, Ui};
const FIELD_FOCUS: &str = "details_field_focus";
@@ -201,7 +201,7 @@ fn setting(ui: &mut Ui, field: &DetailSetting, focus: bool) -> Option<DetailEven
}
};
if let Some(response) = response {
solidcraft_ui_registry::publish_target(&format!("details:field:{}", field.id), response.rect);
scenario::publish_target(&format!("details:field:{}", field.id), response.rect);
// Visibility reads Context::will_discard. Never re-enter Context while
// holding its data write lock.
let visible = ui.is_visible() && ui.is_enabled() && ui.is_rect_visible(response.rect);
@@ -235,7 +235,7 @@ fn setting(ui: &mut Ui, field: &DetailSetting, focus: bool) -> Option<DetailEven
&& ![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]].contains(vector)
{
let response = ui.add(egui::Label::new(RichText::new(current).monospace()).wrap()).backing_on_hover_text(current);
solidcraft_ui_registry::publish_target(&format!("details:current:{}", field.id), response.rect);
scenario::publish_target(&format!("details:current:{}", field.id), response.rect);
}
changed.then(|| DetailEvent::Change(field.id.clone(), value))
}
@@ -257,7 +257,7 @@ pub(crate) fn details(ui: &mut Ui, id: &str, description: &Details, max_height:
if let Some(caption) = &description.caption {
let response = ui.add(egui::Label::new(RichText::new(caption).size(t.font_label).color(t.text_dim)).wrap());
caption_height = response.rect.height() + t.space_8;
solidcraft_ui_registry::publish_rect("details:caption", response.rect);
scenario::publish_rect("details:caption", response.rect);
}
let footer_height = if description.actions.is_empty() { 0.0 } else { t.control_height + t.space_16 };
let body = egui::ScrollArea::vertical()
@@ -318,7 +318,7 @@ pub(crate) fn details(ui: &mut Ui, id: &str, description: &Details, max_height:
.push_id(&row.id, |ui| super::icon_button_at(ui, rect, "fit", crate::i18n::tr("ui.details_target")))
.inner;
remember_control(ui, response.id);
solidcraft_ui_registry::publish_target(&format!("details:target:{}", row.id), response.rect);
scenario::publish_target(&format!("details:target:{}", row.id), response.rect);
if response.clicked() {
events.push(DetailEvent::Target(row.id.clone()));
}
@@ -327,7 +327,7 @@ pub(crate) fn details(ui: &mut Ui, id: &str, description: &Details, max_height:
});
}
});
solidcraft_ui_registry::publish_count(
scenario::publish_count(
"details:findings_visible",
if expanded { description.findings.len() } else { description.findings.len().min(6) } as f64,
);
@@ -335,7 +335,7 @@ pub(crate) fn details(ui: &mut Ui, id: &str, description: &Details, max_height:
let text = crate::i18n::format("ui.details_show_all", &[("count", &description.findings.len().to_string())]);
let response = super::button(ui, text, super::Button::Ghost);
remember_control(ui, response.id);
solidcraft_ui_registry::publish_target("details:show_all", response.rect);
scenario::publish_target("details:show_all", response.rect);
if response.clicked() {
ui.data_mut(|data| data.insert_temp(expanded_id, true));
}
@@ -357,7 +357,7 @@ pub(crate) fn details(ui: &mut Ui, id: &str, description: &Details, max_height:
});
}
});
solidcraft_ui_registry::publish_count(
scenario::publish_count(
"details:readout_visible",
if expanded { description.readout.len() } else { description.readout.len().min(6) } as f64,
);
@@ -365,27 +365,27 @@ pub(crate) fn details(ui: &mut Ui, id: &str, description: &Details, max_height:
let text = crate::i18n::format("ui.details_show_all", &[("count", &description.readout.len().to_string())]);
let response = super::button(ui, text, super::Button::Ghost);
remember_control(ui, response.id);
solidcraft_ui_registry::publish_target("details:show_all", response.rect);
scenario::publish_target("details:show_all", response.rect);
if response.clicked() {
ui.data_mut(|data| data.insert_temp(expanded_id, true));
}
}
}
});
solidcraft_ui_registry::publish_rect("details:body", body.inner_rect);
scenario::publish_rect("details:body", body.inner_rect);
if !description.actions.is_empty() {
super::divider(ui, "details_actions");
let actions = ui.horizontal(|ui| {
for (id, label) in description.actions.iter().take(2) {
let response = super::text_button(ui, label, true);
remember_control(ui, response.id);
solidcraft_ui_registry::publish_target(&format!("details:action:{id}"), response.rect);
scenario::publish_target(&format!("details:action:{id}"), response.rect);
if response.clicked() {
events.push(DetailEvent::Action(id.clone()));
}
}
});
solidcraft_ui_registry::publish_rect("details:actions", actions.response.rect);
scenario::publish_rect("details:actions", actions.response.rect);
}
});
events
+69 -109
View File
@@ -4,11 +4,10 @@ use super::{
metrics::*,
text::paint_text,
};
use crate::theme::Tokens;
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{Container, Surface};
use crate::{scenario, theme::Tokens};
use egui::{Response, RichText, Ui, vec2};
use std::sync::Arc;
/// One alignment policy for every kit text input. Align the galley itself rather than
/// approximating font metrics with integer margins (Plex Mono and Sans differ).
@@ -17,48 +16,6 @@ pub(super) fn text_input(edit: egui::TextEdit<'_>) -> egui::TextEdit<'_> {
edit.align(egui::Align2::LEFT_CENTER).margin(vec2(t.form_inset, 0.0)).min_size(vec2(0.0, t.control_height))
}
/// The host's expression language, as the kit's value fields use it. The kit draws and edits the
/// text; it never parses expressions itself. The host installs one per context with
/// [`install_expressions`]. Without one, a field's label drag changes nothing (no text is a
/// literal) and an editor offers no completion or stepping.
pub trait Expressions: Send + Sync {
/// Whether the trimmed `text` is a plain literal: a number with an optional unit ("20 mm",
/// "-1.5 in"), not a parameter or a formula. Only a literal can be changed by a label drag.
fn is_literal(&self, text: &str) -> bool;
/// Whether `unit`, the text after a literal's number as written (" mm", "in", ""), is a unit
/// of the language. An empty unit is a unit-less number.
fn is_unit(&self, unit: &str) -> bool;
/// Offer completion and stepping for this frame under the editor `response` over `text`.
/// Returns true when either changed the text; that frame's Enter then accepts the change
/// instead of submitting.
fn complete(&self, ui: &mut Ui, response: &Response, text: &mut String) -> bool;
}
/// Where a context keeps its installed [`Expressions`].
fn expressions_id() -> egui::Id {
egui::Id::unique("sc_kit_expressions")
}
/// Give every kit value field in `ctx` the host's expression language. Installing again replaces it.
pub fn install_expressions(ctx: &egui::Context, expressions: Arc<dyn Expressions>) {
ctx.data_mut(|data| data.insert_temp(expressions_id(), expressions));
}
/// The expression language installed in `ctx`, if any.
pub(crate) fn expressions(ctx: &egui::Context) -> Option<Arc<dyn Expressions>> {
ctx.data(|data| data.get_temp::<Arc<dyn Expressions>>(expressions_id()))
}
/// A plain number typed into a field, with a decimal point or a decimal comma (#1347): "2.5"
/// and "2,5" are both 2.5, as a numeric keypad types either depending on the locale. Spaces are
/// ignored and "−" is a minus, as in egui's own parser. A comma beside a point ("1.234,5") or a
/// second comma is not guessed at: the text is not a number.
pub(crate) fn parse_number(text: &str) -> Option<f64> {
let t: String = text.chars().filter(|c| !c.is_whitespace()).map(|c| if c == '\u{2212}' { '-' } else { c }).collect();
let t = if !t.contains('.') && t.matches(',').count() == 1 { t.replacen(',', ".", 1) } else { t };
t.parse::<f64>().ok().filter(|v| v.is_finite())
}
/// Expression editor with the application's existing completion and number-focus behaviour.
/// Values carry their own unit text ("20 mm", "45 deg"): there is no unit suffix. Returns accepted Enter.
/// Tokens: `control_height, control_fill, form_inset, font_label, space_4; scope tokens`.
@@ -129,7 +86,7 @@ pub(crate) fn count_field(ui: &mut Ui, value: &mut String, limits: &Count<'_>) -
));
let sign = if delta > 0 { "+1" } else { "−1" };
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{} {sign}", crate::i18n::tr_label(&row))));
solidcraft_ui_registry::publish_handle(&format!("dialog:{row} {name}"), half.center());
scenario::publish_handle(&format!("dialog:{row} {name}"), half.center());
if let (true, true, Some(next)) = (enabled, arrow.clicked(), next) {
*value = next;
}
@@ -183,20 +140,18 @@ struct Literal {
/// The largest value a label drag reads or writes.
const LITERAL_CAP: f64 = 1e12;
/// `text` as a [`Literal`] of the `expressions` language, or None for anything else: a parameter,
/// a formula, an empty field. Dragging never rewrites an expression, so a driving parameter stays driving.
fn literal(expressions: &dyn Expressions, text: &str) -> Option<Literal> {
/// `text` as a [`Literal`], or None for anything else: a parameter, a formula, an empty field.
/// Dragging never rewrites an expression, so a driving parameter stays driving.
fn literal(text: &str) -> Option<Literal> {
let text = text.trim();
if !expressions.is_literal(text) {
if !solidcraft_engine::doc::expr::is_literal(text) {
return None;
}
let sign = usize::from(text.starts_with(['-', '+']));
let end = text.char_indices().skip(sign).find(|(_, c)| !(c.is_ascii_digit() || *c == '.' || *c == ',')).map_or(text.len(), |(at, _)| at);
let (number, unit) = (text.get(..end)?, text.get(end..)?);
// An exponent ("1e3") or anything else that is not a unit is left alone.
if !expressions.is_unit(unit) {
return None;
}
solidcraft_engine::doc::expr::unit_info(unit)?;
let comma = number.contains(',');
let number: f64 = number.replace(',', ".").parse().ok().filter(|n: &f64| n.is_finite() && n.abs() < LITERAL_CAP)?;
Some(Literal { number, unit: unit.to_owned(), comma })
@@ -245,7 +200,7 @@ struct Scrub {
/// holding an expression or a parameter is left alone. Returns whether the text changed.
pub(super) fn scrub(ui: &Ui, label: &Response, value: &mut String) -> bool {
let id = label.id.with("sc_scrub");
let Some(now) = expressions(ui.ctx()).and_then(|e| literal(e.as_ref(), value)).filter(|_| ui.is_enabled()) else {
let Some(now) = literal(value).filter(|_| ui.is_enabled()) else {
ui.ctx().data_mut(|data| data.remove::<Scrub>(id));
return false;
};
@@ -378,7 +333,7 @@ fn searchable_dropdown_with(
menu: SearchMenu<'_>,
) -> Option<String> {
let SearchMenu { custom, pending, icon } = menu;
super::settings_search::note_current(ui, current);
crate::prefs_search::note_current(ui, current);
let mut chosen = None;
let row = label(ui).to_owned();
let combo = egui::ComboBox::from_id_salt(id)
@@ -400,12 +355,12 @@ fn searchable_dropdown_with(
);
// Preferences types into its own search only while no field has the keyboard.
if search.has_focus() {
super::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
solidcraft_ui_registry::publish_handle(&format!("dialog:{row} search"), search.rect.center());
scenario::publish_handle(&format!("dialog:{row} search"), search.rect.center());
if let Some(custom) = custom {
let response = ui.selectable_label(false, custom);
solidcraft_ui_registry::publish_handle(&format!("dialog:{row} file"), response.rect.center());
scenario::publish_handle(&format!("dialog:{row} file"), response.rect.center());
if response.clicked() {
chosen = Some(filter.trim().to_owned());
ui.close();
@@ -422,7 +377,7 @@ fn searchable_dropdown_with(
for option in options.iter().filter(|o| o.label.to_lowercase().contains(&query)) {
let response = ui.selectable_label(current == option.label, option.label);
if let Some(handle) = option.handle {
solidcraft_ui_registry::publish_handle(handle, response.rect.center());
scenario::publish_handle(handle, response.rect.center());
}
if response.clicked() {
chosen = Some(option.value.to_owned());
@@ -498,12 +453,12 @@ pub(crate) enum Picked {
/// list stays cheap. Returns the pick and the control's response.
/// Tokens: dropdown tokens, field tokens, `space_24`, `font_small`, `text_dim`, `form_card_width`.
pub(crate) fn search_picker(ui: &mut Ui, p: SearchPicker<'_>) -> egui::InnerResponse<Option<Picked>> {
super::settings_search::note_current(ui, p.current);
crate::prefs_search::note_current(ui, p.current);
// As in `dropdown_menu_with_close_behavior`: only a search probe skips the real control.
if super::settings_search::querying(ui)
&& (!ui.is_visible() || (super::settings_search::suppress_chrome(ui) && !super::settings_search::texts_hit(ui, &[p.current])))
if crate::prefs_search::querying(ui)
&& (!ui.is_visible() || (crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[p.current])))
{
return egui::InnerResponse::new(None, super::settings_search::hidden_response(ui));
return egui::InnerResponse::new(None, crate::prefs_search::hidden_response(ui));
}
let state = egui::Id::unique(p.id);
let mut picked = None;
@@ -532,7 +487,7 @@ pub(crate) fn search_picker(ui: &mut Ui, p: SearchPicker<'_>) -> egui::InnerResp
})
.inner;
publish(ui, &response, egui::Role::ComboBox);
solidcraft_ui_registry::publish_target(p.handles, response.rect);
scenario::publish_target(p.handles, response.rect);
egui::InnerResponse::new(picked, response)
}
@@ -558,7 +513,7 @@ fn search_picker_popup(ui: &mut Ui, p: &SearchPicker<'_>, state: egui::Id) -> Op
.desired_width(f32::INFINITY),
);
t.input_bevel(ui, &search);
solidcraft_ui_registry::publish_target(&format!("{}:search", p.handles), search.rect);
scenario::publish_target(&format!("{}:search", p.handles), search.rect);
// Asked until it has it: over a modal window egui drops the request of the opening's first frame.
if !ui.data(|data| data.get_temp::<bool>(focused_id)).unwrap_or(false) {
if search.has_focus() {
@@ -569,7 +524,7 @@ fn search_picker_popup(ui: &mut Ui, p: &SearchPicker<'_>, state: egui::Id) -> Op
}
}
if search.has_focus() {
super::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
if search.changed() {
ui.data_mut(|data| data.insert_temp(filter_id, filter.clone()));
@@ -646,7 +601,7 @@ fn search_picker_popup(ui: &mut Ui, p: &SearchPicker<'_>, state: egui::Id) -> Op
search_heading(ui, crate::i18n::tr("ui.agent_chat_custom_heading"), t.space_16);
let text = crate::i18n::format("ui.agent_chat_model_custom", &[("model", &compact(model, SEARCH_LABEL_CAP))]);
let row = search_row(ui, &text, false);
solidcraft_ui_registry::publish_target(&format!("{}:custom", p.handles), row.rect);
scenario::publish_target(&format!("{}:custom", p.handles), row.rect);
if row.clicked() {
picked = Some(Picked::Custom(model.to_owned()));
}
@@ -666,14 +621,14 @@ fn search_picker_popup(ui: &mut Ui, p: &SearchPicker<'_>, state: egui::Id) -> Op
SearchRow::Heading(group) => search_heading(ui, group, row_height),
SearchRow::Group { name, count, open } => {
let response = search_group_row(ui, name, *count, *open);
solidcraft_ui_registry::publish_target(&format!("{}:group:{}", p.handles, name), response.rect);
scenario::publish_target(&format!("{}:group:{}", p.handles, name), response.rect);
if response.clicked() {
toggled = Some(((*name).to_owned(), !*open));
}
}
SearchRow::Choice(choice) => {
let response = search_row(ui, &compact(choice.label, SEARCH_LABEL_CAP), choice.value == p.current_value);
solidcraft_ui_registry::publish_target(&format!("{}:{}", p.handles, choice.value), response.rect);
scenario::publish_target(&format!("{}:{}", p.handles, choice.value), response.rect);
let response = if choice.tip.is_empty() {
response
} else {
@@ -859,7 +814,7 @@ fn editor_reserving(ui: &mut Ui, value: &mut String, hint: &str, completion: boo
}
super::text_menu::menu(ui, &mut response, value, super::text_menu::Field::LINE);
if response.has_focus() {
super::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
if completion && ui.is_enabled() {
ui.ctx().data_mut(|data| {
@@ -879,7 +834,7 @@ fn editor_reserving(ui: &mut Ui, value: &mut String, hint: &str, completion: boo
});
super::layout::publish_geometry(ui, &response);
// A completion's Enter accepts the suggestion, rather than submitting the dialog.
let completed = completion && expressions(ui.ctx()).is_some_and(|e| e.complete(ui, &response, value));
let completed = completion && crate::params_dialog::complete(ui, &response, value);
(!completed && response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)), response.changed(), response)
}
@@ -895,7 +850,7 @@ pub(crate) fn dropdown(ui: &mut Ui, id: &str, selected: &mut usize, options: &[&
dropdown_menu(ui, id, text, |ui| {
for (index, option) in options.iter().enumerate() {
let response = ui.selectable_value(selected, index, crate::i18n::tr_label(option));
solidcraft_ui_registry::publish_target(&format!("dialog:{row}:{option}"), response.rect);
scenario::publish_target(&format!("dialog:{row}:{option}"), response.rect);
}
})
}
@@ -909,7 +864,7 @@ pub(crate) fn dropdown_icons(ui: &mut Ui, id: &str, selected: &mut usize, option
for (index, (text, icon)) in options.iter().enumerate() {
let response = ui.selectable_label(*selected == index, dropdown_icon_text(text));
paint_dropdown_icon(ui, response.rect, icon);
solidcraft_ui_registry::publish_target(&format!("dialog:{row}:{text}"), response.rect);
scenario::publish_target(&format!("dialog:{row}:{text}"), response.rect);
if response.clicked() {
*selected = index;
}
@@ -935,7 +890,7 @@ fn paint_dropdown_icon(ui: &Ui, rect: egui::Rect, icon: &str) {
vec2(t.space_16, t.space_16),
);
crate::icons::paint_ink(ui.painter(), rect, icon, t.form_ink);
solidcraft_ui_registry::publish_rect(&format!("dialog:dropdown_icon:{icon}"), rect);
scenario::publish_rect(&format!("dialog:dropdown_icon:{icon}"), rect);
}
/// [`dropdown`] whose options each show a picture beside them while hovered, painted by `preview`
@@ -947,10 +902,10 @@ pub(crate) fn dropdown_previews(ui: &mut Ui, id: &str, selected: &mut usize, opt
dropdown_menu(ui, id, text, |ui| {
for (index, option) in options.iter().enumerate() {
let response = ui.selectable_value(selected, index, crate::i18n::tr_label(option));
solidcraft_ui_registry::publish_target(&format!("dialog:{row}:{option}"), response.rect);
scenario::publish_target(&format!("dialog:{row}:{option}"), response.rect);
Container::tooltip_ui(&response, |ui| {
preview(ui, index);
solidcraft_ui_registry::publish_rect(&format!("dialog:preview:{row}:{option}"), ui.min_rect());
scenario::publish_rect(&format!("dialog:preview:{row}:{option}"), ui.min_rect());
});
}
})
@@ -966,7 +921,7 @@ pub(crate) fn dropdown_picked(ui: &mut Ui, id: &str, selected: &mut usize, optio
for (index, option) in options.iter().enumerate() {
let response = ui.selectable_value(selected, index, crate::i18n::tr_label(option));
picked |= response.clicked();
solidcraft_ui_registry::publish_handle(&format!("dialog:{row}:{option}"), response.rect.center());
scenario::publish_handle(&format!("dialog:{row}:{option}"), response.rect.center());
}
});
picked
@@ -981,11 +936,11 @@ pub(crate) fn dropdown_menu(ui: &mut Ui, id: impl egui::AsIdSalt, current: &str,
/// A width-bounded toolbar picker. Long selected values and popup rows truncate;
/// callers can retain their complete labels in hover text.
pub(crate) fn bounded_dropdown_menu(ui: &mut Ui, id: impl egui::AsIdSalt, current: &str, width: f32, add: impl FnOnce(&mut Ui)) -> Response {
super::settings_search::note_current(ui, current);
if super::settings_search::querying(ui)
&& (!ui.is_visible() || (super::settings_search::suppress_chrome(ui) && !super::settings_search::texts_hit(ui, &[current])))
crate::prefs_search::note_current(ui, current);
if crate::prefs_search::querying(ui)
&& (!ui.is_visible() || (crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[current])))
{
return super::settings_search::hidden_response(ui);
return crate::prefs_search::hidden_response(ui);
}
let width = dimension(width);
let response = ui
@@ -1050,13 +1005,13 @@ fn dropdown_menu_width(
close_behavior: egui::PopupCloseBehavior,
add: impl FnOnce(&mut Ui),
) -> Response {
super::settings_search::note_current(ui, current);
crate::prefs_search::note_current(ui, current);
// Only a search probe may skip the real control. A dialog's first frame is not visible yet,
// and a stand-in there makes the next frame too short to show OK.
if super::settings_search::querying(ui)
&& (!ui.is_visible() || (super::settings_search::suppress_chrome(ui) && !super::settings_search::texts_hit(ui, &[current])))
if crate::prefs_search::querying(ui)
&& (!ui.is_visible() || (crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[current])))
{
return super::settings_search::hidden_response(ui);
return crate::prefs_search::hidden_response(ui);
}
let t = Tokens::get();
let width = if compact {
@@ -1151,9 +1106,9 @@ pub(crate) fn selection_field(ui: &mut Ui, text: &str, options: Selection) -> Re
false,
);
publish(ui, &response, egui::Role::Button);
solidcraft_ui_registry::publish_rect(&format!("dialog:field:{}", label(ui)), rect);
solidcraft_ui_registry::publish_count(&format!("dialog:selection:{}:filled", label(ui)), f64::from(filled));
solidcraft_ui_registry::publish_count(&format!("dialog:selection:{}:active", label(ui)), f64::from(active));
scenario::publish_rect(&format!("dialog:field:{}", label(ui)), rect);
scenario::publish_count(&format!("dialog:selection:{}:filled", label(ui)), f64::from(filled));
scenario::publish_count(&format!("dialog:selection:{}:active", label(ui)), f64::from(active));
response
}
@@ -1203,8 +1158,8 @@ pub(crate) fn number_pair<T: egui::emath::Numeric>(
/// Retains the established 0.01 step, fractional range and locale parser.
/// Locale-aware variable-radius position editor. Tokens: `scope control/font tokens`.
pub(crate) fn fraction_field(ui: &mut Ui, value: &mut f64) -> Response {
let response = ui.add(egui::DragValue::new(value).speed(0.01).range(0.01..=0.99).fixed_decimals(2).custom_parser(parse_number));
solidcraft_ui_registry::publish_target(&format!("dialog:fraction:{}", label(ui)), response.rect);
let response = ui.add(egui::DragValue::new(value).speed(0.01).range(0.01..=0.99).fixed_decimals(2).custom_parser(crate::prefs::parse_number));
scenario::publish_target(&format!("dialog:fraction:{}", label(ui)), response.rect);
response
}
@@ -1376,7 +1331,7 @@ pub(crate) fn search_field(ui: &mut Ui, id: egui::Id, value: &mut String, hint:
info
});
if response.has_focus() {
super::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
crate::theme::TabAccent::number_focus(ui, &response);
t.input_bevel(ui, &response);
@@ -1469,8 +1424,8 @@ impl Default for Slider {
/// Native input, keyboard routing, enabled state and accessibility; one token-painted track/thumb.
/// Tokens: control_height, space_24/8/4/2, dialog_rule, control_fill, form_primary.
pub(crate) fn slider<T: egui::emath::Numeric>(ui: &mut Ui, value: &mut T, range: std::ops::RangeInclusive<T>, options: Slider) -> Response {
if super::settings_search::suppress_chrome(ui) {
return super::settings_search::hidden_response(ui);
if crate::prefs_search::suppress_chrome(ui) {
return crate::prefs_search::hidden_response(ui);
}
// Own form styling even in callers such as the viewport menu which have no kit scope.
super::scope(ui, |ui| {
@@ -1558,7 +1513,7 @@ pub(crate) fn slider<T: egui::emath::Numeric>(ui: &mut Ui, value: &mut T, range:
ui.ctx().read_response(id).is_some_and(|focused| response.rect.contains(focused.rect.center()))
|| egui::TextEdit::load_state(ui.ctx(), id).is_some()
}) {
super::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
// Match egui's native input range (handle_radius = thickness / 2.5), so the
// painted thumb stays under the pointer even at the two ends of the range.
@@ -1579,8 +1534,8 @@ pub(crate) fn slider<T: egui::emath::Numeric>(ui: &mut Ui, value: &mut T, range:
ui.painter().circle(center, t.space_8, fill, egui::Stroke::new(t.space_2, if off { t.form_dim } else { primary }));
publish(ui, &response, egui::Role::Slider);
response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), value.to_f64(), crate::i18n::tr_label(label(ui))));
solidcraft_ui_registry::publish_rect(&format!("dialog:slider_track:{}", label(ui)), slider);
solidcraft_ui_registry::publish_target(&format!("dialog:slider:{}", label(ui)), response.rect);
scenario::publish_rect(&format!("dialog:slider_track:{}", label(ui)), slider);
scenario::publish_target(&format!("dialog:slider:{}", label(ui)), response.rect);
response
})
}
@@ -1626,7 +1581,7 @@ fn scalar_field_impl(ui: &mut Ui, value: &mut f64, range: std::ops::RangeInclusi
let before = *value;
let parsed = std::cell::Cell::new(false);
let field = egui::DragValue::new(value).range(range).speed(0.01).custom_parser(|text| {
let value = parse_number(text);
let value = crate::prefs::parse_number(text);
parsed.set(value.is_some());
value
});
@@ -1647,7 +1602,7 @@ fn scalar_field_impl(ui: &mut Ui, value: &mut f64, range: std::ops::RangeInclusi
if !plain {
Tokens::get().input_bevel(ui, &response);
}
solidcraft_ui_registry::publish_target(&format!("dialog:scalar:{}", label(ui)), response.rect);
scenario::publish_target(&format!("dialog:scalar:{}", label(ui)), response.rect);
response
}
@@ -1656,7 +1611,7 @@ fn scalar_field_impl(ui: &mut Ui, value: &mut f64, range: std::ops::RangeInclusi
pub(crate) fn length_field(ui: &mut Ui, value: &mut f64, units: &str, decimals: u8) -> Response {
let response = ui.add(length_drag(value, units, decimals));
Tokens::get().input_bevel(ui, &response);
solidcraft_ui_registry::publish_target(&format!("dialog:length:{}", label(ui)), response.rect);
scenario::publish_target(&format!("dialog:length:{}", label(ui)), response.rect);
response
}
@@ -1670,7 +1625,7 @@ pub(crate) fn bounded_length_field(
speed: f64,
) -> Response {
let response = ui.add(length_drag(value, units, decimals).range(range).speed(speed));
solidcraft_ui_registry::publish_target(&format!("dialog:length:{}", label(ui)), response.rect);
scenario::publish_target(&format!("dialog:length:{}", label(ui)), response.rect);
response
}
@@ -1685,13 +1640,13 @@ pub(crate) fn angle_field(ui: &mut Ui, value: &mut f64) -> Response {
if let Some(last) = ui.data(|d| d.get_temp::<f64>(set)).filter(|last| (last - shown).abs() <= 1e-9 * last.abs().max(1.0)) {
shown = last;
}
let response = ui.add(egui::DragValue::new(&mut shown).speed(1.0).suffix(" deg").custom_parser(parse_number));
let response = ui.add(egui::DragValue::new(&mut shown).speed(1.0).suffix(" deg").custom_parser(crate::prefs::parse_number));
if response.changed() {
*value = shown;
ui.data_mut(|d| d.insert_temp(set, shown));
}
Tokens::get().input_bevel(ui, &response);
solidcraft_ui_registry::publish_target(&format!("dialog:angle:{}", label(ui)), response.rect);
scenario::publish_target(&format!("dialog:angle:{}", label(ui)), response.rect);
response
}
@@ -1770,10 +1725,15 @@ fn bounded_number_field<T: egui::emath::Numeric>(
clamp_existing: bool,
) -> Response {
let response = ui.add(
egui::DragValue::new(value).range(range).clamp_existing_to_range(clamp_existing).speed(speed).suffix(suffix).custom_parser(parse_number),
egui::DragValue::new(value)
.range(range)
.clamp_existing_to_range(clamp_existing)
.speed(speed)
.suffix(suffix)
.custom_parser(crate::prefs::parse_number),
);
if response.has_focus() {
super::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
Tokens::get().input_bevel(ui, &response);
response.widget_info(|| {
@@ -1798,7 +1758,7 @@ pub(crate) fn vector3_field(ui: &mut Ui, values: &mut [f64; 3], range: std::ops:
ui.spacing_mut().interact_size.x = width;
ui.set_clip_rect(ui.clip_rect().intersect(ui.max_rect()));
let response = number_field(ui, value, range.clone(), 0.01, suffix);
solidcraft_ui_registry::publish_target(&format!("dialog:vector:{label}:{axis}"), response.rect);
scenario::publish_target(&format!("dialog:vector:{label}:{axis}"), response.rect);
changed |= response.changed();
}
});
@@ -1844,7 +1804,7 @@ pub(crate) fn track_number(ui: &mut Ui, value: &mut f64, speed: f64, decimals: u
response
}
/// A value typed as a sum of numbers (`parse_number` each), added to `now` when it
/// A value typed as a sum of numbers (`crate::prefs::parse_number` each), added to `now` when it
/// starts with a sign: "+30" moves the value by 30, "0-5" is −5.
fn relative_number(text: &str, now: f64) -> Option<f64> {
let text: String = text.chars().filter(|c| !c.is_whitespace()).map(|c| if c == '\u{2212}' { '-' } else { c }).collect();
@@ -1853,13 +1813,13 @@ fn relative_number(text: &str, now: f64) -> Option<f64> {
let mut prev = ' ';
for c in text.chars() {
if (c == '+' || c == '-') && !term.is_empty() && !matches!(prev, 'e' | 'E') {
total += parse_number(&term)?;
total += crate::prefs::parse_number(&term)?;
term.clear();
}
term.push(c);
prev = c;
}
total += parse_number(&term)?;
total += crate::prefs::parse_number(&term)?;
total.is_finite().then_some(total)
}
@@ -1971,8 +1931,8 @@ pub(crate) fn library_cell(ui: &mut Ui, id: egui::Id, value: &mut String, hint:
let button = egui::Rect::from_min_max(egui::pos2(split, rect.top()), rect.max);
t.secondary_vline(ui.painter(), split, rect.y_range(), t.dialog_rule);
let library = super::icon_button_at(ui, button, "chevron", "Library");
solidcraft_ui_registry::publish_handle("dialog:menu:Library", button.center());
solidcraft_ui_registry::publish_rect("dialog:menu:Library", button);
scenario::publish_handle("dialog:menu:Library", button.center());
scenario::publish_rect("dialog:menu:Library", button);
let popup = Container::new(ui.ctx(), Surface::Popup).menu(&library);
let open = popup.is_open();
popup.show(|ui| super::scope(ui, menu));
+2 -3
View File
@@ -1419,7 +1419,7 @@ fn a_track_number_adds_up_and_a_sign_adds_to_the_value_now() {
#[test]
fn a_label_drag_reads_only_literal_values_and_moves_them_on_the_step_grid() {
let literal = |text: &str| super::literal(&crate::params_dialog::DocExpressions, text).map(|l| (l.number, l.unit, l.comma));
let literal = |text: &str| super::literal(text).map(|l| (l.number, l.unit, l.comma));
assert_eq!(literal("20 mm"), Some((20.0, " mm".into(), false)));
assert_eq!(literal(" 1,5in "), Some((1.5, "in".into(), true)));
assert_eq!(literal("-3"), Some((-3.0, String::new(), false)));
@@ -1435,7 +1435,7 @@ fn a_label_drag_reads_only_literal_values_and_moves_them_on_the_step_grid() {
assert_eq!(super::scrubbed(2.0, -500.0, 0.1), 0.0, "a size stops at zero");
assert!((super::scrubbed(-2.0, -10.0, 0.1) + 3.0).abs() < 1e-9, "a negative offset keeps moving");
assert_eq!(super::scrubbed(1.0, f32::MAX, 1e8), 1.0, "an overflowing drag keeps the value");
let text = |n, step, typed: &str| super::scrub_text(n, step, &super::literal(&crate::params_dialog::DocExpressions, typed).unwrap());
let text = |n, step, typed: &str| super::scrub_text(n, step, &super::literal(typed).unwrap());
assert_eq!(text(23.0, 0.1, "20 mm"), "23.0 mm");
assert_eq!(text(1.75, 0.01, "1,5 in"), "1,75 in");
assert_eq!(text(-0.0001, 0.01, "-1"), "0.00");
@@ -1473,7 +1473,6 @@ fn dragging_a_value_rows_label_changes_its_literal_and_leaves_expressions_alone(
frame(ctx, vec![], modifiers, values);
}
let ctx = crate::fonts::test_context();
crate::params_dialog::install_expressions(&ctx);
let mut values = ["20 mm".to_owned(), "depth + 2 mm".to_owned()];
frame(&ctx, vec![], Default::default(), &mut values);
drag(&ctx, "Distance", 40.0, Default::default(), &mut values);
+2 -2
View File
@@ -1,5 +1,5 @@
//! Shared aspect choices and camera composition guides; no document or command authority.
use crate::theme::Tokens;
use crate::{scenario, theme::Tokens};
use egui::{Rect, Stroke, Ui, pos2, vec2};
pub(crate) struct AspectTile<'a> {
@@ -48,7 +48,7 @@ pub(crate) fn aspect_grid(ui: &mut Ui, tiles: &[AspectTile<'_>]) -> Option<usize
ink,
);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), tile.selected, tile.label));
solidcraft_ui_registry::publish_target(tile.id, rect);
scenario::publish_target(tile.id, rect);
if response.clicked() {
clicked = Some(row * 3 + column);
}
+68 -68
View File
@@ -1,8 +1,8 @@
//! Table-driven layout/paint references in both themes. No window or GPU required.
use super::preferences_figures::{ThemeSample, paint_theme_thumbnail};
use super::*;
use crate::theme::Tokens;
use crate::ui_kit::{Container, Surface};
use crate::{scenario, theme::Tokens};
use egui::{Ui, vec2};
use solidcraft_engine::kernel::Continuity;
@@ -29,7 +29,7 @@ macro_rules! element {
};
}
fn navigation_sample(ui: &mut Ui, size: NavigationSize) {
fn navigation_sample(ui: &mut Ui, size: crate::navigation_bar::Size) {
let metrics = NavigationMetrics::for_size(size);
let inset = NAV_PADDING + NAV_BORDER;
let menu_width = metrics.icon + NAV_MENU_LEFT + NAV_MENU_RIGHT + NAV_MENU_GAP + NAV_CHEVRON;
@@ -38,12 +38,16 @@ fn navigation_sample(ui: &mut Ui, size: NavigationSize) {
navigation_surface(ui, rect, size, true);
let mut x = rect.left() + inset;
for (id, icon, kind, selected, width) in [
("pan", "pan", NavigationKind::Tool, true, metrics.button),
("fit", "fit", NavigationKind::Action, false, metrics.button),
("visual_style", "style_shaded_edges", NavigationKind::Menu, false, menu_width),
("pan", "pan", crate::navigation_bar::Kind::Tool, true, metrics.button),
("fit", "fit", crate::navigation_bar::Kind::Action, false, metrics.button),
("visual_style", "style_shaded_edges", crate::navigation_bar::Kind::Menu, false, menu_width),
] {
let bounds = egui::Rect::from_min_size(egui::pos2(x, rect.top() + inset), vec2(width, metrics.button));
navigation_button(ui, bounds, NavigationButton { id, icon, label: id, kind, selected, labels: false, menu: kind == NavigationKind::Menu });
navigation_button(
ui,
bounds,
NavigationButton { id, icon, label: id, kind, selected, labels: false, menu: kind == crate::navigation_bar::Kind::Menu },
);
if id == "fit" {
navigation_edit_outline(ui, bounds, true, false);
}
@@ -216,8 +220,8 @@ const ELEMENTS: &[Element] = &[
element!("look_chain_empty", |ui, _| {
look_chain(ui, "gallery:empty-chain", ["Face", "Body", "Component", "Material"], [None; 4], LookStep::Face);
}),
element!("navigation_regular", |ui, _| navigation_sample(ui, NavigationSize::Regular)),
element!("navigation_compact", |ui, _| navigation_sample(ui, NavigationSize::Compact)),
element!("navigation_regular", |ui, _| navigation_sample(ui, crate::navigation_bar::Size::Regular)),
element!("navigation_compact", |ui, _| navigation_sample(ui, crate::navigation_bar::Size::Compact)),
element!("navigation_popup", |ui, _| {
navigation_menu(ui, NAV_POPUP_WIDTH, |ui| {
navigation_menu_heading(ui, "Visual style");
@@ -508,7 +512,7 @@ const ELEMENTS: &[Element] = &[
element!("catalog_footer_height", |ui, _| {
let expected = catalog_footer_height(ui);
catalog_footer(ui, "Click to apply to the selection.");
assert_eq!(solidcraft_ui_registry::rect_of("catalog:footer").unwrap().height(), expected);
assert_eq!(crate::scenario::rect_of("catalog:footer").unwrap().height(), expected);
}),
element!("catalog_footer_with_tip", |ui, _| {
catalog_footer_with_tip(ui, "Click to apply to the selection.", "Paint and drag assignment are not supported yet.");
@@ -1212,7 +1216,7 @@ const ELEMENTS: &[Element] = &[
let (r, _) = ui.allocate_exact_size(vec2(360.0, 360.0), egui::Sense::hover());
let mut app = crate::test_support::sample_app();
app.ui.tab = "SURFACE".into();
app.publish_tab_accent(ui.ctx());
crate::theme::TabAccent::publish(&app, ui.ctx());
radial_menu(ui, r.center(), &crate::context_menu::radial_items(&app), Some(3));
}),
element!("workspace_menu", |ui, _| {
@@ -1918,7 +1922,7 @@ const ELEMENTS: &[Element] = &[
let (rect, response) = ui.allocate_exact_size(vec2(t.form_button_height * 3.0, t.form_button_height * 2.0), egui::Sense::click());
ui.painter().add(persistent_button_shape(ui, &response, rect));
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, "Selected", crate::fonts::semibold(t.font_label), t.selected_ink);
solidcraft_ui_registry::publish_target("gallery:persistent_button", rect);
scenario::publish_target("gallery:persistent_button", rect);
}),
element!("native_button_focus", "gallery:native_button_focus", Some("gallery:native_button_focus"), |ui, _| {
let t = Tokens::get();
@@ -1926,7 +1930,7 @@ const ELEMENTS: &[Element] = &[
response.request_focus();
ui.painter().rect_filled(rect, t.radius_sm, t.control_fill);
ui.painter().add(native_button_focus(ui, &response, rect));
solidcraft_ui_registry::publish_target("gallery:native_button_focus", rect);
scenario::publish_target("gallery:native_button_focus", rect);
}),
element!("button", "dialog:row:Gallery", Some("dialog:button:Gallery:Add"), |ui, _| {
row(ui, "Gallery", |ui| {
@@ -2090,7 +2094,7 @@ fn example(ui: &mut Ui, element: &Element) -> (&'static str, Option<&'static str
// actual shadow bounds as part of the gallery sample, keeping the paint guard strict.
ui.expand_to_include_rect(rect + Menu::frame().shadow.margin());
}
solidcraft_ui_registry::publish_rect("gallery:allocation", rect);
scenario::publish_rect("gallery:allocation", rect);
(element.rect, element.handle)
}
@@ -2105,11 +2109,11 @@ fn every_element_has_geometry_and_stays_in_its_allocation_in_both_themes() {
ctx.set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() });
for pass in 0..3 {
let events = if element.name.starts_with("checkbox_hovered") && pass > 0 {
vec![egui::Event::PointerMoved(solidcraft_ui_registry::rect_of("gallery:allocation").expect("hover allocation").center())]
vec![egui::Event::PointerMoved(scenario::rect_of("gallery:allocation").expect("hover allocation").center())]
} else {
vec![]
};
solidcraft_ui_registry::clear_handles();
scenario::clear_handles();
let mut allocation = egui::Rect::NOTHING;
let mut published = ("", None);
let mut output = ctx.run_ui(
@@ -2131,12 +2135,12 @@ fn every_element_has_geometry_and_stays_in_its_allocation_in_both_themes() {
},
);
output.textures_delta.clear();
let rect = solidcraft_ui_registry::rect_of(published.0).unwrap_or_else(|| panic!("{name}: missing {}", published.0));
let rect = scenario::rect_of(published.0).unwrap_or_else(|| panic!("{name}: missing {}", published.0));
assert!(rect.is_finite() && rect.is_positive(), "{name}: {rect:?}");
assert!(allocation.expand(1.0).contains_rect(rect), "{name}: {rect:?} outside {allocation:?}");
if let Some(handle) = published.1 {
assert!(
rect.contains(solidcraft_ui_registry::handle_at(handle).unwrap_or_else(|| panic!("{name}: missing {handle}"))),
rect.contains(scenario::handle_at(handle).unwrap_or_else(|| panic!("{name}: missing {handle}"))),
"{name}: handle outside {rect:?}"
);
}
@@ -2176,27 +2180,26 @@ fn visual_style_dropdown_specimen_selects_real_options_and_paints_their_icons()
super::test_support::frame(&ctx, vec![], &mut draw);
}
for (index, (_, label)) in crate::navigation_bar::VISUAL_STYLES.into_iter().enumerate() {
let trigger = solidcraft_ui_registry::handle_at("dialog:Visual style").unwrap();
let trigger = scenario::handle_at("dialog:Visual style").unwrap();
super::test_support::frame(&ctx, super::test_support::pointer(trigger, true), &mut draw);
super::test_support::frame(&ctx, super::test_support::pointer(trigger, false), &mut draw);
super::test_support::frame(&ctx, vec![], &mut draw);
for (option, (_, name)) in crate::navigation_bar::VISUAL_STYLES.into_iter().enumerate() {
let rect = solidcraft_ui_registry::rect_of(&format!("dialog:Visual style:{name}")).unwrap();
let rect = scenario::rect_of(&format!("dialog:Visual style:{name}")).unwrap();
let icon = crate::navigation_bar::visual_style_icon(option as u8);
let painted = solidcraft_ui_registry::rect_of(&format!("dialog:dropdown_icon:{icon}")).unwrap();
let owner =
if option == sample.borrow().selected { solidcraft_ui_registry::rect_of("dialog:widget:Visual style").unwrap() } else { rect };
let painted = scenario::rect_of(&format!("dialog:dropdown_icon:{icon}")).unwrap();
let owner = if option == sample.borrow().selected { scenario::rect_of("dialog:widget:Visual style").unwrap() } else { rect };
assert!(owner.contains_rect(painted), "{name}: its real icon is in its option or selected trigger");
}
let option = solidcraft_ui_registry::handle_at(&format!("dialog:Visual style:{label}")).unwrap();
let option = scenario::handle_at(&format!("dialog:Visual style:{label}")).unwrap();
super::test_support::frame(&ctx, super::test_support::pointer(option, true), &mut draw);
super::test_support::frame(&ctx, super::test_support::pointer(option, false), &mut draw);
super::test_support::frame(&ctx, vec![], &mut draw);
assert_eq!(sample.borrow().selected, index, "the specimen edits the real selection index");
let icon = crate::navigation_bar::visual_style_icon(index as u8);
let trigger = solidcraft_ui_registry::rect_of("dialog:widget:Visual style").unwrap();
assert!(trigger.contains_rect(solidcraft_ui_registry::rect_of(&format!("dialog:dropdown_icon:{icon}")).unwrap()));
assert!(solidcraft_ui_registry::handle_at("dialog:Visual style:Wireframe").is_none(), "the native popup closes after selection");
let trigger = scenario::rect_of("dialog:widget:Visual style").unwrap();
assert!(trigger.contains_rect(scenario::rect_of(&format!("dialog:dropdown_icon:{icon}")).unwrap()));
assert!(scenario::handle_at("dialog:Visual style:Wireframe").is_none(), "the native popup closes after selection");
}
}
crate::theme::set_dark(previous);
@@ -2211,7 +2214,7 @@ fn apply_does_not_submit_and_obeys_validation() {
action = footer(ui, "OK", "Cancel", enabled, Footer { apply: Some("Apply"), ..Default::default() });
};
super::test_support::frame(&ctx, vec![], &mut draw);
let at = solidcraft_ui_registry::handle_at("dialog:apply").unwrap();
let at = scenario::handle_at("dialog:apply").unwrap();
super::test_support::frame(&ctx, super::test_support::pointer(at, true), &mut draw);
super::test_support::frame(&ctx, super::test_support::pointer(at, false), &mut draw);
assert_eq!(action, if enabled { Action::Apply } else { Action::None });
@@ -2226,7 +2229,7 @@ fn picked_item_remove_and_paired_values_have_independent_targets() {
removed = list_row(ui, "point-1", "Point 1").clicked();
};
super::test_support::frame(&ctx, vec![], &mut draw);
let at = solidcraft_ui_registry::handle_at("dialog:list:point-1:remove").unwrap();
let at = scenario::handle_at("dialog:list:point-1:remove").unwrap();
super::test_support::frame(&ctx, super::test_support::pointer(at, true), &mut draw);
super::test_support::frame(&ctx, super::test_support::pointer(at, false), &mut draw);
assert!(removed);
@@ -2237,9 +2240,9 @@ fn picked_item_remove_and_paired_values_have_independent_targets() {
value_pair(ui, "Position", [&mut x, &mut y], ["X", "Y"], None);
};
super::test_support::frame(&ctx, vec![], &mut draw);
let row = solidcraft_ui_registry::rect_of("dialog:row:Position").unwrap();
let first = solidcraft_ui_registry::handle_at("dialog:X").unwrap();
let second = solidcraft_ui_registry::handle_at("dialog:Y").unwrap();
let row = scenario::rect_of("dialog:row:Position").unwrap();
let first = scenario::handle_at("dialog:X").unwrap();
let second = scenario::handle_at("dialog:Y").unwrap();
assert_eq!(row.height(), Tokens::get().control_height);
assert!(row.contains(first) && row.contains(second) && first.x < second.x);
super::test_support::frame(&ctx, super::test_support::pointer(second, true), &mut draw);
@@ -2266,12 +2269,12 @@ fn gallery_rows_share_a_label_column_after_measurement_in_both_themes() {
});
});
}
let first = solidcraft_ui_registry::rect_of("dialog:control:Edges").unwrap();
let second = solidcraft_ui_registry::rect_of("dialog:control:Counterbore depth").unwrap();
let first = scenario::rect_of("dialog:control:Edges").unwrap();
let second = scenario::rect_of("dialog:control:Counterbore depth").unwrap();
assert_eq!(first.left(), second.left());
assert_eq!(first.height(), Tokens::get().control_height);
assert_eq!(second.height(), Tokens::get().control_height);
let width = solidcraft_ui_registry::rect_of("dialog:label:Edges").unwrap().width();
let width = scenario::rect_of("dialog:label:Edges").unwrap().width();
assert!(width >= Tokens::get().form_label_width && width <= Tokens::get().form_label_width * 1.6);
}
crate::theme::set_dark(previous);
@@ -2292,14 +2295,14 @@ fn compound_row_actions_preserve_the_original_field_handle() {
field(ui, &mut value);
},
);
expected = solidcraft_ui_registry::handle_at("dialog:Mid Radius");
expected = scenario::handle_at("dialog:Mid Radius");
button(ui, "×", Button::Small);
});
});
});
assert_eq!(solidcraft_ui_registry::handle_at("dialog:Mid Radius"), expected);
assert_eq!(scenario::handle_at("dialog:Mid Radius"), expected);
assert!(expected.is_some());
assert_ne!(solidcraft_ui_registry::handle_at("dialog:button:Mid Radius:×"), expected);
assert_ne!(scenario::handle_at("dialog:button:Mid Radius:×"), expected);
}
#[test]
@@ -2323,7 +2326,7 @@ fn emphasis_and_compact_modes_keep_independent_actions_in_both_themes() {
};
super::test_support::frame(&ctx, vec![], &mut draw);
for handle in ["dialog:Bold", "dialog:Italic", "dialog:Alignment:Long translated centre"] {
let at = solidcraft_ui_registry::handle_at(handle).unwrap();
let at = scenario::handle_at(handle).unwrap();
super::test_support::frame(&ctx, super::test_support::pointer(at, true), &mut draw);
super::test_support::frame(&ctx, super::test_support::pointer(at, false), &mut draw);
}
@@ -2356,8 +2359,8 @@ fn table_cells_keep_alignment_fixed_rows_editing_and_action_targets() {
});
row.cell(|ui| {
let response = cell_editor(ui, ui.scope_id().with("expression"), value, "Expression", false);
solidcraft_ui_registry::publish_handle(&format!("gallery:cell:{name}"), response.rect.center());
solidcraft_ui_registry::publish_rect(&format!("gallery:cell:{name}"), response.rect);
scenario::publish_handle(&format!("gallery:cell:{name}"), response.rect.center());
scenario::publish_rect(&format!("gallery:cell:{name}"), response.rect);
});
row.cell(|ui| {
if button(ui, "Delete", Button::Small).clicked() {
@@ -2373,18 +2376,18 @@ fn table_cells_keep_alignment_fixed_rows_editing_and_action_targets() {
for _ in 0..3 {
super::test_support::frame(&ctx, vec![], &mut draw);
}
let first_rect = solidcraft_ui_registry::rect_of("gallery:cell:Width").unwrap();
let second_rect = solidcraft_ui_registry::rect_of("gallery:cell:Height").unwrap();
let header = solidcraft_ui_registry::rect_of("dialog:column:cells:Expression").unwrap();
let first_rect = scenario::rect_of("gallery:cell:Width").unwrap();
let second_rect = scenario::rect_of("gallery:cell:Height").unwrap();
let header = scenario::rect_of("dialog:column:cells:Expression").unwrap();
assert_eq!(first_rect.left(), header.left());
assert_eq!(second_rect.left(), header.left());
assert_eq!(first_rect.height(), Tokens::get().control_height);
assert_eq!(second_rect.height(), Tokens::get().control_height);
let at = solidcraft_ui_registry::handle_at("gallery:cell:Height").unwrap();
let at = scenario::handle_at("gallery:cell:Height").unwrap();
super::test_support::frame(&ctx, super::test_support::pointer(at, true), &mut draw);
super::test_support::frame(&ctx, super::test_support::pointer(at, false), &mut draw);
super::test_support::frame(&ctx, vec![egui::Event::Text("x".into())], &mut draw);
let delete = solidcraft_ui_registry::handle_at("dialog:button:Actions:Delete").unwrap();
let delete = scenario::handle_at("dialog:button:Actions:Delete").unwrap();
super::test_support::frame(&ctx, super::test_support::pointer(delete, true), &mut draw);
super::test_support::frame(&ctx, super::test_support::pointer(delete, false), &mut draw);
assert_eq!(first, "10 mm");
@@ -2415,7 +2418,7 @@ fn popup_suggestions_and_selectable_lists_keep_click_selection() {
for _ in 0..3 {
super::test_support::frame(&ctx, vec![], &mut draw);
}
let at = solidcraft_ui_registry::handle_at("dialog:suggestion:1").unwrap();
let at = scenario::handle_at("dialog:suggestion:1").unwrap();
super::test_support::frame(&ctx, super::test_support::pointer(at, true), &mut draw);
super::test_support::frame(&ctx, super::test_support::pointer(at, false), &mut draw);
assert_eq!(selected, Some(1));
@@ -2428,7 +2431,7 @@ fn popup_suggestions_and_selectable_lists_keep_click_selection() {
});
};
super::test_support::frame(&ctx, vec![], &mut draw);
let at = solidcraft_ui_registry::handle_at("dialog:list:preset").unwrap();
let at = scenario::handle_at("dialog:list:preset").unwrap();
super::test_support::frame(&ctx, super::test_support::pointer(at, true), &mut draw);
super::test_support::frame(&ctx, super::test_support::pointer(at, false), &mut draw);
assert!(picked);
@@ -2488,11 +2491,8 @@ fn gallery_covers_every_inline_element() {
&& matches!(&item.vis, syn::Visibility::Restricted(v) if v.path.is_ident("crate"))
{
let name = item.sig.ident.to_string();
// Inherited labels, state keys and accessors, parsers and size measurements do not render elements.
if !matches!(
name.as_str(),
"eye_drag_id" | "expressions" | "icon_label_width" | "label" | "parse_number" | "placement_field_content_size"
) {
// Inherited labels, state keys and size measurements do not render elements.
if !matches!(name.as_str(), "eye_drag_id" | "icon_label_width" | "label" | "placement_field_content_size") {
assert!(names.contains(name.as_str()), "{file}: {name} needs a gallery entry");
}
}
@@ -2681,7 +2681,7 @@ fn command_search_board_in_both_themes() {
let mut textures = HashMap::new();
let mut image = None;
for _ in 0..3 {
solidcraft_ui_registry::clear_handles();
scenario::clear_handles();
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(bounds), focused: true, ..Default::default() }, |ui| {
ui.painter().rect_filled(bounds, 0.0, Tokens::get().panel);
crate::shortcut_box::show(&mut app, ui.ctx());
@@ -2690,25 +2690,25 @@ fn command_search_board_in_both_themes() {
output.textures_delta.clear();
image = Some(raster(&ctx, output.shapes, output.pixels_per_point, bounds, &textures));
}
let popup = solidcraft_ui_registry::rect_of("ribbon:search_popup").unwrap();
let field = solidcraft_ui_registry::rect_of("shortcut:search").unwrap();
let popup = scenario::rect_of("ribbon:search_popup").unwrap();
let field = scenario::rect_of("shortcut:search").unwrap();
assert_eq!(field.height(), 40.0);
assert_eq!(field.width(), 520.0);
assert_eq!(popup.width(), 520.0);
assert_eq!(popup.top(), field.bottom() + Tokens::get().space_6);
assert_eq!(popup.x_range(), field.x_range());
let footer = solidcraft_ui_registry::rect_of("ribbon:search_footer").unwrap();
let footer = scenario::rect_of("ribbon:search_footer").unwrap();
assert_eq!(footer, egui::Rect::from_min_max(egui::pos2(popup.left(), footer.top()), popup.right_bottom()));
assert!(bounds.contains_rect(popup), "{state}: {popup:?}");
match state {
"matches" => {
assert!(solidcraft_ui_registry::handle_at("menu:solid.fillet").is_some());
assert_eq!(solidcraft_ui_registry::rect_of("menu:solid.fillet").unwrap().height(), 38.0);
assert!(solidcraft_ui_registry::handle_at("menu:search:design:0").is_some());
assert!(scenario::handle_at("menu:solid.fillet").is_some());
assert_eq!(scenario::rect_of("menu:solid.fillet").unwrap().height(), 38.0);
assert!(scenario::handle_at("menu:search:design:0").is_some());
}
"command" => assert!(solidcraft_ui_registry::rect_of("dialog:note:command_parameters").is_some()),
"invalid" => assert!(solidcraft_ui_registry::rect_of("dialog:report:command_parameters").is_some()),
"no-match" => assert!(solidcraft_ui_registry::rect_of("dialog:report:search_no_match").is_some()),
"command" => assert!(scenario::rect_of("dialog:note:command_parameters").is_some()),
"invalid" => assert!(scenario::rect_of("dialog:report:command_parameters").is_some()),
"no-match" => assert!(scenario::rect_of("dialog:report:search_no_match").is_some()),
_ => {}
}
let image = image.unwrap();
@@ -2773,7 +2773,7 @@ fn confirm_radial_and_history_rows_keep_multitone_artwork() {
ctx.set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() });
let mut app = crate::SolveApp::new(Default::default(), Default::default());
app.ui.tab = tab.into();
app.publish_tab_accent(&ctx);
crate::theme::TabAccent::publish(&app, &ctx);
let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(600.0, 360.0));
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(bounds), ..Default::default() }, |ui| {
ui.painter().rect_filled(bounds, 0.0, Tokens::get().panel);
@@ -2782,7 +2782,7 @@ fn confirm_radial_and_history_rows_keep_multitone_artwork() {
radial_menu(
ui,
egui::pos2(180.0, 180.0),
&[super::Item::action("solid.extrude", "Extrude", icon)],
&[crate::context_menu::Item::action("solid.extrude", "Extrude", icon)],
(surface == "radial_hover").then_some(0),
);
}
@@ -2913,7 +2913,7 @@ fn command_search_ribbon_transition_clips_content_in_both_themes() {
app.ui.palette_open = false;
app.sbox.text.clear();
}
solidcraft_ui_registry::clear_handles();
scenario::clear_handles();
let mut output = ctx.run_ui(
egui::RawInput { screen_rect: Some(bounds), time: Some(f64::from(frame) / 60.0), focused: true, ..Default::default() },
|ui| {
@@ -2923,12 +2923,12 @@ fn command_search_ribbon_transition_clips_content_in_both_themes() {
);
textures_update(&mut textures, &output.textures_delta);
output.textures_delta.clear();
let field = solidcraft_ui_registry::rect_of("ribbon:search").unwrap();
let field = scenario::rect_of("ribbon:search").unwrap();
if frame == 2 {
closed = field;
}
if (3..14).contains(&frame) {
let sheet = solidcraft_ui_registry::rect_of("ribbon:search_popup").unwrap();
let sheet = scenario::rect_of("ribbon:search_popup").unwrap();
assert!(sheet.top() > field.bottom() && sheet.top() <= field.bottom() + Tokens::get().space_6);
// Actual result text is painted with the unfolding sheet's clip,
// rather than relying on a smaller background hiding overflow.
@@ -3021,7 +3021,7 @@ fn file_view_application_evidence_in_both_themes_and_widths() {
textures_update(&mut textures, &output.textures_delta);
if frame == 5 {
assert!(app.home.open);
let page = solidcraft_ui_registry::rect_of("home:page").expect("actual File view allocation");
let page = scenario::rect_of("home:page").expect("actual File view allocation");
assert!(bounds.contains_rect(page));
raster(&ctx, output.shapes.clone(), output.pixels_per_point, bounds, &textures)
.save(output_dir.join(format!("file-{name}-{}.png", if dark { "dark" } else { "light" })))
+4 -4
View File
@@ -239,7 +239,7 @@ impl HistoryTransport {
pub fn show(self, ui: &mut egui::Ui, rect: Rect, playing: bool) -> 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);
crate::scenario::publish_target(&format!("history:{}", self.id()), rect);
if response.hovered() {
ui.painter().rect_filled(rect, t.radius_sm, t.hover);
}
@@ -399,9 +399,9 @@ pub(crate) fn history_seam(ui: &mut egui::Ui, card: Rect, names: bool) -> Histor
ui.label(egui::RichText::new(crate::i18n::tr("ui.history_edge_hint")).size(t.font_small).color(t.text_dim));
});
}
solidcraft_ui_registry::publish_handle("history:names", seam.pill.center());
solidcraft_ui_registry::publish_rect("history:seam", seam.edge);
solidcraft_ui_registry::publish_rect("history:handle", seam.pill);
crate::scenario::publish_handle("history:names", seam.pill.center());
crate::scenario::publish_rect("history:seam", seam.edge);
crate::scenario::publish_rect("history:handle", seam.pill);
// egui's click response includes Enter and Space for a focused button.
HistorySeamResponse {
toggle: button.clicked() || edge.double_clicked(),
+65 -65
View File
@@ -1,7 +1,7 @@
//! Layout elements for dense cards; see `docs/ui-kit.md` for tokens and usage.
use super::{metrics::*, text::paint_text};
use crate::theme::Tokens;
use crate::ui_kit::BackingTooltipExt as _;
use crate::{scenario, theme::Tokens};
use egui::{Response, RichText, Ui, vec2};
use solidcraft_engine::kernel::Continuity;
@@ -98,7 +98,7 @@ pub(crate) fn catalog_footer(ui: &mut Ui, text: &str) -> Response {
let galley = ui.painter().layout_job(job);
ui.painter().galley(rect.left_top() + vec2(t.space_24, t.space_8), galley, t.form_dim);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), text));
solidcraft_ui_registry::publish_rect("catalog:footer", rect);
crate::scenario::publish_rect("catalog:footer", rect);
response
}
@@ -141,7 +141,7 @@ impl CatalogLayout {
.min_scrolled_height(0.0)
.auto_shrink([false, false])
.show(ui, add);
solidcraft_ui_registry::publish_rect(&format!("{}:body", self.key), scroll.inner_rect);
scenario::publish_rect(&format!("{}:body", self.key), scroll.inner_rect);
scroll.inner
}
@@ -157,8 +157,8 @@ impl CatalogLayout {
if (height - self.footer_height).abs() > 0.5 {
ui.ctx().request_discard("catalog footer measurement changed");
}
solidcraft_ui_registry::publish_rect(&format!("{}:content", self.key), self.bounds);
solidcraft_ui_registry::publish_rect(&format!("{}:footer", self.key), scroll.inner_rect);
scenario::publish_rect(&format!("{}:content", self.key), self.bounds);
scenario::publish_rect(&format!("{}:footer", self.key), scroll.inner_rect);
}
}
@@ -174,7 +174,7 @@ pub(super) fn publish(ui: &Ui, response: &Response, kind: egui::Role) {
egui::Role::TextInput => {
t.input_bevel(ui, response);
if response.has_focus() {
super::settings_search::note_key_sink(ui.ctx());
crate::prefs_search::note_key_sink(ui.ctx());
}
}
egui::Role::ComboBox | egui::Role::Button => t.button_bevel(ui, response, false),
@@ -208,7 +208,7 @@ pub(crate) fn status_line(ui: &mut Ui, text: &str, tone: StatusTone) -> Response
StatusTone::Error => t.error,
};
let response = ui.add(egui::Label::new(RichText::new(crate::i18n::tr_label(text)).size(t.font_label).color(ink)).wrap());
solidcraft_ui_registry::publish_rect(&format!("dialog:status:{text}"), response.rect);
scenario::publish_rect(&format!("dialog:status:{text}"), response.rect);
response
}
@@ -299,31 +299,31 @@ fn scrub_row<R>(ui: &mut Ui, text: &str, control: impl FnOnce(&mut Ui, Option<&R
fn row_with<R>(ui: &mut Ui, text: &str, height: f32, scrub: bool, control: impl FnOnce(&mut Ui, Option<&Response>) -> R) -> R {
let mut passthrough = false;
if super::settings_search::querying(ui) {
if crate::prefs_search::querying(ui) {
// A row with no label has no words to match; its controls supply them (#9265).
let unlabelled = text.is_empty();
if unlabelled {
super::settings_search::set_unlabelled_control(ui);
crate::prefs_search::set_unlabelled_control(ui);
} else {
super::settings_search::set_control(ui, text);
crate::prefs_search::set_control(ui, text);
}
if !super::settings_search::visible(ui, &[text]) {
if !crate::prefs_search::visible(ui, &[text]) {
if unlabelled {
super::settings_search::push_unlabelled(ui);
crate::prefs_search::push_unlabelled(ui);
}
let result = super::settings_search::run_hidden(ui, text, |ui| control(ui, None));
let result = crate::prefs_search::run_hidden(ui, text, |ui| control(ui, None));
if unlabelled {
super::settings_search::pop_unlabelled(ui);
crate::prefs_search::pop_unlabelled(ui);
}
super::settings_search::recheck(ui, &[text]);
crate::prefs_search::recheck(ui, &[text]);
return result;
}
super::settings_search::push_passthrough(ui);
crate::prefs_search::push_passthrough(ui);
passthrough = true;
}
let result = sized_row_body(ui, text, height, scrub, control);
if passthrough {
super::settings_search::pop_passthrough(ui);
crate::prefs_search::pop_passthrough(ui);
}
result
}
@@ -332,8 +332,8 @@ fn sized_row_body<R>(ui: &mut Ui, text: &str, height: f32, scrub: bool, control:
let t = Tokens::get();
let width = dimension(ui.available_width());
let (rect, _) = ui.allocate_exact_size(vec2(width, height), egui::Sense::hover());
super::settings_search::remember(ui, text, rect);
super::settings_search::follow(ui, text, rect);
crate::prefs_search::remember(ui, text, rect);
crate::prefs_search::follow(ui, text, rect);
// A nested row (`" Of"`) lines up under the one above: the lookup uses the text without its
// indent and the indent stays in front of the translation. The `LABEL` tag keeps the original.
let shown = indented_label(text);
@@ -359,8 +359,8 @@ fn sized_row_body<R>(ui: &mut Ui, text: &str, height: f32, scrub: bool, control:
)
.inner;
let help = super::help_icon(ui, help);
solidcraft_ui_registry::publish_rect(&format!("dialog:row_label:{text}"), label.rect);
solidcraft_ui_registry::publish_rect(&format!("dialog:row_help:{text}"), help.rect);
scenario::publish_rect(&format!("dialog:row_label:{text}"), label.rect);
scenario::publish_rect(&format!("dialog:row_help:{text}"), help.rect);
} else {
ui.add(egui::Label::new(RichText::new(shown.clone()).size(t.font_label).color(t.dialog_label)).truncate())
.backing_on_hover_text(shown);
@@ -388,10 +388,10 @@ fn sized_row_body<R>(ui: &mut Ui, text: &str, height: f32, scrub: bool, control:
},
)
.inner;
solidcraft_ui_registry::publish_rect(&format!("dialog:row:{text}"), rect);
solidcraft_ui_registry::publish_rect(&format!("dialog:label:{text}"), label_rect);
solidcraft_ui_registry::publish_rect(&format!("dialog:control:{text}"), control_rect);
solidcraft_ui_registry::publish_handle(&format!("dialog:row:{text}"), rect.center());
scenario::publish_rect(&format!("dialog:row:{text}"), rect);
scenario::publish_rect(&format!("dialog:label:{text}"), label_rect);
scenario::publish_rect(&format!("dialog:control:{text}"), control_rect);
scenario::publish_handle(&format!("dialog:row:{text}"), rect.center());
result
}
@@ -409,7 +409,7 @@ pub(crate) fn warning_caption(ui: &mut Ui, aligned_to: &str, text: &str) {
}
fn caption_in(ui: &mut Ui, aligned_to: &str, text: &str, color: egui::Color32) {
if super::settings_search::suppress_chrome(ui) {
if crate::prefs_search::suppress_chrome(ui) {
return;
}
let t = Tokens::get();
@@ -418,7 +418,7 @@ fn caption_in(ui: &mut Ui, aligned_to: &str, text: &str, color: egui::Color32) {
let left = (rect.left() + label_column(ui, aligned_to, width) + label_gap(ui, width)).min(rect.right());
let text_rect = egui::Rect::from_min_max(egui::pos2(left, rect.top()), rect.max);
paint_text(ui, text_rect, text, egui::FontId::proportional(t.font_small), color, false);
solidcraft_ui_registry::publish_rect(&format!("dialog:caption:{aligned_to}"), text_rect);
scenario::publish_rect(&format!("dialog:caption:{aligned_to}"), text_rect);
}
/// Natural-height content aligned beneath an indexed row's control, bounded independently of the form.
@@ -485,8 +485,8 @@ pub(crate) fn figure_rows(
top += slot + t.space_4;
}
paint(ui.painter(), figure, &slots);
solidcraft_ui_registry::publish_rect(&format!("dialog:figure:{id}"), rect);
solidcraft_ui_registry::publish_rect(&format!("dialog:figure_picture:{id}"), figure);
scenario::publish_rect(&format!("dialog:figure:{id}"), rect);
scenario::publish_rect(&format!("dialog:figure_picture:{id}"), figure);
enter
}
@@ -498,7 +498,7 @@ pub(crate) fn section_heading(ui: &mut Ui, title: &str) {
let t = Tokens::get();
let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.space_16), egui::Sense::hover());
paint_text(ui, rect, &crate::i18n::tr_label(title).to_uppercase(), crate::fonts::semibold(t.font_caption), t.form_dim, true);
solidcraft_ui_registry::publish_rect(&format!("dialog:heading:{title}"), rect);
scenario::publish_rect(&format!("dialog:heading:{title}"), rect);
}
pub(crate) fn section<R>(ui: &mut Ui, id: impl egui::AsIdSalt, title: &str, default_open: bool, add: impl FnOnce(&mut Ui) -> R) -> Option<R> {
@@ -570,10 +570,10 @@ fn section_with_badge<R>(
let (count, pending, compact) = badge;
// A search still walks the body, so a setting inside a folded section can match, without
// leaving the folded heading on screen for every query.
if super::settings_search::querying(ui) && super::settings_search::suppress_chrome(ui) && !super::settings_search::texts_hit(ui, &[title]) {
if crate::prefs_search::querying(ui) && crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[title]) {
return Some(add(ui));
}
let searching = super::settings_search::querying(ui);
let searching = crate::prefs_search::querying(ui);
let t = Tokens::get();
ui.add_space(t.space_2);
let id = ui.make_persistent_id(id);
@@ -591,7 +591,7 @@ fn section_with_badge<R>(
),
egui::Sense::click(),
);
super::settings_search::follow(ui, title, rect);
crate::prefs_search::follow(ui, title, rect);
if response.hovered() || response.has_focus() {
ui.painter().rect_filled(rect.shrink2(vec2(0.0, 1.0)), t.radius_sm, t.hover);
}
@@ -629,7 +629,7 @@ fn section_with_badge<R>(
egui::FontId::proportional(t.font_small),
t.form_dim,
);
solidcraft_ui_registry::publish_rect(&format!("dialog:section:{title}:count"), badge);
scenario::publish_rect(&format!("dialog:section:{title}:count"), badge);
if pending {
rule_right = badge.left() - t.space_12;
} else {
@@ -642,8 +642,8 @@ fn section_with_badge<R>(
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::DisclosureTriangle, ui.is_enabled(), open, crate::i18n::tr_label(title)));
// A disclosure says whether it is open as expanded, as egui's own collapsing header does.
ui.ctx().accesskit_node_builder(response.id, |node| node.set_expanded(open));
solidcraft_ui_registry::publish_handle(&format!("dialog:section:{title}"), response.rect.center());
solidcraft_ui_registry::publish_rect(&format!("dialog:section:{title}"), rect);
scenario::publish_handle(&format!("dialog:section:{title}"), response.rect.center());
scenario::publish_rect(&format!("dialog:section:{title}"), rect);
if response.clicked() && !pending && !searching {
open = !open;
}
@@ -666,13 +666,13 @@ fn section_with_badge<R>(
pub(crate) fn search_fold<R>(ui: &mut Ui, title: &str, count: usize, add: impl FnOnce(&mut Ui) -> R) -> Option<R> {
let t = Tokens::get();
let id = egui::Id::unique(("prefs_search_fold", title));
let query = super::settings_search::query(ui);
let query = crate::prefs_search::query(ui);
let mut open = ui.data(|data| data.get_temp::<bool>(id)).unwrap_or(false);
let previous = ui.data(|data| data.get_temp::<String>(id.with("query"))).unwrap_or_default();
if previous != query {
open = false;
}
super::settings_search::reveal(ui);
crate::prefs_search::reveal(ui);
ui.add_space(t.space_2);
let (rect, response) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), row_height()), egui::Sense::click());
if response.hovered() || response.has_focus() {
@@ -717,8 +717,8 @@ pub(crate) fn search_fold<R>(ui: &mut Ui, title: &str, count: usize, add: impl F
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::DisclosureTriangle, ui.is_enabled(), open, &accessible));
// A disclosure says whether it is open as expanded, as egui's own collapsing header does.
ui.ctx().accesskit_node_builder(response.id, |node| node.set_expanded(open));
solidcraft_ui_registry::publish_rect("prefs:search:shortcuts", rect);
solidcraft_ui_registry::publish_handle("prefs:search:shortcuts", response.rect.center());
scenario::publish_rect("prefs:search:shortcuts", rect);
scenario::publish_handle("prefs:search:shortcuts", response.rect.center());
if response.clicked() {
open = !open;
}
@@ -727,9 +727,9 @@ pub(crate) fn search_fold<R>(ui: &mut Ui, title: &str, count: usize, add: impl F
data.insert_temp(id, open);
});
open.then(|| {
super::settings_search::push_force(ui);
crate::prefs_search::push_force(ui);
let result = add(ui);
super::settings_search::pop_force(ui);
crate::prefs_search::pop_force(ui);
result
})
}
@@ -745,20 +745,20 @@ pub(crate) fn list_row(ui: &mut Ui, key: &str, text: &str) -> Response {
let label = egui::Rect::from_min_max(rect.min, egui::pos2((remove.left() - t.space_8).max(rect.left()), rect.bottom()));
paint_text(ui, label, text, egui::FontId::proportional(t.font_label), t.form_ink, false);
let response = ui.push_id(key, |ui| super::icon_button_at(ui, remove, "close", "Remove")).inner;
solidcraft_ui_registry::publish_rect(&format!("dialog:list:{key}"), rect);
solidcraft_ui_registry::publish_handle(&format!("dialog:list:{key}:remove"), remove.center());
scenario::publish_rect(&format!("dialog:list:{key}"), rect);
scenario::publish_handle(&format!("dialog:list:{key}:remove"), remove.center());
response.union(row)
}
/// Full-width rule; Tokens: dialog_rule. Use between groups without extra padding.
/// Full-width rule without extra padding. Tokens: `dialog_rule`.
pub(crate) fn divider(ui: &mut Ui, key: &str) {
if super::settings_search::suppress_chrome(ui) {
if crate::prefs_search::suppress_chrome(ui) {
return;
}
let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), 1.0), egui::Sense::hover());
Tokens::get().secondary_hline(ui.painter(), rect.x_range(), rect.top(), Tokens::get().dialog_rule);
solidcraft_ui_registry::publish_rect(&format!("dialog:divider:{key}"), rect);
scenario::publish_rect(&format!("dialog:divider:{key}"), rect);
}
/// Give a compound row's subfield its accessible name without allocating layout space.
@@ -771,12 +771,12 @@ pub(crate) fn named_control<R>(ui: &mut Ui, label: &str, add: impl FnOnce(&mut U
/// Publish geometry without replacing native accessibility. Tokens: `none`.
pub(super) fn publish_geometry(ui: &Ui, response: &Response) {
let label = label(ui);
solidcraft_ui_registry::publish_rect(&format!("dialog:widget:{label}"), response.rect);
scenario::publish_rect(&format!("dialog:widget:{label}"), response.rect);
// A click lands where the field shows: a field scrolled out of a dialog's field area, under
// its footer, has no handle (a click at its centre would press the button over it).
let shown = response.rect.intersect(ui.clip_rect());
if shown.is_positive() {
solidcraft_ui_registry::publish_handle(&format!("dialog:{label}"), shown.center());
scenario::publish_handle(&format!("dialog:{label}"), shown.center());
}
}
@@ -797,7 +797,7 @@ pub(crate) fn text_lines(ui: &mut Ui, id: &str, text: &str) {
ui.add(egui::Label::new(RichText::new(*line).font(font.clone()).color(t.form_ink)).truncate()).backing_on_hover_text(*line);
}
});
solidcraft_ui_registry::publish_rect(&format!("dialog:lines:{id}"), response.inner_rect);
scenario::publish_rect(&format!("dialog:lines:{id}"), response.inner_rect);
}
/// Read-only text variants retain their original wrapping, allocation and scenario identities.
@@ -837,20 +837,20 @@ pub(crate) fn name_cell_with_hint(ui: &mut Ui, name: &str, strong: bool, hint: &
}
fn text_impl(ui: &mut Ui, text: &str, style: Text<'_>, hover_hint: Option<&str>) -> Response {
if super::settings_search::querying(ui) {
if crate::prefs_search::querying(ui) {
let heading = matches!(style, Text::Heading(_));
if heading {
let hit = super::settings_search::texts_hit(ui, &[text]);
super::settings_search::set_heading_open(ui, hit);
let hit = crate::prefs_search::texts_hit(ui, &[text]);
crate::prefs_search::set_heading_open(ui, hit);
if hit {
super::settings_search::reveal(ui);
} else if super::settings_search::suppress_chrome(ui) {
return super::settings_search::hidden_response(ui);
crate::prefs_search::reveal(ui);
} else if crate::prefs_search::suppress_chrome(ui) {
return crate::prefs_search::hidden_response(ui);
}
} else if super::settings_search::suppress_chrome(ui) && !super::settings_search::texts_hit(ui, &[text]) {
return super::settings_search::hidden_response(ui);
} else if super::settings_search::texts_hit(ui, &[text]) && !super::settings_search::shows_all(ui) {
super::settings_search::reveal(ui);
} else if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, &[text]) {
return crate::prefs_search::hidden_response(ui);
} else if crate::prefs_search::texts_hit(ui, &[text]) && !crate::prefs_search::shows_all(ui) {
crate::prefs_search::reveal(ui);
}
}
let t = Tokens::get();
@@ -922,13 +922,13 @@ fn text_impl(ui: &mut Ui, text: &str, style: Text<'_>, hover_hint: Option<&str>)
Text::Cell | Text::MonoCell | Text::NameCell(_) | Text::SelectedNameCell => ("cell", text),
};
let id = format!("dialog:{kind}:{key}");
solidcraft_ui_registry::publish_rect(&id, response.rect);
scenario::publish_rect(&id, response.rect);
if matches!(style, Text::Heading(_)) {
super::settings_search::remember(ui, text, response.rect);
super::settings_search::follow(ui, text, response.rect);
crate::prefs_search::remember(ui, text, response.rect);
crate::prefs_search::follow(ui, text, response.rect);
}
if !matches!(style, Text::Note(_) | Text::MonoNote(_)) {
solidcraft_ui_registry::publish_handle(&id, response.rect.center());
scenario::publish_handle(&id, response.rect.center());
}
response
}
@@ -956,8 +956,8 @@ pub(crate) fn summary_row(ui: &mut Ui, name: &str, summary: &str, enabled: &mut
paint_text(ui, detail, summary, egui::FontId::proportional(t.font_small), t.form_dim, false);
let response = ui.interact(text, ui.scope_id().with("summary"), egui::Sense::hover()).backing_on_hover_text(summary);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), format!("{name}: {summary}")));
solidcraft_ui_registry::publish_rect(&format!("dialog:inline:{name}"), title);
solidcraft_ui_registry::publish_rect("dialog:report:rule_report", detail);
scenario::publish_rect(&format!("dialog:inline:{name}"), title);
scenario::publish_rect("dialog:report:rule_report", detail);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(egui::Rect::from_min_max(egui::pos2(right, rect.top()), rect.max))
+1 -1
View File
@@ -262,7 +262,7 @@ fn mouse_mapping_search_keeps_main_gesture_chords_and_actions() {
let output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
scope(ui, |ui| {
ui.set_width(280.0);
let _search = crate::ui_kit::settings_search::begin(ui, query);
let _search = crate::prefs_search::begin(ui, query);
settings_mouse_mapping(
ui,
&[(vec!["Ctrl/Cmd".into(), "Middle drag".into()], "Zoom toward the cursor".into()), (vec!["Alt/Option".into()], "Orbit".into())],
+4 -4
View File
@@ -1,6 +1,6 @@
//! Appearance inheritance selector. The caller owns selection and commands.
use super::{Sphere, sphere::paint_sphere};
use crate::theme::Tokens;
use crate::{scenario, theme::Tokens};
use egui::{Rect, Sense, Stroke, Ui, pos2, vec2};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -79,10 +79,10 @@ pub fn look_chain(ui: &mut Ui, id: &str, labels: [&str; 4], looks: [Option<Spher
let label_left = (cell.center().x - label_width / 2.0).max(cell.left());
let label_rect = Rect::from_min_max(pos2(label_left, cell.top() + side + t.space_2), cell.max);
super::text::paint_text(ui, label_rect, label, font, if step == active { t.render_accent } else { t.form_dim }, false);
solidcraft_ui_registry::publish_rect(&key, cell);
solidcraft_ui_registry::publish_handle(&key, cell.center());
scenario::publish_rect(&key, cell);
scenario::publish_handle(&key, cell.center());
}
solidcraft_ui_registry::publish_rect(id, rect);
scenario::publish_rect(id, rect);
clicked
}
@@ -1,5 +1,4 @@
use super::*;
use crate::scenario;
use crate::ui_kit::{
FinishKind,
test_support::{frame, pointer},
+5 -102
View File
@@ -1,104 +1,7 @@
//! Menus share one popup surface and one row painter. Command dispatch stays with callers.
use crate::ui_kit::{Container, Surface};
use crate::{fonts::RichTextExt as _, icons, theme::Tokens};
use crate::{context_menu::Item, fonts::RichTextExt as _, icons, theme::Tokens};
use egui::{Color32, FontId, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
use serde::Serialize;
use serde_json::Value;
/// One menu entry: a command id (run with `params` when given, else started like a toolbar
/// click) or a `ui.*` action. `-` is a separator, `#…` a section heading.
#[derive(Clone, Debug, Serialize)]
pub struct Item {
pub id: String,
pub label: String,
pub shortcut: String,
pub enabled: bool,
pub icon: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub children: Vec<Item>,
#[serde(skip_serializing_if = "Option::is_none")]
pub swatch: Option<[u8; 3]>,
/// Why the entry is off: shown on hover, and in the control channel's report.
#[serde(skip_serializing_if = "Option::is_none")]
pub tip: Option<String>,
}
impl Item {
/// A view action (`ui.*`).
pub fn action(id: &str, label: &str, icon: &str) -> Item {
Item {
id: id.into(),
label: label.into(),
shortcut: String::new(),
enabled: true,
icon: icon.into(),
params: None,
children: Vec::new(),
swatch: None,
tip: None,
}
}
pub fn sep() -> Item {
Item {
id: "-".into(),
label: String::new(),
shortcut: String::new(),
enabled: false,
icon: String::new(),
params: None,
children: Vec::new(),
swatch: None,
tip: None,
}
}
pub(crate) fn heading(label: &str) -> Item {
Item {
id: format!("#{label}"),
label: label.into(),
shortcut: String::new(),
enabled: false,
icon: String::new(),
params: None,
children: Vec::new(),
swatch: None,
tip: None,
}
}
pub(crate) fn is_sep(&self) -> bool {
self.id == "-"
}
pub(crate) fn is_heading(&self) -> bool {
self.id.starts_with('#')
}
pub fn with(mut self, params: Value) -> Item {
self.params = Some(params);
self
}
pub fn on_if(self, enabled: bool) -> Item {
self.on(enabled)
}
pub(crate) fn on(mut self, enabled: bool) -> Item {
self.enabled &= enabled;
self
}
/// Off, saying why, when there is a reason.
pub(crate) fn off_because(mut self, why: Option<String>) -> Item {
if let Some(why) = why {
self.enabled = false;
self.tip = Some(why);
}
self
}
pub fn key(mut self, k: &str) -> Item {
self.shortcut = k.into();
self
}
}
/// Width of list menus.
pub const LIST_W: f32 = 250.0;
pub(crate) struct Menu;
@@ -341,7 +244,7 @@ impl Menu {
leftward: bool,
add: impl FnOnce(&mut Ui) -> R,
) -> egui::InnerResponse<R> {
let width = LIST_W;
let width = crate::context_menu::LIST_W;
let screen = ctx.content_rect();
// The estimate only places the area's first, invisible sizing pass.
let height = ctx.memory(|m| m.area_rect(id)).map_or(height, |r| r.height());
@@ -459,7 +362,7 @@ impl<'a> MenuItem<'a> {
let galley = painter.layout_job(job);
painter.galley(pos2(left, r.center().y - galley.size().y / 2.0), galley, ink);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
solidcraft_ui_registry::publish_target(&format!("menu:{}", self.id), r);
crate::scenario::publish_target(&format!("menu:{}", self.id), r);
if enabled && response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
@@ -505,7 +408,7 @@ pub(crate) fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option<
if item.enabled { Sense::click() } else { Sense::hover() },
);
for handle in [format!("menu:{}", item.id), format!("workspace:menu:{slug}")] {
solidcraft_ui_registry::publish_target(&handle, r);
crate::scenario::publish_target(&handle, r);
}
if current || (item.enabled && (resp.hovered() || key_row == Some(k))) {
ui.painter().rect_filled(r, t.control_radius.saturating_sub(1), soft);
@@ -594,7 +497,7 @@ pub(crate) fn rename_menu(ui: &mut Ui, value: &mut String, focused: &mut bool) -
let id = egui::Id::unique("sc_rename_text");
let response = ui.add(super::fields::text_input(egui::TextEdit::singleline(value).id(id)).desired_width(ui.available_width()));
t.input_bevel(ui, &response);
solidcraft_ui_registry::publish_target("rename:name", response.rect);
crate::scenario::publish_target("rename:name", response.rect);
if !*focused {
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) {
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
+4 -10
View File
@@ -42,11 +42,9 @@ mod preferences_layout;
mod preferences_navigation;
mod preferences_small;
mod radial;
pub(crate) mod recent_colors;
mod reports;
mod review;
mod search;
pub(crate) mod settings_search;
mod shortcuts;
mod sphere;
mod stages;
@@ -58,7 +56,6 @@ mod theme_picker;
mod tree;
mod value_focus;
mod viewport_status;
mod wheel;
pub(crate) use actions::*;
pub use actions::{Button, button};
@@ -74,29 +71,26 @@ pub(crate) use choice::*;
pub(crate) use color::*;
pub(crate) use compare::*;
pub(crate) use confirm::{Confirm, Tone};
pub use container::{BackingTooltipExt, Container, DockEdges, Edge, LayoutMode, Surface};
pub(crate) use container::{SHELL_GAP, input_blocked, set_input_blocked};
pub use container::{BackingTooltipExt, Container, DockEdges, Edge, Surface};
pub(crate) use details::*;
pub(crate) use disclosure::*;
pub(crate) use drag::*;
pub(crate) use fields::*;
pub use fields::{Expressions, cell_editor, install_expressions, wide_cell_editor};
pub use fields::{cell_editor, wide_cell_editor};
pub(crate) use file_view::{FileView, RecentRow, RecoveryMessage, SampleCard};
pub(crate) use framing::*;
pub(crate) use history::*;
pub(crate) use history_group::*;
pub(crate) use layout::*;
pub use look_chain::{LookStep, look_chain};
pub use menu::{Item, LIST_W};
pub(crate) use menu::{Menu, MenuItem, rename_menu, workspace_menu};
pub(crate) use metrics::{ContentWidth, header_height, row_height, table_row_height};
pub use name_list::{NameList, NameRow, name_list};
pub(crate) use navigation::*;
pub use navigation::{NavigationEdge, NavigationIdle, NavigationSize};
pub(crate) use navigation_menu::*;
pub(crate) use navigation_settings::*;
pub(crate) use panel::{
ComputeStatus, FeatureOptions, InspectorAction, Panel, Part, WindowPage, WorkspaceOptions, inspector_header, panel_header_hover, within,
ComputeStatus, FeatureOptions, InspectorAction, Panel, Part, WindowPage, WorkspaceOptions, inspector_header, panel_header_hover,
};
pub use panel::{DialogButtons, Window};
#[cfg(test)]
@@ -122,7 +116,7 @@ pub(crate) use shortcuts::*;
pub(crate) use sphere::sphere_preview;
pub use sphere::{FinishKind, Sphere, sphere_thumbnail};
pub(crate) use sphere::{SphereTile, sphere_cursor, sphere_grid, sphere_grid_drag, sphere_preview_with_source};
pub(crate) use stages::{Cards, StageAction, StageBar};
pub(crate) use stages::{StageAction, StageBar};
pub(crate) use status::*;
pub(crate) use table::*;
#[cfg(test)]
+3 -3
View File
@@ -2,8 +2,8 @@
//! cells leave. Built on the kit's table grid; see `docs/ui-kit.md` for tokens and usage.
use super::metrics::dimension;
use super::table::{Column, Table, TableRow, table_custom};
use crate::theme::Tokens;
use crate::ui_kit::BackingTooltipExt as _;
use crate::{scenario, theme::Tokens};
use egui::{Response, Ui, vec2};
/// Columns past this are ignored: a list of named rows has a handful of cells.
@@ -111,7 +111,7 @@ fn name_text(ui: &mut Ui, name: &str) -> Response {
super::text::paint_text(ui, rect, name, font, t.form_ink, false);
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), name));
let id = format!("dialog:name:{name}");
solidcraft_ui_registry::publish_rect(&id, rect);
solidcraft_ui_registry::publish_count(&format!("{id}:cut"), f64::from(u8::from(cut)));
scenario::publish_rect(&id, rect);
scenario::publish_count(&format!("{id}:cut"), f64::from(u8::from(cut)));
if cut { response.backing_on_hover_text(name) } else { response }
}
+21 -66
View File
@@ -1,7 +1,10 @@
//! Navigation chrome and measurements shared by the viewport and Preferences editor.
use crate::{icons, theme::Tokens};
use crate::{
icons,
navigation_bar::{Idle, Kind, Size},
theme::Tokens,
};
use egui::{Rect, Response, Sense, Ui, pos2, vec2};
use serde::{Deserialize, Serialize};
pub(crate) const NAV_BORDER: f32 = 1.0;
pub(crate) const NAV_PADDING: f32 = 3.0;
@@ -38,48 +41,6 @@ pub(crate) const NAV_FADE_SECONDS: f32 = 0.12;
pub(crate) const NAV_TOOLTIP_DELAY: f64 = 0.8;
pub(crate) const NAV_TOOLTIP_KEY_LINE_HEIGHT: f32 = 15.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NavigationKind {
Action,
Tool,
Toggle,
Menu,
Separator,
}
/// The edge of the 3D view the bar sits on (#8183): the bottom, or the top, under the ribbon.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NavigationEdge {
#[default]
Bottom,
Top,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NavigationSize {
Compact,
#[default]
Regular,
}
impl NavigationSize {
// Logical points: egui's UI zoom applies the preference's scale to these dimensions.
pub(crate) fn points(self) -> f32 {
NavigationMetrics::for_size(self).button
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NavigationIdle {
Always,
#[default]
Fade,
Hide,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct NavigationMetrics {
pub button: f32,
@@ -88,10 +49,10 @@ pub(crate) struct NavigationMetrics {
pub button_radius: u8,
}
impl NavigationMetrics {
pub(crate) fn for_size(size: NavigationSize) -> Self {
pub(crate) fn for_size(size: Size) -> Self {
match size {
NavigationSize::Regular => Self { button: 32.0, icon: 18.0, radius: 10, button_radius: 7 },
NavigationSize::Compact => Self { button: 26.0, icon: 16.0, radius: 9, button_radius: 6 },
Size::Regular => Self { button: 32.0, icon: 18.0, radius: 10, button_radius: 7 },
Size::Compact => Self { button: 26.0, icon: 16.0, radius: 9, button_radius: 6 },
}
}
pub(crate) fn height(self) -> f32 {
@@ -104,7 +65,7 @@ pub(crate) struct NavigationButton<'a> {
pub label: &'a str,
pub icon: &'a str,
pub selected: bool,
pub kind: NavigationKind,
pub kind: Kind,
pub labels: bool,
pub menu: bool,
}
@@ -118,10 +79,10 @@ pub(crate) enum NavigationFill {
}
/// Actions never latch, and keyboard focus has its own outline rather than a fill.
pub(crate) fn navigation_visual(kind: NavigationKind, active: bool, hovered: bool, pressed: bool, keyboard_focus: bool) -> (NavigationFill, bool) {
let fill = if pressed || (active && kind == NavigationKind::Menu) {
pub(crate) fn navigation_visual(kind: Kind, active: bool, hovered: bool, pressed: bool, keyboard_focus: bool) -> (NavigationFill, bool) {
let fill = if pressed || (active && kind == Kind::Menu) {
NavigationFill::Press
} else if active && matches!(kind, NavigationKind::Tool | NavigationKind::Toggle) {
} else if active && matches!(kind, Kind::Tool | Kind::Toggle) {
NavigationFill::Active
} else if hovered {
NavigationFill::Hover
@@ -153,12 +114,12 @@ pub(crate) fn navigation_ink(t: &Tokens, fill: NavigationFill) -> egui::Color32
/// WCAG small-text contrast for an active slot's label on its selected fill.
pub(crate) const NAV_ACTIVE_MIN_CONTRAST: f64 = 4.5;
pub(crate) fn navigation_opacity(idle: NavigationIdle, active: bool) -> f32 {
pub(crate) fn navigation_opacity(idle: Idle, active: bool) -> f32 {
match idle {
_ if active => 1.0,
NavigationIdle::Always => 1.0,
NavigationIdle::Fade => NAV_IDLE_OPACITY,
NavigationIdle::Hide => 0.0,
Idle::Always => 1.0,
Idle::Fade => NAV_IDLE_OPACITY,
Idle::Hide => 0.0,
}
}
@@ -168,11 +129,8 @@ pub(crate) fn navigation_button(ui: &mut Ui, rect: Rect, button: NavigationButto
pub(crate) fn navigation_button_editable(ui: &mut Ui, rect: Rect, button: NavigationButton<'_>, editing: bool) -> Response {
let t = Tokens::get();
let metrics = NavigationMetrics::for_size(if rect.height() < NavigationMetrics::for_size(NavigationSize::Regular).button {
NavigationSize::Compact
} else {
NavigationSize::Regular
});
let metrics =
NavigationMetrics::for_size(if rect.height() < NavigationMetrics::for_size(Size::Regular).button { Size::Compact } else { Size::Regular });
let response = ui.interact(rect, ui.make_persistent_id(("nav_slot", button.id)), if editing { Sense::click_and_drag() } else { Sense::click() });
let pressed = response.is_pointer_button_down_on();
if pressed || response.clicked_by(egui::PointerButton::Primary) {
@@ -233,11 +191,8 @@ pub(crate) fn navigation_separator(ui: &Ui, rect: Rect) {
pub(crate) fn navigation_edit_outline(ui: &Ui, rect: Rect, hidden: bool, selected: bool) {
let t = Tokens::get();
let metrics = NavigationMetrics::for_size(if rect.height() < NavigationMetrics::for_size(NavigationSize::Regular).button {
NavigationSize::Compact
} else {
NavigationSize::Regular
});
let metrics =
NavigationMetrics::for_size(if rect.height() < NavigationMetrics::for_size(Size::Regular).button { Size::Compact } else { Size::Regular });
let mut painter = ui.painter().clone();
if hidden {
painter.multiply_opacity(NAV_HIDDEN_OPACITY);
@@ -264,7 +219,7 @@ pub(crate) fn navigation_edit_outline(ui: &Ui, rect: Rect, hidden: bool, selecte
}
}
pub(crate) fn navigation_surface(ui: &Ui, rect: Rect, size: NavigationSize, editing: bool) {
pub(crate) fn navigation_surface(ui: &Ui, rect: Rect, size: Size, editing: bool) {
let t = Tokens::get();
let frame = super::Container::new(ui.ctx(), super::Surface::Navigation)
.frame(ui.ctx(), t.dialog_body, crate::frame::Side::Left)
+5 -5
View File
@@ -1,7 +1,7 @@
//! Popup and tooltip skin from the approved navigation boards.
use super::{Container, Surface, navigation::*};
use crate::ui_kit::BackingTooltipExt as _;
use crate::{icons, theme::Tokens};
use crate::{icons, navigation_bar::Edge, theme::Tokens};
use egui::{Align2, FontId, Rect, Response, Sense, Ui, pos2, vec2};
pub(crate) fn navigation_popup_frame() -> egui::Frame {
@@ -110,7 +110,7 @@ pub(crate) fn navigation_menu_separator(ui: &mut Ui) {
/// A tip beyond `along` (the bar, or the row it names): above it, or below it for a bar on the
/// view's top edge.
pub(crate) fn navigation_tooltip(response: &Response, along: Rect, edge: NavigationEdge, label: &str, shortcut: Option<&str>) {
pub(crate) fn navigation_tooltip(response: &Response, along: Rect, edge: Edge, label: &str, shortcut: Option<&str>) {
let ctx = &response.ctx;
let id = response.id.with("nav_tooltip_delay");
if !response.hovered() {
@@ -130,10 +130,10 @@ pub(crate) fn navigation_tooltip(response: &Response, along: Rect, edge: Navigat
.area(response.id.with("nav_tooltip"))
.order(egui::Order::Tooltip)
.interactable(false)
.pivot(if edge == NavigationEdge::Top { Align2::CENTER_TOP } else { Align2::CENTER_BOTTOM })
.pivot(if edge == Edge::Top { Align2::CENTER_TOP } else { Align2::CENTER_BOTTOM })
.fixed_pos(match edge {
NavigationEdge::Bottom => pos2(response.rect.center().x, along.top() - NAV_POPUP_GAP),
NavigationEdge::Top => pos2(response.rect.center().x, along.bottom() + NAV_POPUP_GAP),
Edge::Bottom => pos2(response.rect.center().x, along.top() - NAV_POPUP_GAP),
Edge::Top => pos2(response.rect.center().x, along.bottom() + NAV_POPUP_GAP),
})
.show(ctx, |ui| {
Container::new(ctx, Surface::Popup).show_frame(ui, frame, ui.max_rect(), |ui| {
@@ -128,7 +128,7 @@ pub(crate) fn navigation_anchor_picker(ui: &mut Ui, id: &str, selected: usize, l
);
}
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), on, *label));
solidcraft_ui_registry::publish_target(&format!("prefs:navbar:anchor:{index}"), cell);
crate::scenario::publish_target(&format!("prefs:navbar:anchor:{index}"), cell);
if response.backing_on_hover_text(*label).clicked() {
clicked = Some(index);
}
@@ -241,7 +241,7 @@ pub(crate) fn navigation_tile(ui: &mut Ui, id: &str, label: &str, icon: &str, dr
(response, add)
}
pub(crate) fn navigation_drag_ghost(ui: &Ui, at: egui::Pos2, button: NavigationButton<'_>, size: NavigationSize) {
pub(crate) fn navigation_drag_ghost(ui: &Ui, at: egui::Pos2, button: NavigationButton<'_>, size: crate::navigation_bar::Size) {
let ctx = ui.ctx();
let t = Tokens::get();
let metrics = NavigationMetrics::for_size(size);
+36 -42
View File
@@ -1,18 +1,12 @@
//! Panel elements for dense cards; see `docs/ui-kit.md` for tokens and usage.
use super::{actions::icon_button_at, metrics::*, text::paint_text};
use crate::theme::Tokens;
use crate::ui_kit::BackingTooltipExt as _;
use crate::{scenario, theme::Tokens};
use egui::{Ui, vec2};
const WINDOW_PAGES_NARROW_WIDTH: f32 = 150.0;
const WINDOW_PAGES_NARROW_AT: f32 = 700.0;
/// `v` kept within `lo..=hi`, or `lo` when that range is empty: `f32::clamp` panics on
/// `lo > hi`, which a rect squeezed to nothing between cards gives.
pub(crate) fn within(v: f32, lo: f32, hi: f32) -> f32 {
if lo <= hi { v.clamp(lo, hi) } else { lo }
}
/// Shared active-tab hover tint for the browser and collapsible panel chrome.
pub(crate) fn panel_header_hover(painter: &egui::Painter, rect: egui::Rect) {
let t = Tokens::get();
@@ -70,7 +64,7 @@ fn header_actions(
);
if closeable {
let close = icon_button_at(ui, close_rect, "close", "Close");
solidcraft_ui_registry::publish_handle("dialog:close", close.rect.center());
scenario::publish_handle("dialog:close", close.rect.center());
if close.clicked() {
on_close();
}
@@ -89,12 +83,12 @@ fn header_actions(
action_ui.set_clip_rect(ui.clip_rect().intersect(action_rect));
}
actions(&mut action_ui);
solidcraft_ui_registry::publish_rect("dialog:header:accessory", action_rect);
scenario::publish_rect("dialog:header:accessory", action_rect);
action_rect.left() - t.space_8
} else if let Some(collapsed) = collapsed {
let fold = egui::Rect::from_min_size(egui::pos2(close_rect.left() - t.form_close_size - t.space_4, close_rect.top()), close_rect.size());
let response = super::disclosure_button_at(ui, fold, !*collapsed, crate::i18n::tr_label(if *collapsed { "Expand" } else { "Collapse" }));
solidcraft_ui_registry::publish_handle("dialog:collapse", response.rect.center());
scenario::publish_handle("dialog:collapse", response.rect.center());
if response.clicked() {
*collapsed = !*collapsed;
}
@@ -109,7 +103,7 @@ fn header_actions(
paint_text(ui, title_rect, title, crate::fonts::semibold(if icon.0.is_empty() { t.font_label } else { t.font_heading }), t.form_ink, false);
let title_response = ui.interact(title_rect, ui.make_persistent_id("title_text"), egui::Sense::hover());
title_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), title));
solidcraft_ui_registry::publish_rect("dialog:header", rect);
scenario::publish_rect("dialog:header", rect);
t.secondary_hline(ui.painter(), rect.x_range(), rect.bottom(), t.dialog_rule);
}
@@ -172,11 +166,11 @@ impl<'a> TopHeader<'a> {
// left out: one control for one action, and its width goes to the title. Its handle
// moves to Cancel, so automation that closes a panel keeps working.
if let Some(cancel_rect) = actions.rect(super::Action::Cancel) {
solidcraft_ui_registry::publish_handle("dialog:close", cancel_rect.center());
scenario::publish_handle("dialog:close", cancel_rect.center());
} else {
let close_rect = target(right);
let response = icon_button_at(ui, close_rect, "close", "Close");
solidcraft_ui_registry::publish_handle("dialog:close", response.rect.center());
scenario::publish_handle("dialog:close", response.rect.center());
if response.clicked() {
*close = true;
}
@@ -185,7 +179,7 @@ impl<'a> TopHeader<'a> {
if let Some(collapsed) = collapsed {
let fold = target(right);
let response = super::disclosure_button_at(ui, fold, !*collapsed, crate::i18n::tr_label(if *collapsed { "Expand" } else { "Collapse" }));
solidcraft_ui_registry::publish_handle("dialog:collapse", response.rect.center());
scenario::publish_handle("dialog:collapse", response.rect.center());
if response.clicked() {
*collapsed = !*collapsed;
}
@@ -198,8 +192,8 @@ impl<'a> TopHeader<'a> {
let left = (icon.right() + t.space_8).min(right);
let title = egui::Rect::from_min_max(egui::pos2(left, band.top()), egui::pos2(right.max(left), band.bottom()));
// Dragging the panel by its title must not start on a button.
solidcraft_ui_registry::publish_handle("dialog:title", title.center());
solidcraft_ui_registry::publish_rect("dialog:header", band);
scenario::publish_handle("dialog:title", title.center());
scenario::publish_rect("dialog:header", band);
t.secondary_hline(ui.painter(), band.x_range(), band.bottom(), t.dialog_rule);
Self { icon, title, actions }
}
@@ -249,7 +243,7 @@ pub(crate) fn inspector_header(ui: &mut Ui, icon: &str, title: &str, kind: &str)
vec2(t.form_close_size, t.form_close_size),
);
let close = super::icon_button_at(ui, close_rect, "close", crate::i18n::tr_label("Close"));
solidcraft_ui_registry::publish_target("render:appearance:close", close.rect);
scenario::publish_target("render:appearance:close", close.rect);
if close.clicked() {
action = Some(InspectorAction::Close);
}
@@ -278,7 +272,7 @@ pub(crate) fn inspector_header(ui: &mut Ui, icon: &str, title: &str, kind: &str)
ui.interact(name, ui.make_persistent_id("inspector_title"), egui::Sense::hover())
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), title));
t.secondary_hline(ui.painter(), rect.x_range(), rect.bottom(), t.dialog_rule);
solidcraft_ui_registry::publish_rect("dialog:header", rect);
scenario::publish_rect("dialog:header", rect);
action
}
@@ -382,7 +376,7 @@ impl<'a> Panel<'a> {
let band = ui.max_rect().expand2(vec2(t.space_12, t.space_8));
ui.painter().rect_filled(band, egui::CornerRadius { nw: 0, ne: 0, sw: t.radius_xl, se: t.radius_xl }, t.dialog_footer);
t.secondary_hline(ui.painter(), band.x_range(), band.top(), t.dialog_rule);
solidcraft_ui_registry::publish_rect("dialog:editor_footer", band);
scenario::publish_rect("dialog:editor_footer", band);
add(ui)
})
}
@@ -529,18 +523,18 @@ impl<'a> Panel<'a> {
let grip = ui
.interact(grip_rect, ui.scope_id().with("workspace_grip"), egui::Sense::click_and_drag())
.on_hover_cursor(egui::CursorIcon::Grab);
solidcraft_ui_registry::publish_target(&format!("{}:grip", options.id), grip.rect);
scenario::publish_target(&format!("{}:grip", options.id), grip.rect);
super::Menu::context(&grip, |ui| {
let (target, label) = if left { ("right", "Move to right side") } else { ("left", "Move to left side") };
let item = super::MenuItem::new("view.render_dock", label, "").show(ui);
solidcraft_ui_registry::publish_target(&format!("{}:move_side", options.id), item.rect);
scenario::publish_target(&format!("{}:move_side", options.id), item.rect);
if item.clicked() {
move_side = Some(target);
ui.close();
}
});
header(ui, &options);
solidcraft_ui_registry::publish_rect(&format!("{}:header", options.id), band);
scenario::publish_rect(&format!("{}:header", options.id), band);
if grip.dragged() {
let frame_nr = ui.ctx().cumulative_frame_nr();
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("sc_dock_drag_frame"), frame_nr));
@@ -581,7 +575,7 @@ impl<'a> Panel<'a> {
);
let resize =
ui.interact(rail, ui.scope_id().with("workspace_resize"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
solidcraft_ui_registry::publish_target(&format!("{}:resize", options.id), rail);
scenario::publish_target(&format!("{}:resize", options.id), rail);
if resize.dragged() && !options.compact {
let delta = ui.input(|i| i.pointer.delta().x) * if left { 1.0 } else { -1.0 };
if delta.is_finite() {
@@ -600,12 +594,12 @@ impl<'a> Panel<'a> {
}
/// Feature surface; shared container policy supplies the border and shadow.
pub(crate) fn feature_frame(ctx: &egui::Context, layout: super::LayoutMode, side: crate::frame::Side) -> egui::Frame {
pub(crate) fn feature_frame(ctx: &egui::Context, layout: crate::shell::LayoutMode, side: crate::frame::Side) -> egui::Frame {
super::Container::for_layout(layout, super::Surface::Feature).frame(ctx, Tokens::get().dialog_body, side).inner_margin(0.0)
}
/// Sketch palette uses the same layout policy as every other workspace panel.
pub(crate) fn palette_frame(ctx: &egui::Context, layout: super::LayoutMode) -> egui::Frame {
pub(crate) fn palette_frame(ctx: &egui::Context, layout: crate::shell::LayoutMode) -> egui::Frame {
let t = Tokens::get();
super::Container::for_layout(layout, super::Surface::Palette)
.frame(ctx, t.panel.gamma_multiply(t.sketch_palette_alpha), crate::frame::Side::Right)
@@ -686,7 +680,7 @@ impl<'a> Panel<'a> {
viewport.left() + width,
(viewport.right() - if gap > 0.0 { Tokens::get().space_8 } else { 0.0 }).max(viewport.left() + width),
),
crate::frame::Side::Left => within(self.anchor.x, viewport.left(), viewport.right() - width),
crate::frame::Side::Left => crate::shell::within(self.anchor.x, viewport.left(), viewport.right() - width),
};
let t = Tokens::get();
let chrome = t.form_header_height + t.form_footer_height + t.form_gap_y * 2.0 + self.frame.total_margin().sum().y + gap;
@@ -798,7 +792,7 @@ impl<'a> Panel<'a> {
let min = ui.cursor().min;
let band = egui::Rect::from_min_size(min, vec2(ui.available_width(), super::header_height()));
if heading.drag_key.is_empty() {
solidcraft_ui_registry::publish_handle("dialog:title", band.center());
scenario::publish_handle("dialog:title", band.center());
} else {
crate::frame::drag_dock(ui, band, heading.drag_key, heading.moved, self.bounded_anchor(ui.ctx()) - heading.docked);
}
@@ -822,7 +816,7 @@ impl<'a> Panel<'a> {
let fields_height = self.body_rows.map_or(fields_height, |rows| fields_height.min(rows * t.control_height + t.space_12 * 2.0));
let fields_rect = egui::Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), fields_height));
let state = if collapsed.as_deref().copied().unwrap_or(false) {
solidcraft_ui_registry::publish_count("dialog:body:content_height", 0.0);
scenario::publish_count("dialog:body:content_height", 0.0);
ComputeStatus { text: None, busy: disabled, applying: disabled }
} else {
ui.scope_builder(egui::UiBuilder::new().max_rect(fields_rect), |ui| {
@@ -845,9 +839,9 @@ impl<'a> Panel<'a> {
.show(ui, body)
.inner
});
solidcraft_ui_registry::publish_count("dialog:body:content_height", f64::from(scroll.content_size.y));
solidcraft_ui_registry::publish_count("dialog:body:scroll_y", f64::from(scroll.state.clamped_offset().y));
solidcraft_ui_registry::publish_rect("dialog:body:viewport", scroll.inner_rect);
scenario::publish_count("dialog:body:content_height", f64::from(scroll.content_size.y));
scenario::publish_count("dialog:body:scroll_y", f64::from(scroll.state.clamped_offset().y));
scenario::publish_rect("dialog:body:viewport", scroll.inner_rect);
scroll.inner
})
.inner
@@ -870,7 +864,7 @@ impl<'a> Panel<'a> {
if state.busy {
let (rect, _) = ui.allocate_exact_size(vec2(t.space_16, ui.available_height()), egui::Sense::hover());
egui::Spinner::new().size(t.space_14).paint_at(ui, rect);
solidcraft_ui_registry::publish_rect("dialog:footer:spinner", rect);
scenario::publish_rect("dialog:footer:spinner", rect);
let status = state.text.as_deref().unwrap_or_else(|| crate::i18n::tr_label("Updating the preview"));
ui.interact(rect, ui.scope_id().with("compute_status"), egui::Sense::hover()).backing_on_hover_text(status);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
@@ -921,7 +915,7 @@ impl<'a> Panel<'a> {
.show(ui, body)
})
.inner;
solidcraft_ui_registry::publish_rect("sketch_palette:options", scroll.inner_rect);
scenario::publish_rect("sketch_palette:options", scroll.inner_rect);
}
if !at_top {
add(ui, Part::Footer);
@@ -934,7 +928,7 @@ impl<'a> Panel<'a> {
.min_scrolled_height(0.0)
.auto_shrink([false, true])
.show(ui, contents);
solidcraft_ui_registry::publish_rect("sketch_palette:options", scroll.inner_rect);
scenario::publish_rect("sketch_palette:options", scroll.inner_rect);
} else {
contents(ui);
}
@@ -1260,7 +1254,7 @@ impl<'a> Window<'a> {
super::Container::finish_content(ui, frame);
result
});
solidcraft_ui_registry::publish_rect("dialog:window", shown.response.rect);
scenario::publish_rect("dialog:window", shown.response.rect);
return shown.inner;
}
let window = super::Container::new(ctx, super::Surface::Window)
@@ -1287,7 +1281,7 @@ impl<'a> Window<'a> {
result
});
if let Some(shown) = &shown {
solidcraft_ui_registry::publish_rect("dialog:window", shown.response.rect);
scenario::publish_rect("dialog:window", shown.response.rect);
}
shown.and_then(|r| r.inner).unwrap_or((false, super::Action::None))
}
@@ -1336,7 +1330,7 @@ fn page_list_sized<S>(
let nav = egui::Rect::from_min_size(body.min, vec2(width, body.height()));
ui.painter().rect_filled(nav, 0.0, t.dialog_footer);
t.secondary_vline(ui.painter(), nav.right(), nav.y_range(), t.dialog_rule);
solidcraft_ui_registry::publish_rect("dialog:pages", nav);
scenario::publish_rect("dialog:pages", nav);
*selected = (*selected).min(pages.len().saturating_sub(1));
let nav_inner = if sidebar_footer.is_some() {
egui::Rect::from_min_max(nav.min + vec2(t.space_8, t.space_6), nav.max - vec2(t.space_8, t.space_10))
@@ -1395,7 +1389,7 @@ fn page_list_sized<S>(
if response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
solidcraft_ui_registry::publish_target(page.handle, rect);
scenario::publish_target(page.handle, rect);
response.backing_on_hover_text(label);
}
});
@@ -1406,7 +1400,7 @@ fn page_list_sized<S>(
ui.set_clip_rect(ui.clip_rect().intersect(rect));
footer(ui, state);
});
solidcraft_ui_registry::publish_rect("dialog:pages_footer", rect);
scenario::publish_rect("dialog:pages_footer", rect);
}
let content = egui::Rect::from_min_max(egui::pos2(nav.right(), body.top()), body.max);
let padding = egui::epaint::MarginF32 {
@@ -1427,14 +1421,14 @@ fn page_list_sized<S>(
if let Some(header) = header {
header(ui, state);
}
let force_scroll = search_scroll && super::settings_search::force_scroll(ui.ctx());
let force_scroll = search_scroll && crate::prefs_search::force_scroll(ui.ctx());
if let Some(page) = pages.get(*selected) {
if page.scroll || force_scroll {
let scroll = super::settings_scroll(ui, ("sc_prefs_page", *selected), |ui| (page.draw)(ui, state));
solidcraft_ui_registry::publish_rect("dialog:page_body", scroll.inner_rect);
scenario::publish_rect("dialog:page_body", scroll.inner_rect);
} else {
(page.draw)(ui, state);
solidcraft_ui_registry::publish_rect("dialog:page_body", content);
scenario::publish_rect("dialog:page_body", content);
}
}
},

Some files were not shown because too many files have changed in this diff Show More