diff --git a/TASKS.md b/TASKS.md index 79def61..8ac1f67 100644 --- a/TASKS.md +++ b/TASKS.md @@ -393,3 +393,4 @@ Per-component lines live in `tasks/`. Tags there: | T33c | 1 | FAIL | the socket log stayed at its top; a revealed secret stayed open across environments; the collection drew drags it never moved; the builder's refill, Send's rest and Bearer, the indented body, the eye and a field's pick path untested | | T33c | 2 | FAIL | where an Inside drop lands in a folder that holds something went untested | | T33c | 3 | PASS | | +| T33d | 1 | FAIL | text fields never painted a highlight's wash; a tile's value ran past 280px; a stray + read as a hex digit; the WCAG thresholds, an exit's badge, a stopped row's load and the meter's clamp untested | diff --git a/src/devtools/contrast.rs b/src/devtools/contrast.rs index 0968057..82cd9a6 100644 --- a/src/devtools/contrast.rs +++ b/src/devtools/contrast.rs @@ -212,4 +212,15 @@ mod tests { ); assert!(contrast(blended, white) < contrast(gpui::hsla(0.0, 0.0, 0.0, 1.0), white)); } + + #[test] + fn each_level_starts_at_its_ratio() { + let met = |ratio: f32| grades(ratio).map(|(_, met)| met); + assert_eq!(met(2.9), [false, false, false, false]); + assert_eq!(met(3.1), [false, false, true, false]); + assert_eq!(met(4.4), [false, false, true, false]); + assert_eq!(met(4.6), [true, false, true, true]); + assert_eq!(met(6.9), [true, false, true, true]); + assert_eq!(met(7.1), [true, true, true, true]); + } } diff --git a/src/devtools/encode.rs b/src/devtools/encode.rs index 982b324..f81e44e 100644 --- a/src/devtools/encode.rs +++ b/src/devtools/encode.rs @@ -81,11 +81,17 @@ pub fn unhex(text: &str) -> Result, String> { .chunks(2) .map(|pair| { let digits: String = pair.iter().collect(); - u8::from_str_radix(&digits, 16).map_err(|_| format!("{digits:?} is not hex")) + byte_of(&digits).ok_or_else(|| format!("{digits:?} is not hex")) }) .collect() } +/// The byte two hex digits spell; a sign or anything else is none. +fn byte_of(digits: &str) -> Option { + (digits.len() == 2 && digits.chars().all(|ch| ch.is_ascii_hexdigit())) + .then(|| u8::from_str_radix(digits, 16).expect("two hex digits read")) +} + /// Percent-encoding undone: `%XX` becomes its byte and `+` a space. pub fn unpercent(text: &str) -> Result, String> { let bytes = text.as_bytes(); @@ -96,10 +102,7 @@ pub fn unpercent(text: &str) -> Result, String> { let digits = text .get(ix + 1..ix + 3) .ok_or("a % needs two hex digits after it")?; - out.push( - u8::from_str_radix(digits, 16) - .map_err(|_| format!("%{digits} is not an escape"))?, - ); + out.push(byte_of(digits).ok_or_else(|| format!("%{digits} is not an escape"))?); ix += 3; } b'+' => { @@ -292,6 +295,13 @@ mod tests { assert!(unbase64("TQ==TWFu").is_err(), "pads end the text"); } + #[test] + fn a_sign_is_no_hex_digit() { + assert!(unhex("+f").is_err()); + assert!(unpercent("%+f").is_err()); + assert_eq!(unhex("0f").expect("hex"), [15]); + } + #[test] fn hex_and_url_round_trip_and_name_their_faults() { assert_eq!(hex(b"hi"), "6869"); diff --git a/src/devtools/machines.rs b/src/devtools/machines.rs index 59d841c..b8cd49f 100644 --- a/src/devtools/machines.rs +++ b/src/devtools/machines.rs @@ -52,6 +52,23 @@ pub struct Container { type OnKey = Rc; +/// A container's line under its name: its image, its ports, and its load while it runs. +fn described(container: &Container) -> String { + let ports = match container.ports.is_empty() { + true => String::new(), + false => format!(" · {}", container.ports.join(", ")), + }; + let load = match container.state == ContainerState::Running { + true => format!( + " · {} · {}", + format::percent(container.cpu as f64, 0, false), + format::file_size(container.memory, true) + ), + false => String::new(), + }; + format!("{}{ports}{load}", container.image) +} + /// Containers on a host, each with its image and ports, its load while it runs, and where it stands. Its buttons, each shown when the owner handles it, start or stop it, restart it and open its logs; Enter or a double press opens it. #[derive(IntoElement)] pub struct ContainerList { @@ -133,24 +150,10 @@ impl RenderOnce for ContainerList { ) }) }; - let load = if running { - format!( - " · {} · {}", - format::percent(container.cpu as f64, 0, false), - format::file_size(container.memory, true) - ) - } else { - String::new() - }; - let ports = if container.ports.is_empty() { - String::new() - } else { - format!(" · {}", container.ports.join(", ")) - }; list.row( key.clone(), ListItem::new((id.clone(), format!("row-{key}")), container.name.clone()) - .description(format!("{}{ports}{load}", container.image)) + .description(described(container)) .leading(Icon::new(IconName::Package).size(IconSize::Md).color(muted)) .trailing( div() @@ -354,7 +357,34 @@ impl RenderOnce for PodList { #[cfg(test)] mod tests { - use super::{Pod, PodPhase, in_namespace, namespaces, running_for}; + use super::{ + Container, ContainerState, Pod, PodPhase, described, in_namespace, namespaces, running_for, + }; + use crate::data_display::Tone; + + #[test] + fn a_clean_exit_rests_quiet_and_a_stopped_container_shows_no_load() { + assert_eq!( + ContainerState::Exited(0).badge(), + (Tone::Neutral, "Exited".to_string()) + ); + assert_eq!( + ContainerState::Exited(1).badge(), + (Tone::Danger, "Exited 1".to_string()) + ); + let mut web = Container { + key: "web".into(), + name: "web".into(), + image: "img".into(), + state: ContainerState::Running, + ports: vec!["80:80".into()], + cpu: 0.25, + memory: 1 << 20, + }; + assert_eq!(described(&web), "img · 80:80 · 25% · 1.0 MiB"); + web.state = ContainerState::Exited(0); + assert_eq!(described(&web), "img · 80:80"); + } #[test] fn a_pod_runs_from_its_start_and_a_clock_ahead_reads_as_now() { diff --git a/src/devtools/monitor.rs b/src/devtools/monitor.rs index 7a7913f..7fbbdd8 100644 --- a/src/devtools/monitor.rs +++ b/src/devtools/monitor.rs @@ -1,6 +1,6 @@ use gpui::{ - App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, - Window, div, + App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, }; use crate::{ @@ -73,6 +73,7 @@ impl RenderOnce for ResourceMonitor { let latest = *reading.values.last().expect("a reading has values"); let shown = reading_line(reading); div() + .debug_selector(move || format!("monitor-tile-{ix}")) .flex_1() .min_w(theme.label_width()) .flex() @@ -97,6 +98,8 @@ impl RenderOnce for ResourceMonitor { .gap_2() .child( div() + .flex_1() + .min_w_0() .text_size(theme.text_size(TextSize::Lg)) .font_weight(FontWeight::SEMIBOLD) .child(shown), diff --git a/src/devtools/tests/tools.rs b/src/devtools/tests/tools.rs index 1888dcd..a4fb460 100644 --- a/src/devtools/tests/tools.rs +++ b/src/devtools/tests/tools.rs @@ -1,7 +1,9 @@ use gpui::{AnyElement, App, Entity, IntoElement, TestAppContext, Window}; use super::{Bench, bench, said, say, tab, tap}; -use crate::devtools::{Container, ContainerList, ContainerState, Encoder, RegexTester}; +use crate::devtools::{ + Container, ContainerList, ContainerState, Encoder, Reading, RegexTester, ResourceMonitor, +}; fn list(_: &Bench, _: &mut Window, _: &mut App, owner: Entity) -> AnyElement { let container = |key: &str, state| Container { @@ -76,3 +78,18 @@ fn decoding_base64_copies_the_text(cx: &mut TestAppContext) { .and_then(|item| item.text()); assert_eq!(copied.as_deref(), Some("hi")); } + +fn over(_: &Bench, _: &mut Window, _: &mut App, _: Entity) -> AnyElement { + ResourceMonitor::new( + "monitor", + [Reading::new("Queue", "jobs", [80.0, 120.0]).limit(100.0)], + ) + .into_any_element() +} + +/// A meter takes a share from 0 to 1, so a reading past its limit must fill it, not break it. +#[gpui::test] +fn a_reading_past_its_limit_fills_its_meter(cx: &mut TestAppContext) { + let (_, cx) = bench(over, cx); + assert!(cx.debug_bounds("monitor-tile-0").is_some(), "the tile drew"); +} diff --git a/src/forms/text/element.rs b/src/forms/text/element.rs index eb3649a..c438119 100644 --- a/src/forms/text/element.rs +++ b/src/forms/text/element.rs @@ -283,21 +283,22 @@ impl Element for TextElement { ); let line_height = window.line_height(); window.with_content_mask(Some(ContentMask { bounds }), |window| { + let mut origins = Vec::with_capacity(prepaint.lines.len()); + let mut top = Pixels::ZERO; + for (_, line) in &prepaint.lines { + origins.push(bounds.origin - scroll + point(Pixels::ZERO, top)); + 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"); + } for rect in prepaint.selections.drain(..) { window.paint_quad(fill(rect, prepaint.selection_color)); } - let mut top = Pixels::ZERO; - for (_, line) in &prepaint.lines { - line.paint( - bounds.origin - scroll + point(Pixels::ZERO, top), - line_height, - TextAlign::Left, - None, - window, - cx, - ) - .expect("text input paint failed"); - top += line.size(line_height).height; + for ((_, line), origin) in prepaint.lines.iter().zip(&origins) { + line.paint(*origin, line_height, TextAlign::Left, None, window, cx) + .expect("text input paint failed"); } if let Some(caret) = prepaint.caret.take() { window.paint_quad(fill(caret, prepaint.caret_color));