Panics · P06 Forms: roots, stale selections, shaping faults and gone units log instead of panicking; cron runs say UTC in an unknown zone

This commit is contained in:
ZacharyZhang-NY committed 2026-10-06 16:49:35 -04:00
1 parent 73a8bf2e34
commit e2d880fb4f
8 files changed
+160 -53

No files matched your search

+41 -6
View File
@@ -2,7 +2,7 @@ use gpui::{
App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled,
Window, div,
};
use jiff::civil::DateTime;
use jiff::{civil::DateTime, tz::TimeZone};
use super::{
super::{Input, TextInput},
@@ -11,6 +11,7 @@ use super::{
use crate::{
buttons::{Button, ButtonVariant},
theme::{ActiveTheme, ControlSize, TextSize},
typography::format::datetime,
};
const PRESETS: [(&str, &str); 5] = [
@@ -54,11 +55,29 @@ impl CronEditor {
}
}
/// A run's time; in the system zone it goes through `datetime`, which says UTC there.
fn shown(at: DateTime, zone: Option<&TimeZone>) -> String {
const PATTERN: &str = "%a %b %-d, %H:%M";
let Some(zone) = zone else {
return at.strftime(PATTERN).to_string();
};
let zoned = at
.to_zoned(zone.clone())
.expect("a run within five years has a zone time");
datetime(zoned.timestamp(), zone, PATTERN).expect("the pattern is fixed")
}
impl RenderOnce for CronEditor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let text = self.state.read(cx).text().trim().to_string();
let rule = (!text.is_empty()).then(|| CronRule::parse(&text));
let now = self.now.unwrap_or_else(|| zoned_now().datetime());
let (now, zone) = match self.now {
Some(now) => (now, None),
None => {
let zoned = zoned_now();
(zoned.datetime(), Some(zoned.time_zone().clone()))
}
};
let theme = cx.theme();
let colors = &theme.colors;
let presets = PRESETS.iter().enumerate().map(|(ix, (label, preset))| {
@@ -83,10 +102,8 @@ impl RenderOnce for CronEditor {
let runs = if next.is_empty() {
"No run in the next five years.".to_string()
} else {
let times: Vec<String> = next
.iter()
.map(|at| at.strftime("%a %b %-d, %H:%M").to_string())
.collect();
let times: Vec<String> =
next.iter().map(|at| shown(*at, zone.as_ref())).collect();
format!("Next: {}", times.join(" \u{00b7} "))
};
(rule.describe(), colors.fg, Some(runs))
@@ -117,3 +134,21 @@ impl RenderOnce for CronEditor {
}))
}
}
#[cfg(test)]
mod tests {
use jiff::{civil::date, tz::TimeZone};
use super::shown;
#[test]
fn a_run_in_the_unknown_zone_says_utc() {
let at = date(2026, 10, 6).at(9, 30, 0, 0);
assert_eq!(shown(at, None), "Tue Oct 6, 09:30");
assert_eq!(shown(at, Some(&TimeZone::UTC)), "Tue Oct 6, 09:30");
assert_eq!(
shown(at, Some(&TimeZone::unknown())),
"Tue Oct 6, 09:30 UTC"
);
}
}
+12 -5
View File
@@ -120,11 +120,12 @@ fn files(multiple: bool) -> PathPromptOptions {
}
}
/// The file's name, or the whole path for a root such as `C:\`.
fn file_name(path: &Path) -> String {
path.file_name()
.expect("a chosen file has a name")
.to_string_lossy()
.into_owned()
match path.file_name() {
Some(name) => name.to_string_lossy().into_owned(),
None => path.display().to_string(),
}
}
/// Chosen files in a field. A click opens the dialog; files dropped on it count too.
@@ -347,7 +348,13 @@ impl RenderOnce for DropZone {
mod tests {
use std::fs;
use super::{PICTURES, dropped, taken};
use super::{PICTURES, dropped, file_name, taken};
#[test]
fn a_root_shows_its_whole_path() {
assert_eq!(file_name(std::path::Path::new("/")), "/");
assert_eq!(file_name(std::path::Path::new("/notes/plan.md")), "plan.md");
}
#[test]
fn drops_take_files_and_refuse_folders_extras_and_nothing() {
+1
View File
@@ -2,6 +2,7 @@ mod choices;
mod colors;
mod dates;
mod other;
mod stale;
mod structure;
mod tags;
mod upload;
+47
View File
@@ -0,0 +1,47 @@
use gpui::{AppContext, Context, IntoElement, Render, TestAppContext, Window};
use super::setup;
use crate::forms::{TextInput, UnitInput};
#[gpui::test]
fn a_selection_outside_the_text_or_inside_a_character_is_left(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| gpui::Empty);
let input = cx.update(|window, cx| cx.new(|cx| TextInput::new(window, cx)));
input.update(cx, |input, cx| {
input.set_text("héllo", cx);
input.select(0..1, cx);
input.select(0..2, cx);
input.select(3..99, cx);
assert_eq!(input.selection(), 0..1);
});
}
/// A unit field whose list may drop the chosen unit.
struct Units {
shortened: bool,
}
impl Render for Units {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let units = if self.shortened {
vec!["em"]
} else {
vec!["px", "em"]
};
UnitInput::new("length", 12.0, units, "px")
}
}
#[gpui::test]
fn a_unit_gone_from_its_list_keeps_the_field(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Units { shortened: false });
cx.run_until_parked();
host.update(cx, |host, cx| {
host.shortened = true;
cx.notify();
});
cx.run_until_parked();
assert!(cx.debug_bounds("number-root").is_some());
}
+38 -29
View File
@@ -178,20 +178,23 @@ impl Element for TextElement {
AvailableSpace::Definite(width) => Some(width),
_ => None,
});
let lines = window
.text_system()
.shape_text(
SharedString::from(text.clone()),
font_size,
&runs,
width,
None,
)
.expect("text input shaping failed");
let rows: usize = lines
.iter()
.map(|line| line.wrap_boundaries().len() + 1)
.sum();
let shaped = window.text_system().shape_text(
SharedString::from(text.clone()),
font_size,
&runs,
width,
None,
);
let rows: usize = match shaped {
Ok(lines) => lines
.iter()
.map(|line| line.wrap_boundaries().len() + 1)
.sum(),
Err(error) => {
log::error!("text input: shaping failed: {error:#}");
1
}
};
size(
width.unwrap_or_default(),
line_height * rows.clamp(min, max) as f32,
@@ -220,7 +223,10 @@ impl Element for TextElement {
let shaped = window
.text_system()
.shape_text(text.clone().into(), font_size, &runs, wrap, None)
.expect("text input shaping failed");
.unwrap_or_else(|error| {
log::error!("text input: shaping failed: {error:#}");
Default::default()
});
let lines: Rc<[(usize, WrappedLine)]> =
line_starts(&text).into_iter().zip(shaped).collect();
let focused = input.focus().is_focused(window);
@@ -234,10 +240,11 @@ impl Element for TextElement {
});
});
let input = self.input.read(cx);
let caret_at = input
.position_for(input.display_offset(input.cursor()))
.expect("caret offset lies inside the laid-out text");
let scroll = keep_in_view(input, &lines, caret_at, caret_width, bounds, line_height);
let caret_at = input.position_for(input.display_offset(input.cursor()));
let scroll = match caret_at {
Some(at) => keep_in_view(input, &lines, at, caret_width, bounds, line_height),
None => input.scroll,
};
let selection = input.selection();
let selections = if placeholder || selection.is_empty() {
Vec::new()
@@ -251,12 +258,9 @@ impl Element for TextElement {
.map(|rect| Bounds::new(rect.origin + bounds.origin - scroll, rect.size))
.collect()
};
let caret = (focused && caret_on && selection.is_empty()).then(|| {
Bounds::new(
bounds.origin + caret_at - scroll,
size(caret_width, line_height),
)
});
let caret = caret_at
.filter(|_| focused && caret_on && selection.is_empty())
.map(|at| Bounds::new(bounds.origin + at - scroll, size(caret_width, line_height)));
self.input.update(cx, |input, _| input.scroll = scroll);
Prepaint {
lines,
@@ -293,15 +297,20 @@ impl Element for TextElement {
top += line.size(line_height).height;
}
for ((_, line), origin) in prepaint.lines.iter().zip(&origins) {
line.paint_background(*origin, line_height, TextAlign::Left, None, window, cx)
.expect("text input wash paint failed");
let washed =
line.paint_background(*origin, line_height, TextAlign::Left, None, window, cx);
if let Err(error) = washed {
log::error!("text input: wash paint failed: {error:#}");
}
}
for rect in prepaint.selections.drain(..) {
window.paint_quad(fill(rect, prepaint.selection_color));
}
for ((_, line), origin) in prepaint.lines.iter().zip(&origins) {
line.paint(*origin, line_height, TextAlign::Left, None, window, cx)
.expect("text input paint failed");
let painted = line.paint(*origin, line_height, TextAlign::Left, None, window, cx);
if let Err(error) = painted {
log::error!("text input: paint failed: {error:#}");
}
}
if let Some(caret) = prepaint.caret.take() {
window.paint_quad(fill(caret, prepaint.caret_color));
+11 -5
View File
@@ -266,12 +266,18 @@ impl TextInput {
self.replace(self.selection.clone(), text, false, cx);
}
/// Selects `range`; one outside the text or inside a character is logged and left.
pub fn select(&mut self, range: Range<usize>, cx: &mut Context<Self>) {
assert!(
range.start <= range.end && range.end <= self.text.len(),
"selection {range:?} is outside {} bytes",
self.text.len()
);
let fits = range.start <= range.end
&& self.text.is_char_boundary(range.start)
&& self.text.is_char_boundary(range.end);
if !fits {
log::error!(
"text input: no selection {range:?} in {} bytes",
self.text.len()
);
return;
}
self.selection = range;
self.reversed = false;
self.restart_blink(cx);
+8 -7
View File
@@ -28,7 +28,7 @@ impl UnitInput {
) -> Self {
let units: Vec<SharedString> = units.into_iter().map(Into::into).collect();
let unit = unit.into();
assert!(units.contains(&unit), "unit {unit} is not among {units:?}");
assert!(!units.is_empty(), "unit input has no units");
Self {
field: NumberInput::new(id, value),
units,
@@ -65,12 +65,13 @@ impl UnitInput {
impl RenderOnce for UnitInput {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let position = self
.units
.iter()
.position(|unit| *unit == self.unit)
.expect("checked at construction");
let next = self.units[(position + 1) % self.units.len()].clone();
let next = match self.units.iter().position(|unit| *unit == self.unit) {
Some(at) => self.units[(at + 1) % self.units.len()].clone(),
None => {
log::error!("unit input: {} is not among {:?}", self.unit, self.units);
self.units[0].clone()
}
};
let on_unit = self.on_unit;
let hover = theme.colors.hover;
let unit = div()