DB & Dev Tools · Tools and machines: regex, encoders, contrast, containers, pods
RegexTester tries a pattern with its flags against a sample, washes each match and lists it with its groups, zero-width ones too; a pattern that does not compile says why. Encoder turns text into Base64, URL or hex and back, says why a decode fails (Base64 pads end the text, twice at most) and copies the result, drawn through typography::literal so no ligature hides a character. ColorContrastChecker measures text on a background, translucent text as it blends, and names the WCAG levels met. ContainerList starts, stops, restarts and opens containers, each button shown when the owner handles it. PodList sorts pods by any column, ages by length through tables' new Cell::Duration, and narrows them by namespace. ResourceMonitor shows each reading's latest value beside its sparkline and, with a limit, how full it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0cf33eea33
commit
e3f2d497f1
17 files changed
+1698
-8
No files matched your search
@@ -179,6 +179,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title).
|
||||
- Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns).
|
||||
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
|
||||
- Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`).
|
||||
|
||||
## Decisions
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod apis;
|
||||
mod data;
|
||||
mod db;
|
||||
mod formats;
|
||||
mod tools;
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
@@ -30,5 +31,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(apis::requests(window, cx))
|
||||
.child(apis::socket(window, cx))
|
||||
.child(apis::graphql(window, cx))
|
||||
.child(tools::text(window, cx))
|
||||
.child(tools::machines(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use ely_gpui_component::devtools::{
|
||||
ColorContrastChecker, Container, ContainerList, ContainerState, Encoder, Pod, PodList,
|
||||
PodPhase, Reading, RegexTester, ResourceMonitor,
|
||||
};
|
||||
use gpui::{App, Hsla, IntoElement, ParentElement, Styled, Window, div, px, rgb};
|
||||
use jiff::Timestamp;
|
||||
|
||||
use crate::ui::{change, keep, section};
|
||||
|
||||
pub fn text(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let colors = keep(
|
||||
"devtools-contrast",
|
||||
|| (Hsla::from(rgb(0x6b6b6b)), Hsla::from(rgb(0xf4efe6))),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let (fore, back) = *colors.read(cx);
|
||||
section(
|
||||
"RegexTester · Base64 / Encoder · ColorContrastChecker",
|
||||
"A pattern tried against a sample with each match washed and its groups listed; text encoded or decoded as Base64, for a URL or as hex; and text on a background with the contrast between them and the WCAG levels it meets.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_6()
|
||||
.child(
|
||||
div().w(px(420.)).child(RegexTester::new(
|
||||
"devtools-regex",
|
||||
r"(?P<user>[\w.]+)@(?P<host>[\w.]+)",
|
||||
"Write to ada@example.com or grace.h@navy.mil.\nNot a@ or @b.",
|
||||
)),
|
||||
)
|
||||
.child(div().w(px(380.)).child(Encoder::new("devtools-encoder", "Hello, 世界")))
|
||||
.child(
|
||||
div().w(px(360.)).child(
|
||||
ColorContrastChecker::new("devtools-contrast", fore, back)
|
||||
.on_change(move |fore, back, _, cx| change(&colors, cx, |colors| *colors = (fore, back))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn containers() -> Vec<Container> {
|
||||
let container = |key: &str, image: &str, state, ports: &[&str], cpu, memory| Container {
|
||||
key: key.to_string().into(),
|
||||
name: key.to_string().into(),
|
||||
image: image.to_string().into(),
|
||||
state,
|
||||
ports: ports.iter().map(|port| port.to_string().into()).collect(),
|
||||
cpu,
|
||||
memory,
|
||||
};
|
||||
vec![
|
||||
container(
|
||||
"web",
|
||||
"registry.example.com/web:1.8.2",
|
||||
ContainerState::Running,
|
||||
&["8080:80"],
|
||||
0.18,
|
||||
184 << 20,
|
||||
),
|
||||
container(
|
||||
"worker",
|
||||
"registry.example.com/worker:1.8.2",
|
||||
ContainerState::Running,
|
||||
&[],
|
||||
0.42,
|
||||
312 << 20,
|
||||
),
|
||||
container(
|
||||
"postgres",
|
||||
"postgres:17",
|
||||
ContainerState::Paused,
|
||||
&["5432:5432"],
|
||||
0.0,
|
||||
96 << 20,
|
||||
),
|
||||
container(
|
||||
"migrate",
|
||||
"registry.example.com/web:1.8.2",
|
||||
ContainerState::Exited(1),
|
||||
&[],
|
||||
0.0,
|
||||
0,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn pods(now: Timestamp) -> Vec<Pod> {
|
||||
let ago = |minutes: i64| now - jiff::SignedDuration::from_mins(minutes);
|
||||
let pod = |key: &str, namespace: &str, phase, ready, restarts, minutes, node: &str| Pod {
|
||||
key: key.to_string().into(),
|
||||
name: key.to_string().into(),
|
||||
namespace: namespace.to_string().into(),
|
||||
phase,
|
||||
ready,
|
||||
restarts,
|
||||
started: ago(minutes),
|
||||
node: node.to_string().into(),
|
||||
};
|
||||
vec![
|
||||
pod(
|
||||
"web-7c9f-2kq4x",
|
||||
"shop",
|
||||
PodPhase::Running,
|
||||
(1, 1),
|
||||
0,
|
||||
3 * 1440,
|
||||
"node-a",
|
||||
),
|
||||
pod(
|
||||
"web-7c9f-8zt6m",
|
||||
"shop",
|
||||
PodPhase::Running,
|
||||
(1, 1),
|
||||
2,
|
||||
3 * 1440,
|
||||
"node-b",
|
||||
),
|
||||
pod(
|
||||
"worker-5d8b-q2n9",
|
||||
"shop",
|
||||
PodPhase::Pending,
|
||||
(0, 1),
|
||||
0,
|
||||
2,
|
||||
"node-b",
|
||||
),
|
||||
pod(
|
||||
"migrate-29xk",
|
||||
"shop",
|
||||
PodPhase::Failed,
|
||||
(0, 1),
|
||||
4,
|
||||
45,
|
||||
"node-a",
|
||||
),
|
||||
pod(
|
||||
"coredns-6f4b-hx8c",
|
||||
"kube-system",
|
||||
PodPhase::Running,
|
||||
(1, 1),
|
||||
0,
|
||||
21 * 1440,
|
||||
"node-a",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn machines(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let now: Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time");
|
||||
let held = keep("devtools-containers", containers, window, cx);
|
||||
let (toggled, restarted) = (held.clone(), held.clone());
|
||||
let list = held.read(cx).clone();
|
||||
let wave = |base: f32, swing: f32| {
|
||||
(0..24).map(move |ix| base + swing * (((ix as f32) * 0.6).sin() + ix as f32 / 24.0))
|
||||
};
|
||||
section(
|
||||
"ContainerList · PodList · ResourceMonitor",
|
||||
"Containers with where each stands and its load, started, stopped or restarted from its row; pods in a table narrowed by namespace; and a machine's readings, each with its latest value, its recent past and how full it is.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.child(
|
||||
div().w(px(640.)).child(
|
||||
ContainerList::new("devtools-containers", list)
|
||||
.on_toggle(move |key, _, cx| {
|
||||
change(&toggled, cx, |list| {
|
||||
let container = list.iter_mut().find(|each| each.key == *key).expect("a listed container");
|
||||
container.state = match container.state {
|
||||
ContainerState::Running => ContainerState::Exited(0),
|
||||
_ => ContainerState::Running,
|
||||
};
|
||||
})
|
||||
})
|
||||
.on_restart(move |key, _, cx| {
|
||||
change(&restarted, cx, |list| {
|
||||
list.iter_mut().find(|each| each.key == *key).expect("a listed container").state = ContainerState::Restarting
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(div().w(px(860.)).child(PodList::new("devtools-pods", pods(now), now)))
|
||||
.child(
|
||||
div().w(px(860.)).child(ResourceMonitor::new(
|
||||
"devtools-monitor",
|
||||
[
|
||||
Reading::new("CPU", "cores", wave(3.1, 1.0)).limit(8.0),
|
||||
Reading::new("Memory", "GB", wave(5.2, 0.6)).limit(16.0),
|
||||
Reading::new("Network in", "MB/s", wave(12.0, 6.0)),
|
||||
Reading::new("Disk", "GB", wave(310.0, 2.0)).limit(512.0),
|
||||
],
|
||||
)),
|
||||
),
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -53,7 +53,7 @@ pub use mindmap_view::MindMap;
|
||||
pub use minimap::MiniMap;
|
||||
pub use nodes::NodeGraph;
|
||||
pub use palette::{BrushSettings, ToolPalette};
|
||||
pub(crate) use panel::{OnEdit, caption, editing};
|
||||
pub(crate) use panel::{OnEdit, caption, color_well, editing};
|
||||
pub use plane::InfiniteCanvas;
|
||||
pub use shape::{Artboard, Corner, Guide, Shape, ShapeKind};
|
||||
pub use style::{Border, BorderEditor, ColorPanel, Dash, Shadow, ShadowEditor};
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, Hsla, IntoElement, ParentElement, RenderOnce, Rgba, Styled, Window,
|
||||
div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
canvas::color_well,
|
||||
data_display::{Badge, Tone},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// A color's relative luminance, as WCAG reads it from sRGB.
|
||||
pub fn luminance(color: Hsla) -> f32 {
|
||||
let rgb = Rgba::from(color);
|
||||
let linear = |channel: f32| {
|
||||
if channel <= 0.04045 {
|
||||
channel / 12.92
|
||||
} else {
|
||||
((channel + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
0.2126 * linear(rgb.r) + 0.7152 * linear(rgb.g) + 0.0722 * linear(rgb.b)
|
||||
}
|
||||
|
||||
/// The contrast between two colors, from 1 for none to 21 for black on white.
|
||||
pub fn contrast(a: Hsla, b: Hsla) -> f32 {
|
||||
let (light, dark) = {
|
||||
let (x, y) = (luminance(a), luminance(b));
|
||||
(x.max(y), x.min(y))
|
||||
};
|
||||
(light + 0.05) / (dark + 0.05)
|
||||
}
|
||||
|
||||
/// `text` laid over `background` as it shows: its alpha blends it in, in sRGB; the background counts as opaque.
|
||||
pub fn over(text: Hsla, background: Hsla) -> Hsla {
|
||||
let (fg, bg) = (Rgba::from(text), Rgba::from(background));
|
||||
let mix = |front: f32, back: f32| front * fg.a + back * (1.0 - fg.a);
|
||||
Hsla::from(Rgba {
|
||||
r: mix(fg.r, bg.r),
|
||||
g: mix(fg.g, bg.g),
|
||||
b: mix(fg.b, bg.b),
|
||||
a: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The WCAG levels a contrast meets: AA and AAA for body text, and for large text.
|
||||
pub fn grades(ratio: f32) -> [(&'static str, bool); 4] {
|
||||
[
|
||||
("AA", ratio >= 4.5),
|
||||
("AAA", ratio >= 7.0),
|
||||
("AA large", ratio >= 3.0),
|
||||
("AAA large", ratio >= 4.5),
|
||||
]
|
||||
}
|
||||
|
||||
type OnColors = Rc<dyn Fn(Hsla, Hsla, &mut Window, &mut App)>;
|
||||
type OnColor = Rc<dyn Fn(Hsla, &mut Window, &mut App)>;
|
||||
|
||||
/// Text on a background, each color opening a picker, with the contrast between them and the WCAG levels it meets, over a sample of body and large text. Translucent text counts as it blends in; the background counts as opaque.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ColorContrastChecker {
|
||||
id: ElementId,
|
||||
text: Hsla,
|
||||
background: Hsla,
|
||||
on_change: Option<OnColors>,
|
||||
}
|
||||
|
||||
impl ColorContrastChecker {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
text: impl Into<Hsla>,
|
||||
background: impl Into<Hsla>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
background: background.into(),
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the text's and the background's colors after either changes.
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(Hsla, Hsla, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ColorContrastChecker {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (text, background) = (self.text, self.background);
|
||||
let solid = Hsla {
|
||||
a: 1.0,
|
||||
..background
|
||||
};
|
||||
let ratio = contrast(over(text, solid), solid);
|
||||
let on_change = self
|
||||
.on_change
|
||||
.unwrap_or_else(|| panic!("color contrast checker {:?} has no on_change", self.id));
|
||||
let theme = cx.theme();
|
||||
let well = |name: &'static str, color: Hsla, set: OnColor| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(color_well(
|
||||
(self.id.clone(), name).into(),
|
||||
color,
|
||||
move |color, window, cx| set(color, window, cx),
|
||||
))
|
||||
.child(div().text_size(theme.text_size(TextSize::Sm)).child(name))
|
||||
};
|
||||
let (fore, back) = (on_change.clone(), on_change);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_4()
|
||||
.child(well(
|
||||
"Text",
|
||||
text,
|
||||
Rc::new(move |color, window, cx| {
|
||||
log::info!("color contrast checker: text");
|
||||
fore(color, background, window, cx);
|
||||
}),
|
||||
))
|
||||
.child(well(
|
||||
"Background",
|
||||
background,
|
||||
Rc::new(move |color, window, cx| {
|
||||
log::info!("color contrast checker: background");
|
||||
back(text, color, window, cx);
|
||||
}),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_4()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(background)
|
||||
.text_color(text)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.child("Body text reads at this size."),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xl))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child("Large text"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Lg))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(format!("{ratio:.2} : 1")),
|
||||
)
|
||||
.children(grades(ratio).into_iter().map(|(level, met)| {
|
||||
Badge::new(level).tone(if met { Tone::Success } else { Tone::Danger })
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{hsla, rgb};
|
||||
|
||||
use super::{contrast, grades, over};
|
||||
|
||||
#[test]
|
||||
fn black_on_white_is_twenty_one_and_a_color_on_itself_is_one() {
|
||||
let (black, white) = (hsla(0.0, 0.0, 0.0, 1.0), hsla(0.0, 0.0, 1.0, 1.0));
|
||||
assert!((contrast(black, white) - 21.0).abs() < 0.01);
|
||||
assert!((contrast(white, white) - 1.0).abs() < 0.001);
|
||||
let grey: gpui::Hsla = rgb(0x767676).into();
|
||||
let ratio = contrast(grey, white);
|
||||
assert!(
|
||||
(ratio - 4.54).abs() < 0.02,
|
||||
"the lightest grey that passes AA on white: {ratio}"
|
||||
);
|
||||
assert_eq!(grades(ratio).map(|(_, met)| met), [true, false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translucent_text_counts_as_it_blends_in() {
|
||||
let white = gpui::hsla(0.0, 0.0, 1.0, 1.0);
|
||||
let half = gpui::hsla(0.0, 0.0, 0.0, 0.5);
|
||||
let blended = over(half, white);
|
||||
assert!(
|
||||
(blended.l - 0.5).abs() < 0.01,
|
||||
"half black on white is mid gray: {blended:?}"
|
||||
);
|
||||
assert!(contrast(blended, white) < contrast(gpui::hsla(0.0, 0.0, 0.0, 1.0), white));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use gpui::{
|
||||
App, AppContext as _, ClipboardItem, ElementId, Entity, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use super::request::percent;
|
||||
use crate::{
|
||||
buttons::{IconButton, SegmentedControl},
|
||||
feedback::InlineMessage,
|
||||
forms::{Input, TextInput},
|
||||
primitives::{IconName, Severity},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
typography::literal,
|
||||
};
|
||||
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Bytes in Base64, padded with `=`.
|
||||
pub fn base64(bytes: &[u8]) -> String {
|
||||
bytes
|
||||
.chunks(3)
|
||||
.flat_map(|chunk| {
|
||||
let word = chunk.iter().enumerate().fold(0u32, |word, (ix, byte)| {
|
||||
word | (*byte as u32) << (16 - 8 * ix)
|
||||
});
|
||||
(0..4).map(move |ix| match ix <= chunk.len() {
|
||||
true => ALPHABET[(word >> (18 - 6 * ix) & 63) as usize] as char,
|
||||
false => '=',
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Base64 back to bytes; spaces are skipped, and any other stray character or a bad length is an error.
|
||||
pub fn unbase64(text: &str) -> Result<Vec<u8>, String> {
|
||||
let clean: Vec<u8> = text
|
||||
.bytes()
|
||||
.filter(|byte| !byte.is_ascii_whitespace())
|
||||
.collect();
|
||||
if !clean.len().is_multiple_of(4) {
|
||||
return Err(format!(
|
||||
"Base64 comes in fours; this is {} long",
|
||||
clean.len()
|
||||
));
|
||||
}
|
||||
let (mut out, quads) = (Vec::new(), clean.len() / 4);
|
||||
for (at, quad) in clean.chunks(4).enumerate() {
|
||||
let pads = quad.iter().rev().take_while(|byte| **byte == b'=').count();
|
||||
if pads > 2 || (pads > 0 && at + 1 < quads) {
|
||||
return Err("= pads only the last four, twice at most".into());
|
||||
}
|
||||
let mut word = 0u32;
|
||||
for (ix, byte) in quad.iter().enumerate() {
|
||||
let value = match byte {
|
||||
b'=' if ix >= 4 - pads => 0,
|
||||
_ => ALPHABET
|
||||
.iter()
|
||||
.position(|each| each == byte)
|
||||
.ok_or_else(|| format!("{:?} is not Base64", *byte as char))?
|
||||
as u32,
|
||||
};
|
||||
word = word << 6 | value;
|
||||
}
|
||||
out.extend(word.to_be_bytes()[1..4 - pads].iter());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Bytes as two hex digits each.
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Hex back to bytes, two digits a byte; spaces are skipped.
|
||||
pub fn unhex(text: &str) -> Result<Vec<u8>, String> {
|
||||
let clean: Vec<char> = text.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if !clean.len().is_multiple_of(2) {
|
||||
return Err("Hex comes in pairs of digits".to_string());
|
||||
}
|
||||
clean
|
||||
.chunks(2)
|
||||
.map(|pair| {
|
||||
let digits: String = pair.iter().collect();
|
||||
u8::from_str_radix(&digits, 16).map_err(|_| format!("{digits:?} is not hex"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Percent-encoding undone: `%XX` becomes its byte and `+` a space.
|
||||
pub fn unpercent(text: &str) -> Result<Vec<u8>, String> {
|
||||
let bytes = text.as_bytes();
|
||||
let (mut out, mut ix) = (Vec::new(), 0);
|
||||
while ix < bytes.len() {
|
||||
match bytes[ix] {
|
||||
b'%' => {
|
||||
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"))?,
|
||||
);
|
||||
ix += 3;
|
||||
}
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
ix += 1;
|
||||
}
|
||||
byte => {
|
||||
out.push(byte);
|
||||
ix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What an encoder turns text into.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Code {
|
||||
Base64,
|
||||
Url,
|
||||
Hex,
|
||||
}
|
||||
|
||||
impl Code {
|
||||
const ALL: [Code; 3] = [Code::Base64, Code::Url, Code::Hex];
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Code::Base64 => "Base64",
|
||||
Code::Url => "URL",
|
||||
Code::Hex => "Hex",
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` encoded, or decoded back to UTF-8 text.
|
||||
pub fn run(self, text: &str, encode: bool) -> Result<String, String> {
|
||||
if encode {
|
||||
return Ok(match self {
|
||||
Code::Base64 => base64(text.as_bytes()),
|
||||
Code::Url => percent(text),
|
||||
Code::Hex => hex(text.as_bytes()),
|
||||
});
|
||||
}
|
||||
let bytes = match self {
|
||||
Code::Base64 => unbase64(text)?,
|
||||
Code::Url => unpercent(text)?,
|
||||
Code::Hex => unhex(text)?,
|
||||
};
|
||||
String::from_utf8(bytes).map_err(|_| "The bytes are not UTF-8 text".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The encoder's own field, code and way.
|
||||
struct Desk {
|
||||
input: Entity<TextInput>,
|
||||
code: Code,
|
||||
encode: bool,
|
||||
}
|
||||
|
||||
/// Text in, encoded or decoded out, as Base64, for a URL, or as hex; what fails to decode says why, and the result copies.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Encoder {
|
||||
id: ElementId,
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
/// The text it starts with.
|
||||
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Encoder {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let text = self.text.to_string();
|
||||
let desk = window.use_keyed_state((self.id.clone(), "desk"), cx, move |window, cx| Desk {
|
||||
input: cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).multi_line(3, 8);
|
||||
input.set_text(text, cx);
|
||||
input
|
||||
}),
|
||||
code: Code::Base64,
|
||||
encode: true,
|
||||
});
|
||||
let (input, code, encode) = {
|
||||
let desk = desk.read(cx);
|
||||
(desk.input.clone(), desk.code, desk.encode)
|
||||
};
|
||||
let out = code.run(input.read(cx).text(), encode);
|
||||
let theme = cx.theme();
|
||||
let (coded, way) = (desk.clone(), desk.clone());
|
||||
let result = match out {
|
||||
Err(why) => InlineMessage::new(Severity::Danger, why).into_any_element(),
|
||||
Ok(text) => {
|
||||
let copied = text.clone();
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.sunken)
|
||||
.child(
|
||||
literal(div())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(text),
|
||||
)
|
||||
.child(
|
||||
IconButton::new((self.id.clone(), "copy"), IconName::Copy)
|
||||
.tooltip("Copy the result")
|
||||
.on_click(move |_, _, cx| {
|
||||
log::info!("encoder: copied");
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(copied.clone()))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.child(
|
||||
Code::ALL
|
||||
.iter()
|
||||
.fold(
|
||||
SegmentedControl::new((self.id.clone(), "code"), code.name()),
|
||||
|control, code| control.segment(code.name(), code.name(), None),
|
||||
)
|
||||
.on_change(move |name, _, cx| {
|
||||
coded.update(cx, |desk, cx| {
|
||||
desk.code = *Code::ALL
|
||||
.iter()
|
||||
.find(|code| code.name() == name.as_ref())
|
||||
.expect("a code the control offers");
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
SegmentedControl::new(
|
||||
(self.id, "way"),
|
||||
if encode { "encode" } else { "decode" },
|
||||
)
|
||||
.segment("encode", "Encode", None)
|
||||
.segment("decode", "Decode", None)
|
||||
.on_change(move |value, _, cx| {
|
||||
way.update(cx, |desk, cx| {
|
||||
desk.encode = match value.as_ref() {
|
||||
"encode" => true,
|
||||
"decode" => false,
|
||||
other => panic!("encoder: no way named {other}"),
|
||||
};
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(Input::new(&input))
|
||||
.child(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Code, base64, hex, unbase64, unhex, unpercent};
|
||||
|
||||
#[test]
|
||||
fn base64_round_trips_with_padding() {
|
||||
assert_eq!(base64(b"Man"), "TWFu");
|
||||
assert_eq!(base64(b"Ma"), "TWE=");
|
||||
assert_eq!(base64(b"M"), "TQ==");
|
||||
assert_eq!(unbase64("TWE=").expect("base64"), b"Ma");
|
||||
assert_eq!(unbase64("TQ ==").expect("spaces skip"), b"M");
|
||||
assert!(unbase64("TW!u").is_err());
|
||||
assert!(unbase64("TWE").is_err());
|
||||
assert!(unbase64("T===").is_err(), "three pads say nothing");
|
||||
assert!(unbase64("TQ==TWFu").is_err(), "pads end the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_and_url_round_trip_and_name_their_faults() {
|
||||
assert_eq!(hex(b"hi"), "6869");
|
||||
assert_eq!(unhex("68 69").expect("hex"), b"hi");
|
||||
assert!(unhex("6").is_err());
|
||||
assert_eq!(unpercent("a%20b+c").expect("url"), b"a b c");
|
||||
assert!(unpercent("%2").is_err());
|
||||
assert_eq!(
|
||||
Code::Url.run("a b/é", true).expect("encodes"),
|
||||
"a%20b%2F%C3%A9"
|
||||
);
|
||||
assert_eq!(
|
||||
Code::Base64.run("//8=", false),
|
||||
Err("The bytes are not UTF-8 text".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
use jiff::Timestamp;
|
||||
|
||||
use crate::{
|
||||
buttons::IconButton,
|
||||
data_display::{Badge, Tone},
|
||||
forms::{Choice, Select},
|
||||
lists::{ListItem, SelectableList, row_action},
|
||||
primitives::{Icon, IconName},
|
||||
tables::{Cell, Column, DataTable, Row},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
typography::format,
|
||||
};
|
||||
|
||||
/// Where a container stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContainerState {
|
||||
Running,
|
||||
Paused,
|
||||
Restarting,
|
||||
/// Stopped, with the code it ended with.
|
||||
Exited(i32),
|
||||
}
|
||||
|
||||
impl ContainerState {
|
||||
fn badge(self) -> (Tone, String) {
|
||||
match self {
|
||||
ContainerState::Running => (Tone::Success, "Running".into()),
|
||||
ContainerState::Paused => (Tone::Warning, "Paused".into()),
|
||||
ContainerState::Restarting => (Tone::Info, "Restarting".into()),
|
||||
ContainerState::Exited(0) => (Tone::Neutral, "Exited".into()),
|
||||
ContainerState::Exited(code) => (Tone::Danger, format!("Exited {code}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A container: its key and name, its image, where it stands, its published ports, its share of a CPU and its memory in bytes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Container {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
pub image: SharedString,
|
||||
pub state: ContainerState,
|
||||
pub ports: Vec<SharedString>,
|
||||
pub cpu: f32,
|
||||
pub memory: u64,
|
||||
}
|
||||
|
||||
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// 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 {
|
||||
id: ElementId,
|
||||
containers: Vec<Container>,
|
||||
on_open: Option<OnKey>,
|
||||
on_toggle: Option<OnKey>,
|
||||
on_restart: Option<OnKey>,
|
||||
on_logs: Option<OnKey>,
|
||||
}
|
||||
|
||||
impl ContainerList {
|
||||
pub fn new(id: impl Into<ElementId>, containers: impl IntoIterator<Item = Container>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
containers: containers.into_iter().collect(),
|
||||
on_open: None,
|
||||
on_toggle: None,
|
||||
on_restart: None,
|
||||
on_logs: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Asked to stop a running container, or to start any other.
|
||||
pub fn on_toggle(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_toggle = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_restart(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_restart = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_logs(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_logs = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ContainerList {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let theme = cx.theme();
|
||||
let muted = theme.colors.fg_muted;
|
||||
let list = self.containers.iter().fold(
|
||||
SelectableList::new((id.clone(), "list")),
|
||||
|list, container| {
|
||||
let key = container.key.clone();
|
||||
let running = container.state == ContainerState::Running;
|
||||
let (tone, words) = container.state.badge();
|
||||
let button = |icon: IconName, tip: &'static str, handler: &Option<OnKey>| {
|
||||
handler.clone().map(|handler| {
|
||||
let key = key.clone();
|
||||
row_action(
|
||||
IconButton::new((id.clone(), format!("{tip}-{key}")), icon)
|
||||
.tooltip(tip)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("container list: {tip} {key}");
|
||||
handler(&key, window, cx);
|
||||
}),
|
||||
)
|
||||
})
|
||||
};
|
||||
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))
|
||||
.leading(Icon::new(IconName::Package).size(IconSize::Md).color(muted))
|
||||
.trailing(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(Badge::new(words).tone(tone).dot())
|
||||
.children(button(
|
||||
if running {
|
||||
IconName::CircleStop
|
||||
} else {
|
||||
IconName::Play
|
||||
},
|
||||
if running { "Stop" } else { "Start" },
|
||||
&self.on_toggle,
|
||||
))
|
||||
.children(button(IconName::RotateCw, "Restart", &self.on_restart))
|
||||
.children(button(IconName::Terminal, "Logs", &self.on_logs)),
|
||||
),
|
||||
)
|
||||
},
|
||||
);
|
||||
match self.on_open {
|
||||
Some(on_open) => list.on_activate(move |key, window, cx| {
|
||||
log::info!("container list: open {key}");
|
||||
on_open(key, window, cx)
|
||||
}),
|
||||
None => list,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pod's phase, as Kubernetes names it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PodPhase {
|
||||
Pending,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl PodPhase {
|
||||
fn cell(self) -> Cell {
|
||||
let (words, tone) = match self {
|
||||
PodPhase::Pending => ("Pending", Tone::Warning),
|
||||
PodPhase::Running => ("Running", Tone::Success),
|
||||
PodPhase::Succeeded => ("Succeeded", Tone::Neutral),
|
||||
PodPhase::Failed => ("Failed", Tone::Danger),
|
||||
PodPhase::Unknown => ("Unknown", Tone::Neutral),
|
||||
};
|
||||
Cell::Tag(words.into(), tone)
|
||||
}
|
||||
}
|
||||
|
||||
/// A pod: its key and name, its namespace, its phase, its containers ready of all, its restarts, when it started, and its node.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pod {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
pub namespace: SharedString,
|
||||
pub phase: PodPhase,
|
||||
pub ready: (u32, u32),
|
||||
pub restarts: u32,
|
||||
pub started: Timestamp,
|
||||
pub node: SharedString,
|
||||
}
|
||||
|
||||
type OnKeys = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
|
||||
|
||||
/// Pods in a table, sortable by any column: name, namespace, phase, containers ready, restarts, age and node; a namespace narrows them.
|
||||
#[derive(IntoElement)]
|
||||
pub struct PodList {
|
||||
id: ElementId,
|
||||
pods: Vec<Pod>,
|
||||
now: Timestamp,
|
||||
selected: Vec<SharedString>,
|
||||
on_select: Option<OnKeys>,
|
||||
}
|
||||
|
||||
impl PodList {
|
||||
/// `now` ages each pod.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
pods: impl IntoIterator<Item = Pod>,
|
||||
now: Timestamp,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
pods: pods.into_iter().collect(),
|
||||
now,
|
||||
selected: Vec::new(),
|
||||
on_select: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.selected = keys.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_select(
|
||||
mut self,
|
||||
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_select = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds from `started` to `now`; a start ahead of now, as clocks between machines drift, reads as now.
|
||||
pub(crate) fn running_for(started: Timestamp, now: Timestamp) -> u64 {
|
||||
(now.as_second() - started.as_second()).max(0) as u64
|
||||
}
|
||||
|
||||
/// The namespaces among `pods`, in the order they first come.
|
||||
fn namespaces(pods: &[Pod]) -> Vec<SharedString> {
|
||||
let mut seen: Vec<SharedString> = Vec::new();
|
||||
for pod in pods {
|
||||
if !seen.contains(&pod.namespace) {
|
||||
seen.push(pod.namespace.clone());
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// The pods in `namespace`; the empty namespace, which Kubernetes never names, stands for all.
|
||||
fn in_namespace<'a>(pods: &'a [Pod], namespace: &'a str) -> impl Iterator<Item = &'a Pod> {
|
||||
pods.iter()
|
||||
.filter(move |pod| namespace.is_empty() || pod.namespace == namespace)
|
||||
}
|
||||
|
||||
impl RenderOnce for PodList {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let space = window.use_keyed_state((id.clone(), "namespace"), cx, |_, _| {
|
||||
SharedString::default()
|
||||
});
|
||||
let chosen = space.read(cx).clone();
|
||||
let spaces = namespaces(&self.pods);
|
||||
let rows: Vec<Row> = in_namespace(&self.pods, &chosen)
|
||||
.map(|pod| {
|
||||
Row::new(
|
||||
pod.key.clone(),
|
||||
[
|
||||
Cell::Text(pod.name.clone()),
|
||||
Cell::Text(pod.namespace.clone()),
|
||||
pod.phase.cell(),
|
||||
Cell::Text(format!("{}/{}", pod.ready.0, pod.ready.1).into()),
|
||||
Cell::Number(pod.restarts as f64),
|
||||
Cell::Duration(running_for(pod.started, self.now)),
|
||||
Cell::Text(pod.node.clone()),
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let theme = cx.theme();
|
||||
let mut table = DataTable::new(
|
||||
(id.clone(), "table"),
|
||||
[
|
||||
Column::new("name", "Name"),
|
||||
Column::new("namespace", "Namespace"),
|
||||
Column::new("phase", "Status"),
|
||||
Column::new("ready", "Ready"),
|
||||
Column::new("restarts", "Restarts").end(),
|
||||
Column::new("age", "Age"),
|
||||
Column::new("node", "Node"),
|
||||
],
|
||||
)
|
||||
.rows(rows)
|
||||
.selected(self.selected);
|
||||
if let Some(on_select) = self.on_select {
|
||||
table = table.on_select(move |keys, window, cx| on_select(keys, window, cx));
|
||||
}
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().max_w(gpui::rems(theme.label_width().0 * 2.0)).child(
|
||||
Select::new(
|
||||
(id, "spaces"),
|
||||
[Choice::new("", "All namespaces")].into_iter().chain(
|
||||
spaces
|
||||
.iter()
|
||||
.map(|space| Choice::new(space.clone(), space.clone())),
|
||||
),
|
||||
)
|
||||
.selected(chosen)
|
||||
.on_change(move |value, _, cx| {
|
||||
space.update(cx, |space, cx| {
|
||||
*space = value.clone();
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(table)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Pod, PodPhase, in_namespace, namespaces, running_for};
|
||||
|
||||
#[test]
|
||||
fn a_pod_runs_from_its_start_and_a_clock_ahead_reads_as_now() {
|
||||
let now: jiff::Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time");
|
||||
let started: jiff::Timestamp = "2026-09-27T10:35:00Z".parse().expect("a time");
|
||||
assert_eq!(running_for(started, now), 5_100);
|
||||
assert_eq!(running_for(now, started), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespaces_come_once_in_order_and_narrow_the_pods() {
|
||||
let now: jiff::Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time");
|
||||
let pod = |key: &str, namespace: &str| Pod {
|
||||
key: key.to_string().into(),
|
||||
name: key.to_string().into(),
|
||||
namespace: namespace.to_string().into(),
|
||||
phase: PodPhase::Running,
|
||||
ready: (1, 1),
|
||||
restarts: 0,
|
||||
started: now,
|
||||
node: "n1".into(),
|
||||
};
|
||||
let pods = [pod("a", "web"), pod("b", "jobs"), pod("c", "web")];
|
||||
assert_eq!(namespaces(&pods), ["web", "jobs"]);
|
||||
let keys = |namespace| {
|
||||
in_namespace(&pods, namespace)
|
||||
.map(|pod| pod.key.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(keys("web"), ["a", "c"]);
|
||||
assert_eq!(keys(""), ["a", "b", "c"]);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
mod collection;
|
||||
mod connection_form;
|
||||
mod connections;
|
||||
mod contrast;
|
||||
mod datum;
|
||||
mod document;
|
||||
mod encode;
|
||||
mod environment;
|
||||
mod graphql;
|
||||
mod json_editor;
|
||||
mod machines;
|
||||
mod monitor;
|
||||
mod queries;
|
||||
mod redis;
|
||||
mod regex;
|
||||
mod request;
|
||||
mod response;
|
||||
mod schema;
|
||||
@@ -21,13 +26,18 @@ mod tests;
|
||||
pub use collection::{CollectionTree, Saved, moved};
|
||||
pub use connection_form::{ConnectionForm, TestState};
|
||||
pub use connections::{Connection, ConnectionManager, ConnectionState, Engine};
|
||||
pub use contrast::{ColorContrastChecker, contrast, grades, luminance, over};
|
||||
pub use datum::{Datum, Unread, read_json, read_yaml};
|
||||
pub use document::{JsonViewer, YamlViewer};
|
||||
pub use encode::{Code, Encoder, base64, hex, unbase64, unhex, unpercent};
|
||||
pub use environment::{Environment, EnvironmentSelector};
|
||||
pub use graphql::{GraphQLExplorer, SchemaType, TypeKind};
|
||||
pub use json_editor::JsonEditor;
|
||||
pub use machines::{Container, ContainerList, ContainerState, Pod, PodList, PodPhase};
|
||||
pub use monitor::{Reading, ResourceMonitor};
|
||||
pub use queries::{Outcome, PlanStep, QueryHistory, QueryPlanViewer, QueryRun};
|
||||
pub use redis::{RedisKey, RedisKeyBrowser, RedisValue};
|
||||
pub use regex::{Found, RegexFlag, RegexTester, find};
|
||||
pub use request::{ApiRequest, ApiRequestBuilder, Auth, Method, percent, resolve};
|
||||
pub use response::{ApiResponse, HeadersTable, ResponseViewer, reason, status_tone};
|
||||
pub use schema::{DbTable, ERDiagram, Field, FieldKey, Schema, SchemaTree};
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
data_display::{Meter, Sparkline},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// A measure over time: its name and unit, its readings oldest first, and the most it can reach, when it has a most.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Reading {
|
||||
pub name: SharedString,
|
||||
pub unit: SharedString,
|
||||
pub values: Vec<f32>,
|
||||
pub limit: Option<f32>,
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
pub fn new(
|
||||
name: impl Into<SharedString>,
|
||||
unit: impl Into<SharedString>,
|
||||
values: impl IntoIterator<Item = f32>,
|
||||
) -> Self {
|
||||
let values: Vec<f32> = values.into_iter().collect();
|
||||
assert!(!values.is_empty(), "a reading has values");
|
||||
Self {
|
||||
name: name.into(),
|
||||
unit: unit.into(),
|
||||
values,
|
||||
limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limit(mut self, limit: f32) -> Self {
|
||||
assert!(limit > 0.0, "a limit of {limit}");
|
||||
self.limit = Some(limit);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// What a tile says of a reading: its latest value, and of how much when it has a limit.
|
||||
pub fn reading_line(reading: &Reading) -> String {
|
||||
let latest = *reading.values.last().expect("a reading has values");
|
||||
match &reading.limit {
|
||||
Some(limit) => format!("{latest:.1} of {limit:.0} {}", reading.unit),
|
||||
None => format!("{latest:.1} {}", reading.unit),
|
||||
}
|
||||
}
|
||||
|
||||
/// A machine's readings in tiles: each its name, its latest value, a sparkline of the readings before, and, with a limit, how full it is.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ResourceMonitor {
|
||||
id: ElementId,
|
||||
readings: Vec<Reading>,
|
||||
}
|
||||
|
||||
impl ResourceMonitor {
|
||||
pub fn new(id: impl Into<ElementId>, readings: impl IntoIterator<Item = Reading>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
readings: readings.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ResourceMonitor {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let tiles = self.readings.iter().enumerate().map(|(ix, reading)| {
|
||||
let latest = *reading.values.last().expect("a reading has values");
|
||||
let shown = reading_line(reading);
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(theme.label_width())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_4()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(Ellipsis::new(reading.name.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_end()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Lg))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(shown),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.child(Sparkline::new(reading.values.clone()).area()),
|
||||
),
|
||||
)
|
||||
.children(reading.limit.map(|limit| {
|
||||
Meter::new(
|
||||
(self.id.clone(), format!("meter-{ix}")),
|
||||
"Used",
|
||||
(latest / limit).clamp(0.0, 1.0),
|
||||
)
|
||||
.thresholds(0.75, 0.9)
|
||||
}))
|
||||
});
|
||||
div().flex().flex_wrap().gap_4().children(tiles)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Reading, reading_line};
|
||||
|
||||
#[test]
|
||||
fn a_tile_reads_its_latest_value_and_its_limit() {
|
||||
let memory = Reading::new("Memory", "GB", [3.0, 5.26]);
|
||||
assert_eq!(reading_line(&memory), "5.3 GB");
|
||||
assert_eq!(reading_line(&memory.limit(16.0)), "5.3 of 16 GB");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, ElementId, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
use regex::RegexBuilder;
|
||||
|
||||
use crate::{
|
||||
buttons::{ToggleGroup, ToggleItem},
|
||||
feedback::InlineMessage,
|
||||
forms::{Highlight, Input, RegexInput, TextInput, regex_highlights},
|
||||
primitives::Severity,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
/// A flag a pattern reads with.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RegexFlag {
|
||||
/// `i`: letters match either case.
|
||||
IgnoreCase,
|
||||
/// `m`: `^` and `$` match at each line.
|
||||
MultiLine,
|
||||
/// `s`: `.` matches a line break too.
|
||||
DotAll,
|
||||
/// `x`: spaces and `#` comments in the pattern are ignored.
|
||||
Verbose,
|
||||
}
|
||||
|
||||
impl RegexFlag {
|
||||
pub const ALL: [RegexFlag; 4] = [
|
||||
RegexFlag::IgnoreCase,
|
||||
RegexFlag::MultiLine,
|
||||
RegexFlag::DotAll,
|
||||
RegexFlag::Verbose,
|
||||
];
|
||||
|
||||
pub fn letter(self) -> &'static str {
|
||||
match self {
|
||||
RegexFlag::IgnoreCase => "i",
|
||||
RegexFlag::MultiLine => "m",
|
||||
RegexFlag::DotAll => "s",
|
||||
RegexFlag::Verbose => "x",
|
||||
}
|
||||
}
|
||||
|
||||
fn words(self) -> &'static str {
|
||||
match self {
|
||||
RegexFlag::IgnoreCase => "Ignore case",
|
||||
RegexFlag::MultiLine => "^ and $ at each line",
|
||||
RegexFlag::DotAll => ". matches a line break",
|
||||
RegexFlag::Verbose => "Spaces and comments ignored",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A match: where it lies in the text, and each group's name or number with what it caught.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Found {
|
||||
pub range: Range<usize>,
|
||||
pub groups: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
/// Every match of `pattern` in `text` with `flags`, or why the pattern does not compile.
|
||||
pub fn find(pattern: &str, flags: &[RegexFlag], text: &str) -> Result<Vec<Found>, String> {
|
||||
let regex = RegexBuilder::new(pattern)
|
||||
.case_insensitive(flags.contains(&RegexFlag::IgnoreCase))
|
||||
.multi_line(flags.contains(&RegexFlag::MultiLine))
|
||||
.dot_matches_new_line(flags.contains(&RegexFlag::DotAll))
|
||||
.ignore_whitespace(flags.contains(&RegexFlag::Verbose))
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let names: Vec<String> = regex
|
||||
.capture_names()
|
||||
.enumerate()
|
||||
.skip(1)
|
||||
.map(|(ix, name)| name.map_or(ix.to_string(), str::to_string))
|
||||
.collect();
|
||||
Ok(regex
|
||||
.captures_iter(text)
|
||||
.map(|captures| {
|
||||
let whole = captures.get(0).expect("a match holds itself");
|
||||
Found {
|
||||
range: whole.range(),
|
||||
groups: names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, name)| {
|
||||
(
|
||||
name.clone(),
|
||||
captures.get(ix + 1).map(|group| group.as_str().to_string()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The tester's own fields and flags.
|
||||
struct Bench {
|
||||
pattern: Entity<TextInput>,
|
||||
sample: Entity<TextInput>,
|
||||
flags: Entity<Vec<RegexFlag>>,
|
||||
}
|
||||
|
||||
/// A pattern to try against a sample: the pattern colored as it is read, its flags as toggles, the sample with each match washed, and each match with its groups; a pattern that does not compile says why.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RegexTester {
|
||||
id: ElementId,
|
||||
pattern: SharedString,
|
||||
sample: SharedString,
|
||||
}
|
||||
|
||||
impl RegexTester {
|
||||
/// The pattern and the sample it starts with.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
pattern: impl Into<SharedString>,
|
||||
sample: impl Into<SharedString>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
pattern: pattern.into(),
|
||||
sample: sample.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RegexTester {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (pattern, sample) = (self.pattern.to_string(), self.sample.to_string());
|
||||
let bench = window.use_keyed_state((self.id.clone(), "bench"), cx, move |window, cx| {
|
||||
let flags = cx.new(|_| Vec::new());
|
||||
let pattern = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).highlighter(regex_highlights);
|
||||
input.set_text(pattern, cx);
|
||||
input
|
||||
});
|
||||
let (read, read_flags) = (pattern.clone(), flags.clone());
|
||||
let sample = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).multi_line(4, 10).highlighter(
|
||||
move |text: &str, cx: &App| {
|
||||
let wash = cx.theme().colors.accent.alpha(0.18);
|
||||
let (pattern, flags) = (
|
||||
read.read(cx).text().to_string(),
|
||||
read_flags.read(cx).clone(),
|
||||
);
|
||||
find(&pattern, &flags, text)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|found| {
|
||||
let mut highlight = Highlight::new(cx.theme().colors.fg);
|
||||
highlight.background = Some(wash);
|
||||
(found.range, highlight)
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
);
|
||||
input.set_text(sample, cx);
|
||||
input
|
||||
});
|
||||
Bench {
|
||||
pattern,
|
||||
sample,
|
||||
flags,
|
||||
}
|
||||
});
|
||||
let (pattern, sample, flags) = {
|
||||
let bench = bench.read(cx);
|
||||
(
|
||||
bench.pattern.clone(),
|
||||
bench.sample.clone(),
|
||||
bench.flags.clone(),
|
||||
)
|
||||
};
|
||||
let on: Vec<RegexFlag> = flags.read(cx).clone();
|
||||
let found = find(pattern.read(cx).text(), &on, sample.read(cx).text());
|
||||
let theme = cx.theme();
|
||||
let text = sample.read(cx).text().to_string();
|
||||
let summary = match &found {
|
||||
Err(why) => InlineMessage::new(
|
||||
Severity::Danger,
|
||||
why.lines().last().unwrap_or(why).to_string(),
|
||||
)
|
||||
.into_any_element(),
|
||||
Ok(matches) => {
|
||||
let rows = matches.iter().enumerate().map(|(ix, found)| {
|
||||
let groups: Vec<String> = found
|
||||
.groups
|
||||
.iter()
|
||||
.map(|(name, caught)| {
|
||||
format!("{name}: {}", caught.as_deref().unwrap_or("none"))
|
||||
})
|
||||
.collect();
|
||||
div()
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(format!(
|
||||
"{} · bytes {}..{} · {:?}",
|
||||
ix + 1,
|
||||
found.range.start,
|
||||
found.range.end,
|
||||
&text[found.range.clone()]
|
||||
)),
|
||||
)
|
||||
.children((!groups.is_empty()).then(|| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(groups.join(" · "))
|
||||
}))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.debug_selector({
|
||||
let count = matches.len();
|
||||
move || format!("regex-matches-{count}")
|
||||
})
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(match matches.len() {
|
||||
1 => "1 match".to_string(),
|
||||
count => format!("{count} matches"),
|
||||
}),
|
||||
)
|
||||
.children(rows)
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
let flip = flags.clone();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(theme.label_width())
|
||||
.child(RegexInput::new(&pattern)),
|
||||
)
|
||||
.child(
|
||||
RegexFlag::ALL
|
||||
.iter()
|
||||
.fold(
|
||||
ToggleGroup::new((self.id, "flags")).multiple(),
|
||||
|group, flag| {
|
||||
group.item(
|
||||
ToggleItem::new(flag.letter())
|
||||
.label(flag.letter())
|
||||
.tooltip(flag.words()),
|
||||
)
|
||||
},
|
||||
)
|
||||
.selected(on.iter().map(|flag| flag.letter()))
|
||||
.on_change(move |letters, _, cx| {
|
||||
flip.update(cx, |flags, cx| {
|
||||
*flags = RegexFlag::ALL
|
||||
.into_iter()
|
||||
.filter(|flag| {
|
||||
letters.iter().any(|letter| letter == flag.letter())
|
||||
})
|
||||
.collect();
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(Input::new(&sample))
|
||||
.child(summary)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RegexFlag, find};
|
||||
|
||||
#[test]
|
||||
fn matches_carry_their_groups_and_flags_change_what_matches() {
|
||||
let found = find(
|
||||
r"(?P<user>\w+)@(\w+)\.com",
|
||||
&[],
|
||||
"ada@example.com, bob@test.com",
|
||||
)
|
||||
.expect("a pattern");
|
||||
assert_eq!(found.len(), 2);
|
||||
assert_eq!(found[0].range, 0..15);
|
||||
assert_eq!(
|
||||
found[0].groups,
|
||||
[
|
||||
("user".to_string(), Some("ada".to_string())),
|
||||
("2".to_string(), Some("example".to_string()))
|
||||
]
|
||||
);
|
||||
assert_eq!(find("hello", &[], "Hello").expect("a pattern").len(), 0);
|
||||
assert_eq!(
|
||||
find("hello", &[RegexFlag::IgnoreCase], "Hello")
|
||||
.expect("a pattern")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(find("(", &[], "x").is_err());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use crate::{editor::CodeEditor, forms, primitives::FocusNext, theme::Theme};
|
||||
|
||||
mod api;
|
||||
mod formats;
|
||||
mod tools;
|
||||
|
||||
type Part = fn(&Bench, &mut Window, &mut App, Entity<Bench>) -> AnyElement;
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use gpui::{AnyElement, App, Entity, IntoElement, TestAppContext, Window};
|
||||
|
||||
use super::{Bench, bench, said, say, tab, tap};
|
||||
use crate::devtools::{Container, ContainerList, ContainerState, Encoder, RegexTester};
|
||||
|
||||
fn list(_: &Bench, _: &mut Window, _: &mut App, owner: Entity<Bench>) -> AnyElement {
|
||||
let container = |key: &str, state| Container {
|
||||
key: key.to_string().into(),
|
||||
name: key.to_string().into(),
|
||||
image: "img".into(),
|
||||
state,
|
||||
ports: Vec::new(),
|
||||
cpu: 0.1,
|
||||
memory: 1 << 20,
|
||||
};
|
||||
let (toggled, opened) = (owner.clone(), owner);
|
||||
ContainerList::new(
|
||||
"containers",
|
||||
[
|
||||
container("web", ContainerState::Running),
|
||||
container("db", ContainerState::Exited(0)),
|
||||
],
|
||||
)
|
||||
.on_toggle(move |key, _, cx| say(&toggled, format!("toggle {key}"), cx))
|
||||
.on_open(move |key, _, cx| say(&opened, format!("open {key}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: the list, then web's Stop and db's Start; with no handlers for them, Restart and Logs do not show.
|
||||
#[gpui::test]
|
||||
fn a_rows_stop_acts_on_its_container_alone(cx: &mut TestAppContext) {
|
||||
let (host, cx) = bench(list, cx);
|
||||
tab(2, cx);
|
||||
tap("space", cx);
|
||||
tab(3, cx);
|
||||
tap("space", cx);
|
||||
tab(1, cx);
|
||||
tap("enter", cx);
|
||||
assert_eq!(said(&host, cx), ["toggle web", "toggle db", "open web"]);
|
||||
}
|
||||
|
||||
fn tester(_: &Bench, _: &mut Window, _: &mut App, _: Entity<Bench>) -> AnyElement {
|
||||
RegexTester::new("regex", "a", "Aa").into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: the pattern, then the flags i, m, s and x.
|
||||
#[gpui::test]
|
||||
fn a_flag_changes_what_matches(cx: &mut TestAppContext) {
|
||||
let (_, cx) = bench(tester, cx);
|
||||
assert!(
|
||||
cx.debug_bounds("regex-matches-1").is_some(),
|
||||
"a alone matches once"
|
||||
);
|
||||
tab(2, cx);
|
||||
tap("space", cx);
|
||||
assert!(
|
||||
cx.debug_bounds("regex-matches-2").is_some(),
|
||||
"ignoring case matches A too"
|
||||
);
|
||||
}
|
||||
|
||||
fn encoder(_: &Bench, _: &mut Window, _: &mut App, _: Entity<Bench>) -> AnyElement {
|
||||
Encoder::new("encoder", "aGk=").into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: Base64, URL and Hex, Encode and Decode, the text, then Copy.
|
||||
#[gpui::test]
|
||||
fn decoding_base64_copies_the_text(cx: &mut TestAppContext) {
|
||||
let (_, cx) = bench(encoder, cx);
|
||||
tab(5, cx);
|
||||
tap("space", cx);
|
||||
tab(7, cx);
|
||||
tap("space", cx);
|
||||
let copied = cx
|
||||
.update(|_, cx| cx.read_from_clipboard())
|
||||
.and_then(|item| item.text());
|
||||
assert_eq!(copied.as_deref(), Some("hi"));
|
||||
}
|
||||
@@ -23,6 +23,8 @@ pub enum Cell {
|
||||
Person(SharedString),
|
||||
/// A label, and the address it opens.
|
||||
Link(SharedString, SharedString),
|
||||
/// A length of time in seconds, read in its two largest units; it sorts by length.
|
||||
Duration(u64),
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
@@ -34,6 +36,9 @@ impl Cell {
|
||||
}
|
||||
Self::Number(value) => value.to_string().into(),
|
||||
Self::Progress(share) => share.to_string().into(),
|
||||
Self::Duration(seconds) => {
|
||||
format::duration(*seconds, format::DurationStyle::Compact).into()
|
||||
}
|
||||
Self::Empty | Self::Spark(_) => SharedString::default(),
|
||||
}
|
||||
}
|
||||
@@ -43,6 +48,7 @@ impl Cell {
|
||||
match self {
|
||||
Self::Number(value) => Some(*value),
|
||||
Self::Progress(share) => Some(f64::from(*share)),
|
||||
Self::Duration(seconds) => Some(*seconds as f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -241,6 +247,9 @@ pub(crate) fn draw(cell: &Cell, column: &Column, id: ElementId, cx: &App) -> Any
|
||||
.child(Avatar::new(id, name.clone()).size(AvatarSize::Xs))
|
||||
.child(Ellipsis::new(name.clone()))
|
||||
.into_any_element(),
|
||||
Cell::Duration(seconds) => tabular(div())
|
||||
.child(format::duration(*seconds, format::DurationStyle::Compact))
|
||||
.into_any_element(),
|
||||
Cell::Link(label, url) => {
|
||||
ExternalLink::new(id, label.clone(), url.clone()).into_any_element()
|
||||
}
|
||||
|
||||
@@ -175,6 +175,16 @@ pub fn to_csv(columns: &[Column], rows: &[Row]) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn durations_sort_by_length_not_by_their_words() {
|
||||
let rows: Vec<Row> = [("a", 600), ("b", 5_100), ("c", 90_000)]
|
||||
.into_iter()
|
||||
.map(|(key, seconds)| Row::new(key, [Cell::Duration(seconds)]))
|
||||
.collect();
|
||||
assert_eq!(sorted_by(&rows, vec![0, 1, 2], &[(0, true)]), [0, 1, 2]);
|
||||
assert_eq!(rows[1].cells[0].words(), "1h 25m");
|
||||
}
|
||||
|
||||
fn table() -> (Vec<Column>, Vec<Row>) {
|
||||
let columns = vec![
|
||||
Column::new("name", "Name"),
|
||||
|
||||
@@ -28,4 +28,4 @@ pub use link::{ExternalLink, Link};
|
||||
pub use math::{Latex, MathError};
|
||||
pub use rolling::AnimatedNumber;
|
||||
pub use select::SelectableText;
|
||||
pub use text::{Caption, Heading, Label, Overline, Paragraph, Subtitle, Title, tabular};
|
||||
pub use text::{Caption, Heading, Label, Overline, Paragraph, Subtitle, Title, literal, tabular};
|
||||
@@ -17,6 +17,16 @@ pub fn tabular<E: Styled>(mut element: E) -> E {
|
||||
element
|
||||
}
|
||||
|
||||
/// Each character drawn as itself, without ligatures: `==` stays two signs in encoded text and keys.
|
||||
pub fn literal<E: Styled>(mut element: E) -> E {
|
||||
let features = FontFeatures(Arc::new(vec![("calt".into(), 0), ("liga".into(), 0)]));
|
||||
element
|
||||
.text_style()
|
||||
.get_or_insert_with(Default::default)
|
||||
.font_features = Some(features);
|
||||
element
|
||||
}
|
||||
|
||||
struct Spec {
|
||||
size: TextSize,
|
||||
weight: FontWeight,
|
||||
|
||||
+6
-6
@@ -114,12 +114,12 @@
|
||||
|
||||
## T33d DB & Dev Tools · Tools and machines
|
||||
|
||||
- [ ] RegexTester
|
||||
- [ ] Base64 / Encoder
|
||||
- [ ] ColorContrastChecker
|
||||
- [ ] ContainerList
|
||||
- [ ] PodList
|
||||
- [ ] ResourceMonitor
|
||||
- [x] RegexTester
|
||||
- [x] Base64 / Encoder
|
||||
- [x] ColorContrastChecker
|
||||
- [x] ContainerList
|
||||
- [x] PodList
|
||||
- [x] ResourceMonitor
|
||||
|
||||
## T34 Dashboard — `src/dashboard`
|
||||
|
||||
|
||||
Reference in new issue
Block a user