From 68a98198b02387b029599b86904db6d555ac46a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 7 Oct 2026 02:12:06 -0400 Subject: [PATCH] =?UTF-8?q?Panics=20=C2=B7=20P25=20Generative:=20gone=20ch?= =?UTF-8?q?oices=20mark=20none,=20overruns=20peg;=20seed=20fields=20hold?= =?UTF-8?q?=20seeds=20through=20fits,=20undo=20and=20input=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/forms/text/mod.rs | 20 ++- src/generative/compare.rs | 16 +- src/generative/embed.rs | 22 ++- src/generative/mask.rs | 7 +- src/generative/models.rs | 7 +- src/generative/ratio.rs | 13 +- src/generative/seed.rs | 25 ++- src/generative/style.rs | 7 +- src/generative/tests/mod.rs | 1 + src/generative/tests/models.rs | 8 +- src/generative/tests/stale.rs | 270 +++++++++++++++++++++++++++++++++ src/generative/tuning.rs | 9 +- src/generative/variation.rs | 106 +++++++------ src/generative/versions.rs | 39 +++-- src/generative/video.rs | 8 +- src/generative/voice.rs | 13 +- tasks/panics.md | 3 +- 17 files changed, 451 insertions(+), 123 deletions(-) create mode 100644 src/generative/tests/stale.rs diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs index 4062a98..5b11379 100644 --- a/src/forms/text/mod.rs +++ b/src/forms/text/mod.rs @@ -180,6 +180,13 @@ impl TextInput { &self.text } + /// The text as committed: while an input method composes, the text before it began. + pub(crate) fn committed(&self) -> &str { + self.composing + .as_ref() + .map_or(&self.text, |start| &start.text) + } + pub fn is_empty(&self) -> bool { self.text.is_empty() } @@ -245,7 +252,10 @@ impl TextInput { pub fn set_text(&mut self, text: impl Into, cx: &mut Context) { let text = self.admit(&text.into(), 0); let text = match &self.fit { - Some(fit) => fit(&self.text, 0..self.text.len(), &text).0, + Some(fit) => { + let base = self.committed(); + fit(base, 0..base.len(), &text).0 + } None => text, }; self.selection = text.len()..text.len(); @@ -256,9 +266,10 @@ impl TextInput { cx.notify(); } - /// Reshapes every edit, as a mask does: text, replaced range and typed text in; text and caret out. + /// Reshapes every edit, as a mask does: text, replaced range and typed text in; text and caret out. Undo starts afresh, since earlier text need not fit. pub(crate) fn set_fit(&mut self, fit: Fit) { self.fit = Some(fit); + self.history = History::default(); } /// Replaces the selection, as typing would. An undo step. @@ -353,11 +364,12 @@ impl TextInput { } } + /// The state as committed, so undo never keeps an input method's text before it commits. fn snapshot(&self) -> Snapshot { - Snapshot { + self.composing.clone().unwrap_or_else(|| Snapshot { text: self.text.clone(), selection: self.selection.clone(), - } + }) } pub(crate) fn restore(&mut self, snapshot: Snapshot, cx: &mut Context) { diff --git a/src/generative/compare.rs b/src/generative/compare.rs index 3d3dd98..870c00f 100644 --- a/src/generative/compare.rs +++ b/src/generative/compare.rs @@ -80,12 +80,16 @@ impl RenderOnce for ABCompareView { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let count = self.sides.len(); assert!((2..=4).contains(&count), "a comparison of {count} sides"); - if let Some(Verdict::Side(ix)) = self.verdict { - assert!(ix < count, "verdict for side {ix} of {count}"); - } + let verdict = match self.verdict { + Some(Verdict::Side(ix)) if ix >= count => { + log::error!("comparison: verdict for side {ix} of {count}; none given"); + None + } + verdict => verdict, + }; let theme = cx.theme(); let colors = theme.colors.clone(); - let decided = self.verdict.is_some(); + let decided = verdict.is_some(); let judge = |verdict: Verdict, label: SharedString, key: String| { self.on_verdict.clone().map(|judge| { Button::new((self.id.clone(), key), label) @@ -103,7 +107,7 @@ impl RenderOnce for ABCompareView { .into_iter() .enumerate() .map(|(ix, (name, answer))| { - let won = self.verdict == Some(Verdict::Side(ix)); + let won = verdict == Some(Verdict::Side(ix)); let title = heading(ix, &name, self.blind, decided); titles.push(title.clone()); div() @@ -147,7 +151,7 @@ impl RenderOnce for ABCompareView { ) }) .collect(); - let verdicts = match self.verdict { + let verdicts = match verdict { Some(Verdict::Tie) => vec![div().child("Called a tie").into_any_element()], Some(Verdict::Side(_)) => Vec::new(), None => titles diff --git a/src/generative/embed.rs b/src/generative/embed.rs index 585752e..fb6eb54 100644 --- a/src/generative/embed.rs +++ b/src/generative/embed.rs @@ -63,14 +63,22 @@ impl EmbeddingVisualizer { for (ix, name) in groups.iter().enumerate() { assert!(!groups[..ix].contains(name), "group {name} named twice"); } + let points: Vec = points + .into_iter() + .filter(|point| { + let held = point.group < groups.len(); + if !held { + log::error!( + "embedding: point {} in group {} of {}; left out", + point.key, + point.group, + groups.len() + ); + } + held + }) + .collect(); for point in &points { - assert!( - point.group < groups.len(), - "point {} in group {} of {}", - point.key, - point.group, - groups.len() - ); assert!( point.at.iter().all(|axis| (-1.0..=1.0).contains(axis)), "point {} sits outside -1 to 1", diff --git a/src/generative/mask.rs b/src/generative/mask.rs index 59408f9..7a99b0b 100644 --- a/src/generative/mask.rs +++ b/src/generative/mask.rs @@ -225,13 +225,16 @@ fn shaped( } } -/// `at` as shares of `bounds`. +/// `at` as shares of `bounds`, kept on the picture as the pen keeps its points in the pad; a canvas that shrank mid-stroke ends the stroke at its edge. fn share(bounds: Bounds, at: Point) -> (f32, f32) { let (width, height) = ( f32::from(bounds.size.width).max(1.0), f32::from(bounds.size.height).max(1.0), ); - (f32::from(at.x) / width, f32::from(at.y) / height) + ( + (f32::from(at.x) / width).clamp(0.0, 1.0), + (f32::from(at.y) / height).clamp(0.0, 1.0), + ) } impl RenderOnce for InpaintCanvas { diff --git a/src/generative/models.rs b/src/generative/models.rs index a535c87..aae1bf3 100644 --- a/src/generative/models.rs +++ b/src/generative/models.rs @@ -203,7 +203,10 @@ impl ModelStatus { /// GPU memory it holds and the card's whole, in bytes. pub fn memory(mut self, used: u64, total: u64) -> Self { - assert!(total > 0 && used <= total, "GPU memory {used} of {total}"); + assert!(total > 0, "GPU memory {used} of none"); + if used > total { + log::error!("model status: GPU memory {used} past {total}; the meter fills"); + } self.memory = Some((used, total)); self } @@ -247,7 +250,7 @@ impl RenderOnce for ModelStatus { Meter::new( (self.id.clone(), "memory"), "GPU memory", - used as f32 / total as f32, + used.min(total) as f32 / total as f32, ) .detail(format!( "{} of {}", diff --git a/src/generative/ratio.rs b/src/generative/ratio.rs index 6ba3c7b..576993d 100644 --- a/src/generative/ratio.rs +++ b/src/generative/ratio.rs @@ -38,12 +38,13 @@ impl AspectRatioPicker { selected: (u32, u32), ) -> Self { let ratios: Vec<(u32, u32)> = ratios.into_iter().collect(); - assert!( - ratios.contains(&selected), - "ratio {}:{} is not among the ratios", - selected.0, - selected.1 - ); + if !ratios.contains(&selected) { + log::error!( + "aspect ratio: {}:{} is not among the ratios; none selected", + selected.0, + selected.1 + ); + } Self { id: id.into(), ratios, diff --git a/src/generative/seed.rs b/src/generative/seed.rs index 3386ee0..0690419 100644 --- a/src/generative/seed.rs +++ b/src/generative/seed.rs @@ -4,7 +4,7 @@ use std::{ rc::Rc, }; -use gpui::{App, ElementId, Entity, IntoElement, RenderOnce, Window}; +use gpui::{App, ElementId, Entity, EntityId, IntoElement, RenderOnce, Window}; use crate::{ buttons::IconButton, @@ -43,22 +43,31 @@ impl SeedInput { } } - /// The seed in `field`; none while it is empty. + /// The seed in `field`, read after a `SeedInput` drew it; none while it is empty. pub fn read(field: &Entity, cx: &App) -> Option { - let text = field.read(cx).text(); + let text = field.read(cx).committed(); (!text.is_empty()).then(|| text.parse().expect("a seed field holds digits")) } } impl RenderOnce for SeedInput { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let fitted = window.use_keyed_state((self.id.clone(), "fitted"), cx, |_, _| false); - if !*fitted.read(cx) { - fitted.update(cx, |fitted, _| *fitted = true); + let fitted = + window.use_keyed_state((self.id.clone(), "fitted"), cx, |_, _| None::); + let field_id = self.field.entity_id(); + if *fitted.read(cx) != Some(field_id) { + fitted.update(cx, |fitted, _| *fitted = Some(field_id)); self.field.update(cx, |input, cx| { - input.set_fit(Rc::new(digits)); let text = input.text().to_string(); - input.set_text(text, cx); + let kept = match text.is_empty() || text.parse::().is_ok() { + true => text, + false => { + log::error!("seed input: {text:?} is no seed; cleared"); + String::new() + } + }; + input.set_fit(Rc::new(digits)); + input.set_text(kept, cx); input.set_placeholder("Random", cx); }); } diff --git a/src/generative/style.rs b/src/generative/style.rs index 74918be..555d13c 100644 --- a/src/generative/style.rs +++ b/src/generative/style.rs @@ -39,10 +39,9 @@ impl StylePresetPicker { ) -> Self { let (presets, selected): (Vec, SharedString) = (presets.into_iter().collect(), selected.into()); - assert!( - presets.iter().any(|preset| preset.key == selected), - "style {selected} is not among the presets" - ); + if !presets.iter().any(|preset| preset.key == selected) { + log::error!("style presets: {selected} is not among the presets; none selected"); + } Self { id: id.into(), presets, diff --git a/src/generative/tests/mod.rs b/src/generative/tests/mod.rs index df7319d..fb69d81 100644 --- a/src/generative/tests/mod.rs +++ b/src/generative/tests/mod.rs @@ -8,6 +8,7 @@ mod models; mod results; mod settings; mod sound; +mod stale; fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); diff --git a/src/generative/tests/models.rs b/src/generative/tests/models.rs index 62820fb..a64911e 100644 --- a/src/generative/tests/models.rs +++ b/src/generative/tests/models.rs @@ -5,7 +5,7 @@ use gpui::{Context, IntoElement, ParentElement, Render, Styled, TestAppContext, use super::{press, settle, setup, tab}; use crate::generative::{ Download, DownloadState, FineTuneJobCard, HardwareMonitor, ModelCard, ModelDownloadManager, - ModelState, ModelStatus, Reading, TunePhase, + Reading, TunePhase, }; type Heard = Rc>>; @@ -120,12 +120,6 @@ fn cards_offer_what_their_state_allows(cx: &mut TestAppContext) { ); } -#[test] -#[should_panic(expected = "GPU memory 5 of 4")] -fn memory_past_the_card_fails_loud() { - let _ = ModelStatus::new("model", "Orchid", ModelState::Ready).memory(5, 4); -} - #[test] #[should_panic(expected = "GPU reads outside 0 to 1")] fn a_reading_past_full_fails_loud() { diff --git a/src/generative/tests/stale.rs b/src/generative/tests/stale.rs new file mode 100644 index 0000000..f32228f --- /dev/null +++ b/src/generative/tests/stale.rs @@ -0,0 +1,270 @@ +use std::{cell::RefCell, rc::Rc, time::Duration}; + +use gpui::{ + AppContext as _, Context, Entity, EntityInputHandler as _, Focusable as _, IntoElement, + Modifiers, MouseButton, ParentElement, Render, Styled, TestAppContext, Window, div, point, px, +}; + +use super::{press, settle, setup, tab}; +use crate::forms::TextInput; +use crate::generative::{ + ABCompareView, AspectRatioPicker, Embedded, EmbeddingVisualizer, FineTuneJobCard, + InpaintCanvas, MaskStroke, ModelState, ModelStatus, Outcome, PromptVersionHistory, SeedInput, + Shot, StylePreset, StylePresetPicker, TTSVoicePicker, TunePhase, VariationPicker, Verdict, + VideoGenerationTimeline, Voice, +}; + +/// Each picker asked for what its list no longer holds, and counts past their whole. +struct Stale; + +impl Render for Stale { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let preset = StylePreset { + key: "ink".into(), + name: "Ink".into(), + picture: None, + }; + let voice = Voice { + key: "iris".into(), + name: "Iris".into(), + about: "a voice".into(), + tags: Vec::new(), + }; + let shot = Shot { + key: "a".into(), + prompt: "a pan".into(), + length: Duration::from_secs(4), + outcome: Outcome::Done("missing.jpg".into()), + }; + let point = |group| Embedded { + key: format!("in-{group}").into(), + label: "a point".into(), + group, + at: [0.0, 0.0, 0.0], + }; + div() + .w(px(640.0)) + .child( + ABCompareView::new("compare") + .side("Orchid", "one answer") + .side("Juniper", "another") + .verdict(Verdict::Side(5)), + ) + .child(AspectRatioPicker::new("ratios", [(1, 1), (4, 3)], (16, 9))) + .child(StylePresetPicker::new("styles", [preset], "gone")) + .child( + VideoGenerationTimeline::new("timeline", [shot], Duration::ZERO).selected("gone"), + ) + .child(TTSVoicePicker::new("voices", [voice]).selected("gone")) + .child(VariationPicker::new("variations", ["a.jpg"], 1.5, 4)) + .child(PromptVersionHistory::new("history", [], 0)) + .child(FineTuneJobCard::new( + "tune", + "Orchid tuned", + "Orchid", + "notes.jsonl", + TunePhase::Running(7, 3, 0.5), + )) + .child(ModelStatus::new("model", "Orchid", ModelState::Ready).memory(5, 4)) + .child(div().h(px(320.0)).child(EmbeddingVisualizer::new( + "space", + [point(0), point(9)], + ["Docs"], + ))) + } +} + +#[gpui::test] +fn gone_choices_mark_none_and_overruns_peg(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Stale); + settle(cx); +} + +/// A seed field the owner may swap for another. +struct Seeded(Entity); + +impl Render for Seeded { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().w(px(420.0)).child(SeedInput::new("seed", &self.0)) + } +} + +#[gpui::test] +fn a_seed_field_fits_whatever_it_held_and_each_new_field(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|window, cx| { + let field = cx.new(|cx| TextInput::new(window, cx)); + field.update(cx, |input, cx| { + input.set_text("4294967296", cx); + input.insert("7", cx); + }); + Seeded(field) + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + view.read_with(cx, |view, cx| { + assert_eq!(SeedInput::read(&view.0, cx), None) + }); + cx.update(|window, cx| { + let field = view.read(cx).0.clone(); + window.focus(&field.focus_handle(cx), cx); + }); + cx.simulate_keystrokes("secondary-z"); + settle(cx); + view.read_with(cx, |view, cx| { + assert_eq!( + SeedInput::read(&view.0, cx), + None, + "undo reaches no unfit text" + ) + }); + let field = cx.update(|window, cx| cx.new(|cx| TextInput::new(window, cx))); + view.update(cx, |view, cx| { + view.0 = field.clone(); + cx.notify(); + }); + settle(cx); + cx.update(|window, cx| window.focus(&field.focus_handle(cx), cx)); + cx.simulate_input("12a3"); + settle(cx); + view.read_with(cx, |view, cx| { + assert_eq!(SeedInput::read(&view.0, cx), Some(123)) + }); + cx.update(|window, cx| { + field.update(cx, |input, cx| { + input.replace_and_mark_text_in_range(None, "ni", None, window, cx) + }) + }); + settle(cx); + assert_eq!( + field.read_with(cx, |input, _| input.text().to_string()), + "123ni" + ); + view.read_with(cx, |view, cx| { + assert_eq!( + SeedInput::read(&view.0, cx), + Some(123), + "a composition is not yet the seed" + ) + }); + cx.simulate_keystrokes("secondary-z secondary-shift-z"); + settle(cx); + view.read_with(cx, |view, cx| { + assert_eq!( + SeedInput::read(&view.0, cx), + Some(123), + "redo brings back what was committed" + ) + }); + cx.update(|window, cx| { + field.update(cx, |input, cx| { + input.replace_and_mark_text_in_range(None, "ni", None, window, cx); + input.set_text("4294967296", cx); + }) + }); + settle(cx); + view.read_with(cx, |view, cx| { + assert_eq!( + SeedInput::read(&view.0, cx), + Some(123), + "a refused text keeps what was committed" + ) + }); +} + +type Heard = Rc>>; + +/// Variations whose chosen one may leave, and the actions asked. +struct Varied { + chosen: usize, + heard: Heard, +} + +impl Render for Varied { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let (vary, upscale) = (self.heard.clone(), self.heard.clone()); + div().w(px(420.0)).child( + VariationPicker::new("variations", ["a.jpg"], 1.5, self.chosen) + .on_vary(move |strong, _, _| vary.borrow_mut().push(format!("vary {strong}"))) + .on_upscale(move |_, _| upscale.borrow_mut().push("upscale".into())), + ) + } +} + +#[gpui::test] +fn no_picture_chosen_offers_no_way_on(cx: &mut TestAppContext) { + setup(cx); + let heard = Heard::default(); + let kept = heard.clone(); + let (view, cx) = cx.add_window_view(|_, _| Varied { + chosen: 0, + heard: kept, + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab(2, cx); + press("enter", cx); + assert_eq!(*heard.borrow(), ["vary false"]); + heard.borrow_mut().clear(); + view.update(cx, |view, cx| { + view.chosen = 4; + cx.notify(); + }); + settle(cx); + cx.update(|window, cx| window.blur(cx)); + for _ in 0..4 { + tab(1, cx); + press("enter", cx); + } + assert_eq!(*heard.borrow(), Vec::::new()); +} + +/// A canvas the owner narrows, keeping every stroke it hands over. +struct Painting { + width: f32, + strokes: Vec, +} + +impl Render for Painting { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let owner = cx.entity(); + div().w(px(self.width)).child( + InpaintCanvas::new("canvas", "missing.jpg", 2.0, self.strokes.clone()).on_stroke( + move |stroke, _, cx| { + owner.update(cx, |view, cx| { + view.strokes.push(stroke); + cx.notify(); + }) + }, + ), + ) + } +} + +#[gpui::test] +fn a_canvas_that_shrinks_under_the_pen_ends_the_stroke_on_it(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Painting { + width: 400.0, + strokes: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + let none = Modifiers::none(); + cx.simulate_mouse_down(point(px(300.0), px(100.0)), MouseButton::Left, none); + cx.simulate_mouse_move(point(px(320.0), px(100.0)), MouseButton::Left, none); + cx.simulate_mouse_move(point(px(340.0), px(100.0)), MouseButton::Left, none); + settle(cx); + view.update(cx, |view, cx| { + view.width = 200.0; + cx.notify(); + }); + settle(cx); + cx.simulate_mouse_up(point(px(160.0), px(50.0)), MouseButton::Left, none); + settle(cx); + let strokes = view.read_with(cx, |view, _| view.strokes.clone()); + assert_eq!(strokes.len(), 1); + let on = |share: f32| (0.0..=1.0).contains(&share); + assert!(strokes[0].points.iter().all(|&(x, y)| on(x) && on(y))); +} diff --git a/src/generative/tuning.rs b/src/generative/tuning.rs index 37796ee..2107e67 100644 --- a/src/generative/tuning.rs +++ b/src/generative/tuning.rs @@ -71,11 +71,16 @@ impl FineTuneJobCard { data: impl Into, phase: TunePhase, ) -> Self { - if let TunePhase::Running(epoch, epochs, share) = phase { + let mut phase = phase; + if let TunePhase::Running(epoch, epochs, share) = &mut phase { assert!( - (1..=epochs).contains(&epoch) && (0.0..=1.0).contains(&share), + *epochs > 0 && (0.0..=1.0).contains(share), "epoch {epoch} of {epochs} at {share}" ); + if !(1..=*epochs).contains(epoch) { + log::error!("fine-tuning: epoch {epoch} of {epochs}; pegged"); + *epoch = (*epoch).clamp(1, *epochs); + } } Self { id: id.into(), diff --git a/src/generative/variation.rs b/src/generative/variation.rs index 631c97d..9fbdc1a 100644 --- a/src/generative/variation.rs +++ b/src/generative/variation.rs @@ -21,7 +21,7 @@ pub struct VariationPicker { id: ElementId, pictures: Vec, ratio: f32, - chosen: usize, + chosen: Option, on_choose: Option, on_vary: Option, on_upscale: Option, @@ -36,16 +36,18 @@ impl VariationPicker { chosen: usize, ) -> Self { let pictures: Vec = pictures.into_iter().map(Into::into).collect(); - assert!( - chosen < pictures.len(), - "variation {chosen} of {}", - pictures.len() - ); + let held = chosen < pictures.len(); + if !held { + log::error!( + "variations: variation {chosen} of {}; none chosen", + pictures.len() + ); + } Self { id: id.into(), pictures, ratio: checked_ratio(ratio), - chosen, + chosen: held.then_some(chosen), on_choose: None, on_vary: None, on_upscale: None, @@ -89,45 +91,50 @@ impl RenderOnce for VariationPicker { let (large, small) = (theme.radius(Radius::Lg), theme.radius(Radius::Md)); let thumb = theme.avatar_size(AvatarSize::Xl); let length = motion::duration(motion::BASE, cx); - let picture = Image::new( - (self.id.clone(), format!("large-{}", self.chosen)), - source(&self.pictures[self.chosen]), - ) - .size_full() - .rounded(large); - let shown = match turn { - 0 => picture.into_any_element(), - _ => div() - .size_full() - .child(picture) - .with_animation( - (self.id.clone(), format!("fade-{turn}")), - Animation::new(length).with_easing(motion::ease_out_cubic), - |shown, t| shown.opacity(0.4 + 0.6 * t), - ) - .into_any_element(), - }; + let shown = self.chosen.map(|chosen| { + let picture = Image::new( + (self.id.clone(), format!("large-{chosen}")), + source(&self.pictures[chosen]), + ) + .size_full() + .rounded(large); + match turn { + 0 => picture.into_any_element(), + _ => div() + .size_full() + .child(picture) + .with_animation( + (self.id.clone(), format!("fade-{turn}")), + Animation::new(length).with_easing(motion::ease_out_cubic), + |shown, t| shown.opacity(0.4 + 0.6 * t), + ) + .into_any_element(), + } + }); let vary = |strong: bool, label: &'static str| { - self.on_vary.clone().map(|vary| { - Button::new((self.id.clone(), label), label) - .variant(ButtonVariant::Secondary) - .size(ControlSize::Sm) - .icon(IconName::Shuffle) - .on_click(move |_, window, cx| { - log::info!("variation picker: vary, strong {strong}"); - vary(strong, window, cx) - }) - }) + self.on_vary + .clone() + .filter(|_| self.chosen.is_some()) + .map(|vary| { + Button::new((self.id.clone(), label), label) + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .icon(IconName::Shuffle) + .on_click(move |_, window, cx| { + log::info!("variation picker: vary, strong {strong}"); + vary(strong, window, cx) + }) + }) }; div() .flex() .flex_col() .gap_3() - .child(framed(self.ratio, cx).rounded(large).child(shown)) + .child(framed(self.ratio, cx).rounded(large).children(shown)) .child(div().flex().flex_wrap().gap_2().children( self.pictures.iter().zip(thumbs).enumerate().map( |(ix, (picture, (focus, focused)))| { - let chosen = ix == self.chosen; + let chosen = self.chosen == Some(ix); let choose = self.on_choose.clone(); div() .id((self.id.clone(), format!("thumb-{ix}"))) @@ -176,16 +183,21 @@ impl RenderOnce for VariationPicker { .gap_2() .children(vary(false, "Vary subtly")) .children(vary(true, "Vary strongly")) - .children(self.on_upscale.clone().map(|upscale| { - Button::new((self.id.clone(), "upscale"), "Upscale") - .variant(ButtonVariant::Secondary) - .size(ControlSize::Sm) - .icon(IconName::Maximize2) - .on_click(move |_, window, cx| { - log::info!("variation picker: upscale"); - upscale(window, cx) - }) - })), + .children( + self.on_upscale + .clone() + .filter(|_| self.chosen.is_some()) + .map(|upscale| { + Button::new((self.id.clone(), "upscale"), "Upscale") + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .icon(IconName::Maximize2) + .on_click(move |_, window, cx| { + log::info!("variation picker: upscale"); + upscale(window, cx) + }) + }), + ), ) } } diff --git a/src/generative/versions.rs b/src/generative/versions.rs index 350fbf1..7d7f811 100644 --- a/src/generative/versions.rs +++ b/src/generative/versions.rs @@ -118,7 +118,7 @@ pub struct PromptVersion { pub struct PromptVersionHistory { id: ElementId, versions: Vec, - chosen: usize, + chosen: Option, on_choose: Option, on_restore: Option, } @@ -131,15 +131,17 @@ impl PromptVersionHistory { chosen: usize, ) -> Self { let versions: Vec = versions.into_iter().collect(); - assert!( - chosen < versions.len(), - "version {chosen} of {}", - versions.len() - ); + let held = chosen < versions.len(); + if !held { + log::error!( + "prompt versions: version {chosen} of {}; none chosen", + versions.len() + ); + } Self { id: id.into(), versions, - chosen, + chosen: held.then_some(chosen), on_choose: None, on_restore: None, } @@ -175,13 +177,18 @@ impl RenderOnce for PromptVersionHistory { let theme = cx.theme(); let colors = theme.colors.clone(); let chosen = self.chosen; - let (heading, before) = compared(&self.versions, chosen); - let shown = &self.versions[chosen]; - let restore = self - .on_restore - .clone() - .filter(|_| chosen > 0) - .map(|restore| { + let diff = chosen.map(|chosen| { + let (heading, before) = compared(&self.versions, chosen); + (heading, before, self.versions[chosen].text.clone()) + }); + let heading: SharedString = match &diff { + Some((heading, ..)) => heading.clone(), + None => "No version is chosen".into(), + }; + let restore = chosen + .filter(|chosen| *chosen > 0) + .zip(self.on_restore.clone()) + .map(|(chosen, restore)| { Button::new((self.id.clone(), "restore"), "Restore") .variant(ButtonVariant::Secondary) .size(ControlSize::Sm) @@ -192,7 +199,7 @@ impl RenderOnce for PromptVersionHistory { }); let rows = self.versions.iter().zip(focuses).enumerate().map( |(ix, (version, (focus, focused)))| { - let on = ix == chosen; + let on = chosen == Some(ix); let choose = self.on_choose.clone(); div() .id((self.id.clone(), format!("version-{ix}"))) @@ -291,7 +298,7 @@ impl RenderOnce for PromptVersionHistory { ) .children(restore), ) - .child(PromptDiff::new(before, shown.text.clone())), + .children(diff.map(|(_, before, text)| PromptDiff::new(before, text))), ) } } diff --git a/src/generative/video.rs b/src/generative/video.rs index 9610a5c..8e5d21b 100644 --- a/src/generative/video.rs +++ b/src/generative/video.rs @@ -93,9 +93,11 @@ impl VideoGenerationTimeline { impl RenderOnce for VideoGenerationTimeline { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let named = (self.selected.as_ref()) - .is_none_or(|key| self.shots.iter().any(|shot| &shot.key == key)); - assert!(named, "the chosen shot is not listed"); + if let Some(key) = &self.selected + && !self.shots.iter().any(|shot| &shot.key == key) + { + log::error!("video timeline: shot {key} is not listed; none selected"); + } let pickable = self.on_select.is_some(); let scroll = window .use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new()) diff --git a/src/generative/voice.rs b/src/generative/voice.rs index b3a038b..44abd47 100644 --- a/src/generative/voice.rs +++ b/src/generative/voice.rs @@ -80,14 +80,11 @@ impl TTSVoicePicker { impl RenderOnce for TTSVoicePicker { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let known = |key: &Option| { - key.as_ref() - .is_none_or(|key| self.voices.iter().any(|voice| &voice.key == key)) - }; - assert!( - known(&self.selected) && known(&self.playing), - "a voice named is not listed" - ); + for key in [&self.selected, &self.playing].into_iter().flatten() { + if !self.voices.iter().any(|voice| &voice.key == key) { + log::error!("voice picker: voice {key} is not listed; none marked"); + } + } let pickable = self.on_select.is_some(); let focuses: Vec<_> = self .voices diff --git a/tasks/panics.md b/tasks/panics.md index cf8ddae..b6b023e 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -39,7 +39,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca - [x] P22 Collaboration — `src/collab` - [x] P23 AI Chat — `src/chat` - [x] P24 Agent — `src/agent` -- [ ] P25 Generative — `src/generative` +- [x] P25 Generative — `src/generative` - [ ] P26 Media — `src/media` - [ ] P27 Files — `src/files` - [ ] P28 Messaging — `src/messaging` @@ -90,3 +90,4 @@ One line per item: what changed, what stayed and why, review rounds. - P22: annotations and suggestions outside their text log and stay out, picks and decisions keeping the owner's index; `accept` returns `Option`; a gone pick, active thread or followed peer logs; yours at no count comes off; a masked field clamps a stale offset, so remote cursors and selections draw. Kept: a thread's comments, presence max, one list's order, empty marks, constant role and mode tables. Review: 3 rounds. - P23: a branch version past its total logs and shows a dash, its arrows off; a parameter outside its range logs and pegs; a passage's matches outside it log and stay out. Kept: one-value contracts (progress, scores, shapes, peaks, levels, limits, prices, capacity, format, hour), the composer's trigger rows and stream reveal by construction; `places` skips zero, so `decimals` meets no zero step. Review: 2 rounds. - P24: a checkpoint or browser frame past its list logs and marks none; progress past its total and memory past its limit log and fill their bars, the text keeping the real counts; three `should_panic` tests became render tests. Kept: the artifact's preview-or-source builder check, one-value contracts (cost, cpu, pointer, totals and limits above zero), registry lookups over a constant table. Review: 1 round. +- P25: a verdict, ratio, style, shot, voice, variation or prompt version gone from its list logs and marks none (no picture, no Vary or Upscale, no diff); embedding points in a gone group stay out; an epoch and GPU memory past their whole log and peg. A seed field holds a seed: each field handed in is fitted, and text that is no seed clears; in `TextInput`, a fit starts a fresh undo history, undo keeps and `set_text` fits against the committed text, never an input method's unfinished one (`committed`), and a lifted mask stroke stays on its picture. Kept: one-value contracts, unique group names, one snapshot's loss lists, two to four sides, the legend's lookup. Review: 5 rounds, the cap; round 5's finding (`set_text` over a composition) is fixed and tested, and P26's review checks it.