ui: jobs with progress, refusals listed, datum preview, form filters and defaults

- A solve or a mesher runs as a job with one progress mechanism: the overlay
  shows the step, stage, count and elapsed time with a bar when bounded, OK
  waits while it runs, and the status sums it up (elements, smallest angle,
  time) (#322, #357).
- Over the control channel such a command is a job too: answered when it
  ends however long it runs, or at once with wait: false, then job.status,
  job.wait, job.cancel; ui.inspect gives job; MCP waits for it (#367).
- A command that cannot run yet lists what it misses in the context panel,
  each with a button to the command that provides it (#323).
- A cancelled or refused solve stays in its workspace (#324, test).
- A mesher form starts from the parameters of the mesh it would replace (#343).
- Seed Edges and edge/vertex/face datum points switch the selection filter
  while open and restore it (#341).
- Datum forms draw the datum live, the Partition form shows its datum, and
  new datums and partitions get the next free name (#347).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 09:14:34 -04:00
1 parent 3eaca26246
commit 4ee855bb27
11 files changed
+447 -28

No files matched your search

+48 -5
View File
@@ -17,7 +17,12 @@ pub const WIDTH: f32 = 310.0;
/// Whether the panel has anything to show.
pub fn has_context(app: &mut ModelApp) -> bool {
app.dialog.is_some() || app.selected.is_some() || app.selected_seed.is_some() || app.reply.is_some() || app.plot().is_some()
app.dialog.is_some()
|| app.refused.is_some()
|| app.selected.is_some()
|| app.selected_seed.is_some()
|| app.reply.is_some()
|| app.plot().is_some()
}
pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
@@ -37,6 +42,10 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
form(app, ui, &t);
ui.add_space(10.0);
}
if app.refused.is_some() {
refused(app, ui, &t);
ui.add_space(10.0);
}
if let Some((kind, name)) = app.selected.clone() {
properties(app, ui, &t, kind, &name);
ui.add_space(10.0);
@@ -67,6 +76,35 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
});
}
/// A command that cannot run yet: what it misses, each with a button that starts the command
/// that provides it (as the browser's ghost rows do).
fn refused(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
let Some(u) = app.refused.clone() else { return };
let mut close = false;
heading(ui, t, Level::Title, |ui| {
ui.label(RichText::new(format!("{} — NOT YET", crate::command_label(u.command).to_uppercase())).font(label_font(ui.ctx(), 12.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = ui.small_button("×").on_hover_text("Dismiss").clicked();
});
});
ui.add_space(4.0);
ui.label(RichText::new("It needs:").color(t.text_dim));
let mut start = None;
for fieldcraft_engine::requirement::Missing(r) in &u.missing {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(format!("• {}", r.what())).color(t.text));
if ui.button(format!("{}…", r.place())).on_hover_text(r.provider()).clicked() {
start = Some(r.provider());
}
});
}
if close {
app.refused = None;
} else if let Some(command) = start {
let _ = app.start(command);
}
}
/// JSON shorter than this is shown pretty-printed in the JSON view.
const MAX_PRETTY: usize = 100_000;
@@ -76,6 +114,8 @@ const MAX_PRETTY: usize = 100_000;
/// coupling) has a Target row that takes the viewport's selection.
fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
let selected = (!app.pick.selection.is_empty()).then(|| app.pick.selection.summary());
// While a job runs (this form's mesher, a solve) OK waits for it.
let running = app.busy().map(str::to_string);
let units = app.session.model.units();
let session = &app.session;
// The set Use selection will make on OK is offered with the others.
@@ -191,9 +231,12 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
ui.label(RichText::new(spec.params).small().color(t.text_dim));
});
ui.add_space(4.0);
let ready = invalid == 0 && missing.is_empty();
let not_ready =
|| if invalid > 0 { format!("{missing}\nA field holds something that is not a value").trim().to_string() } else { missing.clone() };
let ready = invalid == 0 && missing.is_empty() && running.is_none();
let not_ready = || match &running {
Some(job) => format!("{job} is running: wait for it or cancel it"),
None if invalid > 0 => format!("{missing}\nA field holds something that is not a value").trim().to_string(),
None => missing.clone(),
};
let mut attempted = false;
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -201,7 +244,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
let ok =
ui.add_enabled(ready, egui::Button::new(RichText::new("OK").color(egui::Color32::WHITE)).fill(t.accent).min_size(vec2(64.0, 0.0)));
// A click on the greyed OK shows every problem, beside its field.
attempted = !ready && ok.contains_pointer() && ui.input(|i| i.pointer.primary_clicked());
attempted = !ready && running.is_none() && ok.contains_pointer() && ui.input(|i| i.pointer.primary_clicked());
run = ok
.on_disabled_hover_ui(|ui| {
ui.label(not_ready());