diff --git a/crates/ui-egui/src/modelbuilder.rs b/crates/ui-egui/src/modelbuilder.rs index 6fa73b6ac..507200f77 100644 --- a/crates/ui-egui/src/modelbuilder.rs +++ b/crates/ui-egui/src/modelbuilder.rs @@ -1321,10 +1321,7 @@ fn input_settings(app: &mut RustGisApp, ui: &mut Ui, i: usize, n: &ModelNode) { /// The layer feeding a tool's first layer parameter, when it is an input node's (for its fields). fn source_layer(app: &RustGisApp, n: &ModelNode, spec: &ToolSpec) -> Option { - let first = spec - .params - .iter() - .find(|p| matches!(p.kind, ParamKind::Layer | ParamKind::PolygonLayer | ParamKind::PointLayer | ParamKind::VectorLayer))?; + let first = crate::panes::first_layer(spec)?; match app.mb.graph.edges.iter().find(|e| e.to == n.id && e.to_port == first.name) { Some(e) => app.mb.graph.nodes.iter().find(|u| u.id == e.from && u.kind == ModelNodeKind::Input).and_then(|u| u.layer.clone()), None => n.params.get(first.name).and_then(Value::as_str).map(str::to_string), diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index f9d925844..c8d40524c 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -2784,6 +2784,51 @@ fn geolibre_web_card(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }); } +/// Whether a tool's parameter of `kind` takes layer `l`, as the tool's form and the Script pane +/// offer layers: by its geometry, or raster or not; any for a `Layer`. False for a kind that isn't +/// a layer's. +pub(crate) fn takes_layer(kind: ParamKind, l: &rustgis_engine::map::Layer) -> bool { + match kind { + ParamKind::Layer => true, + ParamKind::PolygonLayer => l.geometry_type() == GeometryType::Polygon, + ParamKind::PointLayer => l.geometry_type() == GeometryType::Point, + ParamKind::RasterLayer | ParamKind::RasterLayers => l.is_raster(), + ParamKind::VectorLayer | ParamKind::VectorLayers => !l.is_raster(), + _ => false, + } +} + +/// Whether a tool's parameter of `kind` takes field `i` of `d`: a numeric one for a +/// `NumericField`, one holding coordinates for a `CoordinateField`, any for a `Field`. False for +/// a kind that isn't a field's. +pub(crate) fn takes_field(kind: ParamKind, d: &rustgis_engine::data::Dataset, i: usize) -> bool { + match kind { + ParamKind::Field => true, + ParamKind::NumericField => d.fields.get(i).is_some_and(|f| f.ty.is_numeric()), + ParamKind::CoordinateField => rustgis_engine::analysis::holds_coordinates(d, i), + _ => false, + } +} + +/// The tool's first layer parameter, whose fields its field parameters take. +pub(crate) fn first_layer(spec: &rustgis_engine::analysis::ToolSpec) -> Option<&rustgis_engine::analysis::ParamSpec> { + spec.params + .iter() + .find(|p| matches!(p.kind, ParamKind::Layer | ParamKind::PolygonLayer | ParamKind::PointLayer | ParamKind::VectorLayer)) +} + +/// The layer or table of name `name`, as a tool finds its input: a layer by its name, else a +/// table (by its name, else its id). +pub(crate) fn input_dataset<'a>(session: &'a Session, name: &str) -> Option<&'a rustgis_engine::data::Dataset> { + session.map().layer_by_name(name).map(|l| &l.dataset).or_else(|| input_table(session, name).map(|t| &t.dataset)) +} + +/// The standalone table of name `name`, as a tool finds one: by its name, else its id. +pub(crate) fn input_table<'a>(session: &'a Session, name: &str) -> Option<&'a rustgis_engine::project::Table> { + let tables = &session.project.tables; + rustgis_engine::project::find_table(tables, name).and_then(|i| tables.get(i)) +} + fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let Some(spec) = app.gp.tool.and_then(rustgis_engine::analysis::tool) else { // Tool finder. @@ -2963,18 +3008,11 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.add_space(4.0); // A standalone table is picked by its id, since names can repeat, and shown by its name. let tables: Vec<(u32, String)> = app.session.project.tables.iter().map(|t| (t.id, t.name.clone())).collect(); - let layer_names = |filter: Option| -> Vec { - app.session.map().layers.iter().filter(|l| filter.is_none_or(|g| l.geometry_type() == g)).map(|l| l.name.clone()).collect() - }; - let kind_names = |raster: bool| -> Vec { - app.session.map().layers.iter().filter(|l| l.is_raster() == raster).map(|l| l.name.clone()).collect() + let layer_names = |kind: ParamKind| -> Vec { + app.session.map().layers.iter().filter(|l| takes_layer(kind, l)).map(|l| l.name.clone()).collect() }; let plugin_tool = spec.id.starts_with(rustgis_engine::plugins::PREFIX); - let first_layer = spec - .params - .iter() - .find(|p| matches!(p.kind, ParamKind::Layer | ParamKind::PolygonLayer | ParamKind::PointLayer | ParamKind::VectorLayer)) - .map(|p| p.name); + let first_layer = first_layer(spec).map(|p| p.name); // The file parameter whose Browse was pressed: picked once the form is drawn. let mut browse_for = None; for p in spec.params { @@ -2993,7 +3031,7 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { .selected_text(shown) .width(ui.available_width() - 4.0) .show_ui(ui, |ui| { - for name in layer_names(None) { + for name in layer_names(ParamKind::Layer) { if ui.selectable_label(picked.is_none() && name == cur, &name).clicked() { app.gp.values.insert(p.name, name); app.gp.tables.remove(p.name); @@ -3011,26 +3049,17 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.add_space(4.0); continue; } - ParamKind::PolygonLayer => Some(layer_names(Some(GeometryType::Polygon))), - ParamKind::PointLayer => Some(layer_names(Some(GeometryType::Point))), + ParamKind::PolygonLayer | ParamKind::PointLayer => Some(layer_names(p.kind)), ParamKind::Field | ParamKind::NumericField | ParamKind::CoordinateField => { let lname = first_layer.and_then(|n| app.gp.values.get(n)).cloned().unwrap_or_default(); let table = first_layer.and_then(|n| app.gp.tables.get(n)).copied(); // The table picked; else the layer or table of that name, as the tool finds it. let ds = match table { Some(id) => app.session.project.table(id).map(|t| &t.dataset), - None => app.session.map().layer_by_name(&lname).map(|l| &l.dataset).or_else(|| { - let tables = &app.session.project.tables; - rustgis_engine::project::find_table(tables, &lname).and_then(|i| tables.get(i)).map(|t| &t.dataset) - }), - }; - let offered = |d: &rustgis_engine::data::Dataset, i: usize, f: &rustgis_engine::data::Field| match p.kind { - ParamKind::NumericField => f.ty.is_numeric(), - ParamKind::CoordinateField => rustgis_engine::analysis::holds_coordinates(d, i), - _ => true, + None => input_dataset(&app.session, &lname), }; let fields = |d: &rustgis_engine::data::Dataset| -> Vec { - d.fields.iter().enumerate().filter(|(i, f)| offered(d, *i, f)).map(|(_, f)| f.name.clone()).collect() + d.fields.iter().enumerate().filter(|(i, _)| takes_field(p.kind, d, *i)).map(|(_, f)| f.name.clone()).collect() }; // Telling which fields hold coordinates reads their rows, too much for every frame: // the list is kept until the project or the input changes. @@ -3063,15 +3092,14 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } ParamKind::Choice(c) => Some(c.iter().map(|s| s.to_string()).collect()), ParamKind::RasterLayer | ParamKind::VectorLayer => { - let mut v = kind_names(p.kind == ParamKind::RasterLayer); + let mut v = layer_names(p.kind); if !p.required { v.insert(0, String::new()); } Some(v) } ParamKind::RasterLayers | ParamKind::VectorLayers => { - let raster = p.kind == ParamKind::RasterLayers; - let offered = app.session.map().layers.iter().filter(|l| l.is_raster() == raster).map(|l| l.name.clone()).collect(); + let offered = layer_names(p.kind); layer_list(&mut app.gp, ui, p.name, &cur, offered); ui.add_space(4.0); continue; diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 84f485c8b..accf7261b 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -5,9 +5,10 @@ //! its `{…}` and in its second-level objects (`key_at`), and the ids some values take: tools for //! a `gp.run` line's `"tool"`, commands for a `task.addStep` line's `"command"` (`value_at`), //! whose own keys then go in its `"params": {…}`, and what the project holds: layers for a -//! `"layer"`, the line's layer's fields for a `"field"`. Typing aids beside it: a command's -//! parameters added on accept (`template`), and braces, brackets and quotes that close themselves -//! (`brace`). +//! `"layer"`, the line's layer's fields for a `"field"`, and in a `gp.run` line's `"params"` what +//! the tool's parameter takes by its kind (`tool_value`), as the tool's form offers it. Typing +//! aids beside it: a command's parameters added on accept (`template`), and braces, brackets and +//! quotes that close themselves (`brace`). //! Quoted text is read by one rule everywhere (`rustgis_engine::Quotes`), the one the colours use. use egui::text::{CCursor, CCursorRange}; @@ -37,8 +38,10 @@ pub struct Offer { /// its id), the keys of the second-level object of a command's entry `key`, a geoprocessing /// tool's parameter keys (by its id) in a `gp.run` line's `"params"`, or the ids a value takes: /// the tools for a `gp.run` line's `"tool"`, the commands for a `task.addStep` line's -/// `"command"`; or what the project holds for a value: the map's layers for a `"layer"`, the -/// fields of a layer (by its id) for a `"field"`. +/// `"command"`; or what the project holds for a value: the map's layers for a `"layer"` (and the +/// tables, for a tool's parameter that takes any layer), the fields of a layer (by its id) for a +/// `"field"`, the fields of a tool's input (by the name its line gives) for a tool's field +/// parameter, a tool's parameter's choices. #[derive(Clone, Debug, PartialEq)] pub enum Of { CommandKeys(String), @@ -48,12 +51,14 @@ pub enum Of { CommandIds, Layers, Fields(u32), + ToolFields(String), + Choices, } impl Of { /// Whether its items are a value's (written as a JSON string's text), not keys. fn is_value(&self) -> bool { - matches!(self, Of::ToolIds | Of::CommandIds | Of::Layers | Of::Fields(_)) + matches!(self, Of::ToolIds | Of::CommandIds | Of::Layers | Of::Fields(_) | Of::ToolFields(_) | Of::Choices) } } @@ -426,8 +431,8 @@ fn line_layer<'a>(session: &'a rustgis_engine::Session, line: &str) -> Option<&' /// What to offer at char `at` of `script`: the command ids for a line's first word, the keys of /// the line's command for a key being typed in its `{…}`, and in its second-level objects the /// keys their entries describe; in a `gp.run` line the tool ids for its `"tool"` and the tool's -/// keys in its `"params"`, in a `task.addStep` line the command ids for its `"command"` and that -/// command's keys in its `"params"`. +/// keys and values (`tool_value`) in its `"params"`, in a `task.addStep` line the command ids for +/// its `"command"` and that command's keys in its `"params"`. fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::Session) -> Option { if let Some((start, word)) = word_at(script, at) { return complete(start, at, &word, ids); @@ -464,6 +469,9 @@ fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::S _ => None, }; } + if let Some(offer) = tool_value(script, at, session) { + return Some(offer); + } let KeyAt { hint: h, start, word, nested } = key_at(script, at)?; let after = object_rest(script.get(byte_at(script, at)?..)?.split('\n').next()?); // The object the key goes in, from its `{` to its `}`, the word being typed taken out. @@ -486,6 +494,52 @@ fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::S offer_keys(Of::SubKeys { id: h.id, key: nested.key }, entries, &object, start, at, &word) } +/// What a `gp.run` line's tool takes for the value being typed in its `"params"` (#788), by the +/// kind of the tool's parameter of that key, as the tool's form offers it (`takes_layer`, +/// `takes_field`): the layers of its kind, then the tables for one that takes any layer; the +/// fields it takes of the layer or table the tool's first layer parameter names (before or after +/// it); or its choices. None for another kind: a number, a bool, a path, a text, several layers in +/// one string. +fn tool_value(script: &str, at: usize, session: &rustgis_engine::Session) -> Option { + use crate::panes::{takes_field, takes_layer}; + use rustgis_engine::analysis::ParamKind as K; + let (h, Scan { nested, .. }, _) = scan_at(script, at)?; + let brace = h.brace + nested.filter(|n| n.key == "params")?.brace; + let (Named::Tool, name) = named_in(script, &h)? else { + return None; + }; + let tool = rustgis_engine::analysis::tool(&name)?; + // The `"params"` object, read as a line's object from its `{`. + let text = script.get(byte_at(script, brace)?..byte_at(script, at)?)?; + let (start, key) = scan(text).value?; + let word = word_from(text, start)?; + let start = brace + start; + let p = tool.params.iter().find(|p| p.name == key)?; + let map = session.map(); + match p.kind { + K::Layer | K::PolygonLayer | K::PointLayer | K::RasterLayer | K::VectorLayer => { + // Those Run finds by their name, as for a `"layer"`; then the tables no layer's name + // hides, as the form lists them, of two named alike the one Run finds. + let layers = map.layers.iter().filter(|l| map.layer_by_name(&l.name).is_some_and(|f| f.id == l.id) && takes_layer(p.kind, l)); + let all = &session.project.tables; + let tables = all.iter().enumerate().filter(|(i, t)| { + p.kind == K::Layer && map.layer_by_name(&t.name).is_none() && rustgis_engine::project::find_table(all, &t.name) == Some(*i) + }); + let names = layers.map(|l| l.name.as_str()).chain(tables.map(|(_, t)| t.name.as_str())); + value_ids(start, at, &word, names, Of::Layers) + } + K::Field | K::NumericField | K::CoordinateField => { + let after = object_rest(script.get(byte_at(script, at)?..)?.split('\n').next()?); + let input = value_of(&format!("{text}{after}"), crate::panes::first_layer(tool)?.name)?; + let d = crate::panes::input_dataset(session, &input)?; + let names = d.fields.iter().enumerate().filter(|(i, _)| takes_field(p.kind, d, *i)).map(|(_, f)| f.name.as_str()); + value_ids(start, at, &word, names, Of::ToolFields(input)) + } + K::Choice(choices) => value_ids(start, at, &word, choices.iter().copied(), Of::Choices), + _ => None, + } +} + /// What follows a cursor in a key (`after`, to the line's end) up to the `}` that closes the key's /// object, or all of it when nothing closes it: an object after it on the line (the next one in /// its array, a later key's) isn't read as holding its keys. @@ -521,10 +575,11 @@ fn sub_desc(desc: &str, key: &str) -> Option { } /// The ids of `ids` that complete `word`, typed from char `start` to `end`, case aside, each -/// once: the tools, commands, layers or fields a value takes (`of`), offered from its quote, so -/// not cut to `MAX_ITEMS` (the list scrolls). Commands sorted, as a line's first word offers -/// them; the rest in their order: tools with the built-in ones first (an external one with the -/// same id isn't the one Run finds), layers top first, fields as the layer's table has them. +/// once: the tools, commands, layers, tables, fields or choices a value takes (`of`), offered +/// from its quote, so not cut to `MAX_ITEMS` (the list scrolls). Commands sorted, as a line's +/// first word offers them; the rest in their order: tools with the built-in ones first (an +/// external one with the same id isn't the one Run finds), layers top first (then tables), fields +/// as the layer's table has them, choices as the tool lists them. fn value_ids<'a>(start: usize, end: usize, word: &str, ids: impl Iterator, of: Of) -> Option { let mut seen = std::collections::HashSet::new(); let mut items: Vec = ids.filter(|id| starts_with_ci(id, word) && seen.insert(*id)).map(str::to_string).collect(); @@ -536,9 +591,12 @@ fn value_ids<'a>(start: usize, end: usize, word: &str, ids: impl Iterator Option<(String, Option)> { let described = |desc: &str| entries(desc).into_iter().find(|(keys, _)| keys.iter().any(|k| k == item)).map(|(_, text)| (text, None)); + let field = |d: &rustgis_engine::data::Dataset| { + d.fields.iter().find(|f| f.name == item).map(|f| (tl!(f.ty.label()).to_string(), f.alias.clone())) + }; match of { Of::CommandKeys(id) => described(¶ms_of(session, id)?), Of::SubKeys { id, key: entry } => described(&sub_desc(¶ms_of(session, id)?, entry)?), @@ -547,7 +605,11 @@ fn item_help(session: &rustgis_engine::Session, of: &Of, item: &str) -> Option<( Some((param_line(p), (!p.help.is_empty()).then(|| tl!(p.help).to_string()))) } Of::Layers => { - let l = session.map().layer_by_name(item)?; + let Some(l) = session.map().layer_by_name(item) else { + // A table, after the layers (a tool's parameter that takes any layer). + let t = crate::panes::input_table(session, item)?; + return Some((crate::i18n::fmt("Table · {n} rows", &[("n", &t.dataset.features.len().to_string())]), None)); + }; if l.is_raster() { return Some((tl!("Raster").to_string(), None)); } @@ -561,11 +623,9 @@ fn item_help(session: &rustgis_engine::Session, of: &Of, item: &str) -> Option<( ); Some((text, None)) } - Of::Fields(id) => { - let f = session.map().layer(*id)?.dataset.fields.iter().find(|f| f.name == item)?; - Some((tl!(f.ty.label()).to_string(), f.alias.clone())) - } - Of::ToolIds | Of::CommandIds => None, + Of::Fields(id) => field(&session.map().layer(*id)?.dataset), + Of::ToolFields(input) => field(crate::panes::input_dataset(session, input)?), + Of::ToolIds | Of::CommandIds | Of::Choices => None, } } @@ -575,7 +635,9 @@ fn item_icon(session: &rustgis_engine::Session, of: Option<&Of>, item: &str) -> use rustgis_engine::data::GeometryType; match of? { Of::Layers => { - let l = session.map().layer_by_name(item)?; + let Some(l) = session.map().layer_by_name(item) else { + return crate::panes::input_table(session, item).map(|_| "table"); + }; Some(match l.geometry_type() { _ if l.is_raster() => "raster_layer", GeometryType::Point => "point_layer", @@ -583,7 +645,7 @@ fn item_icon(session: &rustgis_engine::Session, of: Option<&Of>, item: &str) -> _ => "polygon_layer", }) } - Of::Fields(_) => Some("field"), + Of::Fields(_) | Of::ToolFields(_) => Some("field"), _ => None, } } @@ -2361,7 +2423,7 @@ mod tests { assert_eq!(offer(&app, r#"labels.field {"field": ""#).unwrap().items, fields, "the active layer"); app.session.active_layer = None; assert!(offer(&app, r#"labels.field {"field": ""#).is_none(), "no active layer"); - assert!(offer(&app, r#"gp.run {"params": {"layer": ""#).is_none(), "a second-level value (#662's 3b)"); + assert!(offer(&app, r#"gp.run {"params": {"layer": ""#).is_none(), "in params, without a tool"); // Two layers named alike but for case: the first, the one Run finds by either name. let other = app.session.map().layers.iter().position(|l| l.name != "Cities").unwrap(); app.session.map_mut().layers[other].name = "CITIES".into(); @@ -2389,6 +2451,87 @@ mod tests { assert_eq!(item_icon(s, Some(&Of::ToolIds), "buffer"), None); } + /// In a `gp.run` line's `"params"`, a value offers what the tool's parameter of its key takes, + /// by its kind, as the tool's form offers it (#788): the layers of a polygon parameter, every + /// layer then the tables for any layer, the numeric fields of the input for a numeric field (the + /// input before or after it, a table's too), a choice's choices in order; nothing for a + /// number, nor in another command's `"params"`. + #[test] + fn a_tools_values_are_offered_by_kind() { + use rustgis_engine::data::{Dataset, Field, FieldType, GeometryType}; + let mut app = crate::RustGisApp::with_sample(); + let offer = |app: &crate::RustGisApp, s: &str| super::offer_at(s, s.chars().count(), &[], &app.session); + let names = |app: &crate::RustGisApp, keep: fn(&rustgis_engine::map::Layer) -> bool| -> Vec { + app.session.map().layers.iter().filter(|l| keep(l)).map(|l| l.name.clone()).collect() + }; + let polygons = names(&app, |l| l.geometry_type() == GeometryType::Polygon); + assert!(polygons.iter().any(|n| n == "Countries") && !polygons.iter().any(|n| n == "Cities")); + let o = offer(&app, r#"gp.run {"tool": "dissolve", "params": {"input": ""#).unwrap(); + assert_eq!((o.items, o.of), (polygons, Some(super::Of::Layers)), "a polygon layer"); + // A table after the layers; one named as a layer isn't the one Run finds, so not offered. + app.session.project.add_table(Dataset::new("Stats", GeometryType::None, vec![Field::new("total", FieldType::Integer)])); + app.session.project.add_table(Dataset::new("Cities", GeometryType::None, vec![])); + let mut any = names(&app, |_| true); + any.push("Stats".into()); + assert_eq!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"input": ""#).unwrap().items, any, "any layer, then the tables"); + assert_eq!( + offer(&app, r#"gp.run {"tool": "Buffer", "params": {"input": "st"#).unwrap().items, + ["Stats"], + "the tool by name, case aside" + ); + let cities = app.session.map().layer_by_name("Cities").unwrap(); + let fields: Vec = cities.dataset.fields.iter().map(|f| f.name.clone()).collect(); + let numeric: Vec = cities.dataset.fields.iter().filter(|f| f.ty.is_numeric()).map(|f| f.name.clone()).collect(); + assert!(!numeric.is_empty() && numeric.len() < fields.len()); + let o = offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"input": "Cities", "field": ""#).unwrap(); + assert_eq!((o.items, o.of), (numeric.clone(), Some(super::Of::ToolFields("Cities".into()))), "a numeric field"); + let o = offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"input": "Cities", "case": ""#).unwrap(); + assert_eq!(o.items, fields, "any field"); + let s = r#"gp.run {"tool": "summaryStatistics", "params": {"field": ""#; + let o = super::offer_at(&format!(r#"{s}", "input": "Cities"}}}}"#), s.chars().count(), &[], &app.session).unwrap(); + assert_eq!(o.items, numeric, "the input after the field"); + let o = offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"input": "Stats", "field": ""#).unwrap(); + assert_eq!(o.items, ["total"], "a table's fields"); + assert!(offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"field": ""#).is_none(), "no input"); + assert!(offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"input": "Nowhere", "field": ""#).is_none()); + let o = offer(&app, r#"gp.run {"tool": "buffer", "params": {"units": ""#).unwrap(); + assert_eq!((o.items, o.of), (["Kilometers", "Meters", "Miles", "Feet"].map(String::from).to_vec(), Some(super::Of::Choices))); + assert_eq!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"units": "mi"#).unwrap().items, ["Miles"], "case aside"); + assert!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"distance": ""#).is_none(), "a number"); + assert!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"nope": ""#).is_none(), "a key the tool doesn't take"); + assert!(offer(&app, r#"gp.run {"tool": "nope", "params": {"input": ""#).is_none(), "a tool that isn't there"); + assert!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"input": {"x": ""#).is_none(), "deeper"); + assert!(offer(&app, r#"task.addStep {"command": "buffer", "params": {"input": ""#).is_none(), "another command's params"); + } + + /// Beside a table, its rows and the table icon; beside a tool's field, its type and the field + /// icon; no help for a choice. + #[test] + fn a_tools_values_have_help_and_icons() { + use super::{Of, item_help, item_icon}; + use rustgis_engine::data::{Dataset, Field, FieldType, GeometryType}; + let mut app = crate::RustGisApp::with_sample(); + app.session.project.add_table(Dataset::new("Stats", GeometryType::None, vec![Field::new("total", FieldType::Integer)])); + let s = &app.session; + assert_eq!(item_help(s, &Of::Layers, "Stats").map(|h| h.0), Some("Table · 0 rows".into())); + assert_eq!(item_icon(s, Some(&Of::Layers), "Stats"), Some("table")); + let of = Of::ToolFields("Stats".into()); + assert_eq!(item_help(s, &of, "total").map(|h| h.0), Some(FieldType::Integer.label().to_string())); + assert_eq!(item_icon(s, Some(&of), "total"), Some("field")); + assert_eq!(item_help(s, &Of::Choices, "Miles"), None); + } + + /// In the pane: accepting a choice closes it, the cursor after its quote. + #[test] + fn a_tools_choice_is_accepted() { + let mut h = pane(); + type_in(&mut h, r#"gp.run {"tool": "buffer", "params": {"units": "Mi"#); + h.key_press(egui::Key::Enter); + h.run(); + type_in(&mut h, "}"); + assert_eq!(h.state().workflow.script, r#"gp.run {"tool": "buffer", "params": {"units": "Miles"}"#); + } + /// In the pane: accepting a layer closes its name, written as a JSON string holds it. #[test] fn a_layer_is_accepted_as_json_writes_it() { diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 04e0c314c..2ff923ad8 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -296,7 +296,11 @@ help; there the help by the cursor is the tool's. A `task.addStep` line works th `"command": "` offers the commands, and its `"params"` that command's keys and help. For a command that takes them, `"layer": "` offers the map's layers, top first, each with its kind's icon and beside it what it holds; `"field": "` offers the fields of the layer the line names -(before or after it), or of the active layer when it names none, with each field's type beside it. Accepting one writes its name +(before or after it), or of the active layer when it names none, with each field's type beside it. +In a `gp.run` line's `"params"`, a value offers what the tool's parameter takes, as the tool's +form does: the layers of its kind (polygon, point, raster, vector; any layer, then the tables), +the fields of the tool's input that it takes (numeric, holding coordinates), or its choices +(`"units": "` offers `Kilometers`, `Meters`, …). Accepting one writes its name as the line's JSON needs it (`"Big \"lakes\""`). A `{` or `[` typed closes itself, the cursor inside, unless text follows it or it's in a string or a comment; so does a `"` where a string can start (after a space, `{`, `[`, `,` or `:`). A `}` or `]` typed