T07: box drawing, block elements and powerline shapes painted as geometry, after Ghostty's sprite rules
This commit is contained in:
7 files changed
+738
-17
No files matched your search
@@ -20,7 +20,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into shapes, `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize.
|
||||
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title.
|
||||
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
|
||||
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||
@@ -43,4 +43,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-14: on Unix the pty is ELYTerminal's own: alacritty 0.26 ignores `chdir`'s result, so a session could start in the wrong folder. Its traits feed alacritty's event loop, keyed 0 and 1 as alacritty's private constants are; alacritty is pinned to =0.26.0 for that.
|
||||
- 2026-10-14: bash integration loads through `--rcfile`, not Ghostty's `--posix` and `ENV`: macOS's bash 3.2 ignores `ENV` there. bash then runs as a non-login shell; on macOS the script reads the login files itself, so `logout` and `~/.bash_logout` do not apply.
|
||||
- 2026-10-14: icons fall back by hand: a private-use character the font lacks paints in JetBrains Mono NF Mono. gpui's macOS fallbacks go through CoreText's cascade list, which never sees fonts added in memory, and gpui loads no font without an `m`, so the symbols-only Nerd Font cannot load.
|
||||
- 2026-10-14: box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) follow Ghostty's sprite rules (MIT, `LICENSE-GHOSTTY`): whole device pixels, line thickness the underline's, each cell clipped to itself, so lines join across cells whatever the font.
|
||||
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,373 @@
|
||||
/// One step of a path, in device pixels from the cell's top-left.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Step {
|
||||
Move(f32, f32),
|
||||
Line(f32, f32),
|
||||
/// A cubic curve: two control points, then the end.
|
||||
Curve([f32; 6]),
|
||||
}
|
||||
|
||||
/// A piece of a drawn character, in device pixels from the cell's top-left.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Shape {
|
||||
/// A rectangle filled at `alpha` of the text color.
|
||||
Rect {
|
||||
x: u32,
|
||||
y: u32,
|
||||
w: u32,
|
||||
h: u32,
|
||||
alpha: f32,
|
||||
},
|
||||
/// A closed outline, filled.
|
||||
Fill(Vec<Step>),
|
||||
/// An open path, stroked this wide with butt ends.
|
||||
Stroke(Vec<Step>, f32),
|
||||
}
|
||||
|
||||
/// Whether the terminal draws `ch` as shapes instead of through the font.
|
||||
pub fn drawn(ch: char) -> bool {
|
||||
matches!(ch, '\u{2500}'..='\u{259F}' | '\u{E0B0}'..='\u{E0B7}')
|
||||
}
|
||||
|
||||
const NONE: u8 = 0;
|
||||
const LIGHT: u8 = 1;
|
||||
const HEAVY: u8 = 2;
|
||||
const DOUBLE: u8 = 3;
|
||||
|
||||
/// Each line character's arms, up, right, down, left, from U+2500.
|
||||
#[rustfmt::skip]
|
||||
const LINES: [[u8; 4]; 128] = [
|
||||
[0, 1, 0, 1], [0, 2, 0, 2], [1, 0, 1, 0], [2, 0, 2, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
|
||||
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 1, 1, 0], [0, 2, 1, 0], [0, 1, 2, 0], [0, 2, 2, 0],
|
||||
[0, 0, 1, 1], [0, 0, 1, 2], [0, 0, 2, 1], [0, 0, 2, 2], [1, 1, 0, 0], [1, 2, 0, 0], [2, 1, 0, 0], [2, 2, 0, 0],
|
||||
[1, 0, 0, 1], [1, 0, 0, 2], [2, 0, 0, 1], [2, 0, 0, 2], [1, 1, 1, 0], [1, 2, 1, 0], [2, 1, 1, 0], [1, 1, 2, 0],
|
||||
[2, 1, 2, 0], [2, 2, 1, 0], [1, 2, 2, 0], [2, 2, 2, 0], [1, 0, 1, 1], [1, 0, 1, 2], [2, 0, 1, 1], [1, 0, 2, 1],
|
||||
[2, 0, 2, 1], [2, 0, 1, 2], [1, 0, 2, 2], [2, 0, 2, 2], [0, 1, 1, 1], [0, 1, 1, 2], [0, 2, 1, 1], [0, 2, 1, 2],
|
||||
[0, 1, 2, 1], [0, 1, 2, 2], [0, 2, 2, 1], [0, 2, 2, 2], [1, 1, 0, 1], [1, 1, 0, 2], [1, 2, 0, 1], [1, 2, 0, 2],
|
||||
[2, 1, 0, 1], [2, 1, 0, 2], [2, 2, 0, 1], [2, 2, 0, 2], [1, 1, 1, 1], [1, 1, 1, 2], [1, 2, 1, 1], [1, 2, 1, 2],
|
||||
[2, 1, 1, 1], [1, 1, 2, 1], [2, 1, 2, 1], [2, 1, 1, 2], [2, 2, 1, 1], [1, 1, 2, 2], [1, 2, 2, 1], [2, 2, 1, 2],
|
||||
[1, 2, 2, 2], [2, 1, 2, 2], [2, 2, 2, 1], [2, 2, 2, 2], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
|
||||
[0, 3, 0, 3], [3, 0, 3, 0], [0, 3, 1, 0], [0, 1, 3, 0], [0, 3, 3, 0], [0, 0, 1, 3], [0, 0, 3, 1], [0, 0, 3, 3],
|
||||
[1, 3, 0, 0], [3, 1, 0, 0], [3, 3, 0, 0], [1, 0, 0, 3], [3, 0, 0, 1], [3, 0, 0, 3], [1, 3, 1, 0], [3, 1, 3, 0],
|
||||
[3, 3, 3, 0], [1, 0, 1, 3], [3, 0, 3, 1], [3, 0, 3, 3], [0, 3, 1, 3], [0, 1, 3, 1], [0, 3, 3, 3], [1, 3, 0, 3],
|
||||
[3, 1, 0, 1], [3, 3, 0, 3], [1, 3, 1, 3], [3, 1, 3, 1], [3, 3, 3, 3], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
|
||||
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 1], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0],
|
||||
[0, 0, 0, 2], [2, 0, 0, 0], [0, 2, 0, 0], [0, 0, 2, 0], [0, 2, 0, 1], [1, 0, 2, 0], [0, 1, 0, 2], [2, 0, 1, 0],
|
||||
];
|
||||
|
||||
/// Quadrant blocks from U+2596: top-left, top-right, bottom-left, bottom-right bits.
|
||||
const QUADRANTS: [u8; 10] = [
|
||||
0b0010, 0b0001, 0b1000, 0b1011, 0b1001, 0b1110, 0b1101, 0b0100, 0b0110, 0b0111,
|
||||
];
|
||||
|
||||
/// The shapes of a `drawn` char in a `w` by `h` cell whose light lines are `t` thick.
|
||||
pub fn shapes(ch: char, w: u32, h: u32, t: u32) -> Vec<Shape> {
|
||||
let code = ch as u32;
|
||||
match code {
|
||||
0x2504..=0x250B | 0x254C..=0x254F => dashes(code, w, h, t),
|
||||
0x256D..=0x2570 => arc(code, w, h, t),
|
||||
0x2571..=0x2573 => diagonals(code, w, h, t),
|
||||
0x2500..=0x257F => lines(LINES[(code - 0x2500) as usize], w, h, t),
|
||||
0x2580..=0x259F => blocks(code, w, h),
|
||||
0xE0B0..=0xE0B7 => powerline(code, w, h, t),
|
||||
_ => panic!("U+{code:04X} is not a drawn character"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(x0: u32, y0: u32, x1: u32, y1: u32) -> Shape {
|
||||
Shape::Rect {
|
||||
x: x0,
|
||||
y: y0,
|
||||
w: x1.saturating_sub(x0),
|
||||
h: y1.saturating_sub(y0),
|
||||
alpha: 1.,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an arm ends at the center, by the arms crossing it: `[heavy, double, light, short]`.
|
||||
fn stop((a, b): (u8, u8), straight: bool, [heavy, double, light, short]: [u32; 4]) -> u32 {
|
||||
if a == HEAVY || b == HEAVY {
|
||||
heavy
|
||||
} else if a != b || straight {
|
||||
if a == DOUBLE || b == DOUBLE {
|
||||
double
|
||||
} else {
|
||||
light
|
||||
}
|
||||
} else if a == NONE && b == NONE {
|
||||
light
|
||||
} else {
|
||||
short
|
||||
}
|
||||
}
|
||||
|
||||
/// Arms from the center to the edges, joined as Ghostty joins them.
|
||||
fn lines([up, right, down, left]: [u8; 4], w: u32, h: u32, t: u32) -> Vec<Shape> {
|
||||
let heavy = 2 * t;
|
||||
let h_light_top = h.saturating_sub(t) / 2;
|
||||
let h_light_bottom = h_light_top + t;
|
||||
let h_heavy_top = h.saturating_sub(heavy) / 2;
|
||||
let h_heavy_bottom = h_heavy_top + heavy;
|
||||
let h_double_top = h_light_top.saturating_sub(t);
|
||||
let h_double_bottom = h_light_bottom + t;
|
||||
let v_light_left = w.saturating_sub(t) / 2;
|
||||
let v_light_right = v_light_left + t;
|
||||
let v_heavy_left = w.saturating_sub(heavy) / 2;
|
||||
let v_heavy_right = v_heavy_left + heavy;
|
||||
let v_double_left = v_light_left.saturating_sub(t);
|
||||
let v_double_right = v_light_right + t;
|
||||
let up_bottom = stop(
|
||||
(left, right),
|
||||
down == up,
|
||||
[h_heavy_bottom, h_double_bottom, h_light_bottom, h_light_top],
|
||||
);
|
||||
let down_top = stop(
|
||||
(left, right),
|
||||
up == down,
|
||||
[h_heavy_top, h_double_top, h_light_top, h_light_bottom],
|
||||
);
|
||||
let left_right = stop(
|
||||
(up, down),
|
||||
left == right,
|
||||
[v_heavy_right, v_double_right, v_light_right, v_light_left],
|
||||
);
|
||||
let right_left = stop(
|
||||
(up, down),
|
||||
right == left,
|
||||
[v_heavy_left, v_double_left, v_light_left, v_light_right],
|
||||
);
|
||||
let (top_double, bottom_double) = (up == DOUBLE, down == DOUBLE);
|
||||
let mut out = Vec::new();
|
||||
match up {
|
||||
LIGHT => out.push(rect(v_light_left, 0, v_light_right, up_bottom)),
|
||||
HEAVY => out.push(rect(v_heavy_left, 0, v_heavy_right, up_bottom)),
|
||||
DOUBLE => {
|
||||
let stop = |side| {
|
||||
if side == DOUBLE {
|
||||
h_light_top
|
||||
} else {
|
||||
up_bottom
|
||||
}
|
||||
};
|
||||
out.push(rect(v_double_left, 0, v_light_left, stop(left)));
|
||||
out.push(rect(v_light_right, 0, v_double_right, stop(right)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match right {
|
||||
LIGHT => out.push(rect(right_left, h_light_top, w, h_light_bottom)),
|
||||
HEAVY => out.push(rect(right_left, h_heavy_top, w, h_heavy_bottom)),
|
||||
DOUBLE => {
|
||||
let start = |double| if double { v_light_right } else { right_left };
|
||||
out.push(rect(start(top_double), h_double_top, w, h_light_top));
|
||||
out.push(rect(
|
||||
start(bottom_double),
|
||||
h_light_bottom,
|
||||
w,
|
||||
h_double_bottom,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match down {
|
||||
LIGHT => out.push(rect(v_light_left, down_top, v_light_right, h)),
|
||||
HEAVY => out.push(rect(v_heavy_left, down_top, v_heavy_right, h)),
|
||||
DOUBLE => {
|
||||
let start = |side| {
|
||||
if side == DOUBLE {
|
||||
h_light_bottom
|
||||
} else {
|
||||
down_top
|
||||
}
|
||||
};
|
||||
out.push(rect(v_double_left, start(left), v_light_left, h));
|
||||
out.push(rect(v_light_right, start(right), v_double_right, h));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match left {
|
||||
LIGHT => out.push(rect(0, h_light_top, left_right, h_light_bottom)),
|
||||
HEAVY => out.push(rect(0, h_heavy_top, left_right, h_heavy_bottom)),
|
||||
DOUBLE => {
|
||||
let end = |double| if double { v_light_left } else { left_right };
|
||||
out.push(rect(0, h_double_top, end(top_double), h_light_top));
|
||||
out.push(rect(0, h_light_bottom, end(bottom_double), h_double_bottom));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Dashes that tile across cells; a cell too small for them takes a solid light line.
|
||||
fn dashes(code: u32, w: u32, h: u32, t: u32) -> Vec<Shape> {
|
||||
let wide = t.max(4);
|
||||
let (count, thick, gap, vertical) = match code {
|
||||
0x2504 => (3, t, wide, false),
|
||||
0x2505 => (3, 2 * t, wide, false),
|
||||
0x2506 => (3, t, wide, true),
|
||||
0x2507 => (3, 2 * t, wide, true),
|
||||
0x2508 => (4, t, wide, false),
|
||||
0x2509 => (4, 2 * t, wide, false),
|
||||
0x250A => (4, t, wide, true),
|
||||
0x250B => (4, 2 * t, wide, true),
|
||||
0x254C => (2, t, t, false),
|
||||
0x254D => (2, 2 * t, 2 * t, false),
|
||||
0x254E => (2, t, 2 * t, true),
|
||||
_ => (2, 2 * t, 2 * t, true),
|
||||
};
|
||||
let (along, across) = if vertical { (h, w) } else { (w, h) };
|
||||
if along < 2 * count {
|
||||
let solid = if vertical {
|
||||
[LIGHT, 0, LIGHT, 0]
|
||||
} else {
|
||||
[0, LIGHT, 0, LIGHT]
|
||||
};
|
||||
return lines(solid, w, h, t);
|
||||
}
|
||||
let gap = gap.min(along / (2 * count));
|
||||
let total = along - count * gap;
|
||||
let (dash, mut extra) = (total / count, total % count);
|
||||
let side = across.saturating_sub(thick) / 2;
|
||||
let mut at = if vertical { 0 } else { gap / 2 };
|
||||
(0..count)
|
||||
.map(|_| {
|
||||
let end = at + dash + u32::from(extra > 0);
|
||||
extra = extra.saturating_sub(1);
|
||||
let piece = if vertical {
|
||||
rect(side, at, side + thick, end)
|
||||
} else {
|
||||
rect(at, side, end, side + thick)
|
||||
};
|
||||
at = end + gap;
|
||||
piece
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A rounded corner from the light lines' centers to two edges.
|
||||
fn arc(code: u32, w: u32, h: u32, t: u32) -> Vec<Shape> {
|
||||
let (wf, hf, tf) = (w as f32, h as f32, t as f32);
|
||||
let cx = (w.saturating_sub(t) / 2) as f32 + tf / 2.;
|
||||
let cy = (h.saturating_sub(t) / 2) as f32 + tf / 2.;
|
||||
let r = wf.min(hf) / 2.;
|
||||
let s = 0.25;
|
||||
let [dy, dx]: [f32; 2] = [[1., 1.], [1., -1.], [-1., -1.], [-1., 1.]][(code - 0x256D) as usize];
|
||||
let (edge_x, edge_y) = (if dx > 0. { wf } else { 0. }, if dy > 0. { hf } else { 0. });
|
||||
let path = vec![
|
||||
Step::Move(cx, edge_y),
|
||||
Step::Line(cx, cy + dy * r),
|
||||
Step::Curve([cx, cy + dy * s * r, cx + dx * s * r, cy, cx + dx * r, cy]),
|
||||
Step::Line(edge_x, cy),
|
||||
];
|
||||
vec![Shape::Stroke(path, tf)]
|
||||
}
|
||||
|
||||
/// Corner-to-corner lines, run half a pixel past so neighbors join once clipped.
|
||||
fn diagonals(code: u32, w: u32, h: u32, t: u32) -> Vec<Shape> {
|
||||
let (wf, hf) = (w as f32, h as f32);
|
||||
let (sx, sy) = ((wf / hf).min(1.) / 2., (hf / wf).min(1.) / 2.);
|
||||
let line = |from: (f32, f32), to: (f32, f32)| {
|
||||
Shape::Stroke(
|
||||
vec![Step::Move(from.0, from.1), Step::Line(to.0, to.1)],
|
||||
t as f32,
|
||||
)
|
||||
};
|
||||
let rising = line((wf + sx, -sy), (-sx, hf + sy));
|
||||
let falling = line((-sx, -sy), (wf + sx, hf + sy));
|
||||
match code {
|
||||
0x2571 => vec![rising],
|
||||
0x2572 => vec![falling],
|
||||
_ => vec![rising, falling],
|
||||
}
|
||||
}
|
||||
|
||||
/// Block elements: eighths, halves, shades and quadrants.
|
||||
fn blocks(code: u32, w: u32, h: u32) -> Vec<Shape> {
|
||||
let part = |share: f32, side: u32| (side as f32 * share).round() as u32;
|
||||
let eighths = |n: u32| n as f32 / 8.;
|
||||
let shade = |alpha| Shape::Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w,
|
||||
h,
|
||||
alpha,
|
||||
};
|
||||
match code {
|
||||
0x2580 => vec![rect(0, 0, w, part(0.5, h))],
|
||||
0x2581..=0x2587 => vec![rect(0, h - part(eighths(code - 0x2580), h), w, h)],
|
||||
0x2588 => vec![rect(0, 0, w, h)],
|
||||
0x2589..=0x258F => vec![rect(0, 0, part(eighths(0x2590 - code), w), h)],
|
||||
0x2590 => vec![rect(w - part(0.5, w), 0, w, h)],
|
||||
0x2591..=0x2593 => vec![shade((code - 0x2590) as f32 / 4.)],
|
||||
0x2594 => vec![rect(0, 0, w, part(0.125, h))],
|
||||
0x2595 => vec![rect(w - part(0.125, w), 0, w, h)],
|
||||
_ => {
|
||||
let bits = QUADRANTS[(code - 0x2596) as usize];
|
||||
let (mx, my) = (part(0.5, w), part(0.5, h));
|
||||
let (nx, ny) = (w - mx, h - my);
|
||||
[
|
||||
rect(0, 0, mx, my),
|
||||
rect(nx, 0, w, my),
|
||||
rect(0, ny, mx, h),
|
||||
rect(nx, ny, w, h),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| bits & (0b1000 >> index) != 0)
|
||||
.map(|(_, piece)| piece)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Powerline's arrows and half circles, filling the cell's height.
|
||||
fn powerline(code: u32, w: u32, h: u32, t: u32) -> Vec<Shape> {
|
||||
let (wf, hf, tf) = (w as f32, h as f32, t as f32);
|
||||
let steps = match code {
|
||||
0xE0B0..=0xE0B3 => vec![
|
||||
Step::Move(0., 0.),
|
||||
Step::Line(wf, hf / 2.),
|
||||
Step::Line(0., hf),
|
||||
],
|
||||
0xE0B4 | 0xE0B6 => half_circle(wf, hf, 0.),
|
||||
_ => half_circle(wf, hf, tf / 2.),
|
||||
};
|
||||
let steps = if matches!(code, 0xE0B2 | 0xE0B3 | 0xE0B6 | 0xE0B7) {
|
||||
mirror(steps, wf)
|
||||
} else {
|
||||
steps
|
||||
};
|
||||
if matches!(code, 0xE0B0 | 0xE0B2 | 0xE0B4 | 0xE0B6) {
|
||||
vec![Shape::Fill(steps)]
|
||||
} else {
|
||||
vec![Shape::Stroke(steps, tf)]
|
||||
}
|
||||
}
|
||||
|
||||
/// The right half of a circle against the left edge, inset `by` so a stroke stays inside.
|
||||
fn half_circle(w: f32, h: f32, by: f32) -> Vec<Step> {
|
||||
let c = (2f32.sqrt() - 1.) * 4. / 3.;
|
||||
let r = w.min(h / 2.) - by;
|
||||
let (top, bottom) = (by, h - by);
|
||||
vec![
|
||||
Step::Move(0., top),
|
||||
Step::Curve([r * c, top, r, top + r - r * c, r, top + r]),
|
||||
Step::Line(r, bottom - r),
|
||||
Step::Curve([r, bottom - r + r * c, r * c, bottom, 0., bottom]),
|
||||
]
|
||||
}
|
||||
|
||||
/// The steps flipped left to right within a cell `w` wide.
|
||||
fn mirror(steps: Vec<Step>, w: f32) -> Vec<Step> {
|
||||
steps
|
||||
.into_iter()
|
||||
.map(|step| match step {
|
||||
Step::Move(x, y) => Step::Move(w - x, y),
|
||||
Step::Line(x, y) => Step::Line(w - x, y),
|
||||
Step::Curve([ax, ay, bx, by, x, y]) => Step::Curve([w - ax, ay, w - bx, by, w - x, y]),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,229 @@
|
||||
use super::*;
|
||||
|
||||
/// Cells as fonts give them: even and odd sides, thin and thick lines.
|
||||
const CELLS: [(u32, u32, u32); 4] = [(16, 32, 2), (9, 19, 1), (20, 41, 3), (7, 15, 1)];
|
||||
|
||||
fn arms(ch: char) -> [u8; 4] {
|
||||
LINES[(ch as u32 - 0x2500) as usize]
|
||||
}
|
||||
|
||||
fn every_drawn() -> impl Iterator<Item = char> {
|
||||
('\u{2500}'..='\u{259F}').chain('\u{E0B0}'..='\u{E0B7}')
|
||||
}
|
||||
|
||||
/// The spans across an edge of the rects touching it; sides go up, right, down, left.
|
||||
fn edge(shapes: &[Shape], side: usize, cell_w: u32, cell_h: u32) -> Vec<(u32, u32)> {
|
||||
let mut spans: Vec<(u32, u32)> = shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match *shape {
|
||||
Shape::Rect { x, y, w, h, .. } => {
|
||||
let touches = [y == 0, x + w == cell_w, y + h == cell_h, x == 0][side];
|
||||
let span = if side.is_multiple_of(2) {
|
||||
(x, x + w)
|
||||
} else {
|
||||
(y, y + h)
|
||||
};
|
||||
touches.then_some(span)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
spans.sort();
|
||||
spans
|
||||
}
|
||||
|
||||
fn points(steps: &[Step]) -> Vec<(f32, f32)> {
|
||||
steps
|
||||
.iter()
|
||||
.flat_map(|step| match *step {
|
||||
Step::Move(x, y) | Step::Line(x, y) => vec![(x, y)],
|
||||
Step::Curve([ax, ay, bx, by, x, y]) => vec![(ax, ay), (bx, by), (x, y)],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_table_reads_as_unicode_names_say() {
|
||||
assert_eq!(arms('─'), [0, 1, 0, 1]);
|
||||
assert_eq!(arms('┃'), [2, 0, 2, 0]);
|
||||
assert_eq!(arms('┍'), [0, 2, 1, 0], "down light and right heavy");
|
||||
assert_eq!(arms('╒'), [0, 3, 1, 0], "down single and right double");
|
||||
assert_eq!(arms('╬'), [3, 3, 3, 3]);
|
||||
assert_eq!(arms('╽'), [1, 0, 2, 0], "light up and heavy down");
|
||||
assert_eq!(arms('╾'), [0, 1, 0, 2], "heavy left and light right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_drawn_char_draws_inside_its_cell() {
|
||||
for (w, h, t) in CELLS {
|
||||
for ch in every_drawn() {
|
||||
let shapes = shapes(ch, w, h, t);
|
||||
assert!(!shapes.is_empty(), "{ch} draws nothing");
|
||||
for shape in &shapes {
|
||||
match shape {
|
||||
Shape::Rect {
|
||||
x, y, w: rw, h: rh, ..
|
||||
} => {
|
||||
assert!(*rw > 0 && *rh > 0, "{ch} has an empty rect at {w}x{h}");
|
||||
assert!(x + rw <= w && y + rh <= h, "{ch} leaves {w}x{h}");
|
||||
}
|
||||
Shape::Fill(steps) | Shape::Stroke(steps, _) => {
|
||||
let reach = t as f32;
|
||||
for (px, py) in points(steps) {
|
||||
let inside = (-reach..=w as f32 + reach).contains(&px)
|
||||
&& (-reach..=h as f32 + reach).contains(&py);
|
||||
assert!(inside, "{ch} reaches ({px}, {py}) in {w}x{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_lines_sit_centered_at_their_weight() {
|
||||
let (w, h, t) = (16, 32, 2);
|
||||
let across = |ch: char, side: usize| edge(&shapes(ch, w, h, t), side, w, h);
|
||||
assert_eq!(across('─', 1), [(15, 17)]);
|
||||
assert_eq!(across('━', 1), [(14, 18)]);
|
||||
assert_eq!(across('═', 1), [(13, 15), (17, 19)]);
|
||||
assert_eq!(across('│', 2), [(7, 9)]);
|
||||
assert_eq!(across('┃', 2), [(6, 10)]);
|
||||
assert_eq!(across('║', 2), [(5, 7), (9, 11)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_arm_meets_the_straight_line_of_its_weight() {
|
||||
for (w, h, t) in CELLS {
|
||||
let across = [['│', '┃', '║'], ['─', '━', '═']];
|
||||
for code in 0x2500..=0x257F_u32 {
|
||||
let ch = char::from_u32(code).expect("a char");
|
||||
let weights = arms(ch);
|
||||
if weights == [0; 4] {
|
||||
continue;
|
||||
}
|
||||
let shapes = shapes(ch, w, h, t);
|
||||
for (side, weight) in weights.into_iter().enumerate() {
|
||||
let found = edge(&shapes, side, w, h);
|
||||
if weight == 0 {
|
||||
assert!(found.is_empty(), "{ch} touches side {side} with no arm");
|
||||
continue;
|
||||
}
|
||||
let straight = across[side % 2][usize::from(weight) - 1];
|
||||
let want = edge(&super::shapes(straight, w, h, t), side, w, h);
|
||||
assert_eq!(
|
||||
found, want,
|
||||
"{ch}'s side {side} misses {straight} in {w}x{h}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_tile_with_one_gap_between_cells() {
|
||||
for (w, h, t) in CELLS {
|
||||
for (dashes, count) in [("┄┅┆┇", 3), ("┈┉┊┋", 4), ("╌╍╎╏", 2)] {
|
||||
for ch in dashes.chars() {
|
||||
let vertical = "┆┇┊┋╎╏".contains(ch);
|
||||
let side = if vertical { h } else { w };
|
||||
let drawn = shapes(ch, w, h, t);
|
||||
if side < 2 * count {
|
||||
let solid = if vertical { '│' } else { '─' };
|
||||
assert_eq!(drawn, shapes(solid, w, h, t), "{ch} at {w}x{h} is solid");
|
||||
continue;
|
||||
}
|
||||
let mut runs: Vec<(u32, u32)> = drawn
|
||||
.iter()
|
||||
.map(|shape| match *shape {
|
||||
Shape::Rect { y, h: rh, .. } if vertical => (y, y + rh),
|
||||
Shape::Rect { x, w: rw, .. } => (x, x + rw),
|
||||
_ => panic!("{ch} is drawn with rects"),
|
||||
})
|
||||
.collect();
|
||||
runs.sort();
|
||||
assert_eq!(runs.len() as u32, count, "{ch} at {w}x{h}");
|
||||
let gap = runs[1].0 - runs[0].1;
|
||||
assert!(gap > 0, "{ch} has no gap in {w}x{h}");
|
||||
for pair in runs.windows(2) {
|
||||
assert_eq!(pair[1].0 - pair[0].1, gap, "{ch} gaps unevenly");
|
||||
}
|
||||
let lead = if vertical { 0 } else { gap / 2 };
|
||||
assert_eq!(runs[0].0, lead, "{ch} starts off center at {w}x{h}");
|
||||
let margins = runs[0].0 + side - runs[runs.len() - 1].1;
|
||||
assert_eq!(
|
||||
margins, gap,
|
||||
"{ch} breaks the rhythm across cells at {w}x{h}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halves_cover_the_cell_and_shades_wash_it() {
|
||||
for (w, h, t) in CELLS {
|
||||
let span = |ch: char| match shapes(ch, w, h, t)[0] {
|
||||
Shape::Rect {
|
||||
x, y, w: rw, h: rh, ..
|
||||
} => (x, y, x + rw, y + rh),
|
||||
_ => panic!("{ch} is a rect"),
|
||||
};
|
||||
let (upper, lower) = (span('▀'), span('▄'));
|
||||
assert_eq!((upper.1, lower.3), (0, h));
|
||||
assert!(upper.3 >= lower.1, "a gap between ▀ and ▄ at {w}x{h}");
|
||||
let (left, right) = (span('▌'), span('▐'));
|
||||
assert_eq!((left.0, right.2), (0, w));
|
||||
assert!(left.2 >= right.0, "a gap between ▌ and ▐ at {w}x{h}");
|
||||
for (ch, alpha) in [('░', 0.25), ('▒', 0.5), ('▓', 0.75)] {
|
||||
let want = Shape::Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w,
|
||||
h,
|
||||
alpha,
|
||||
};
|
||||
assert_eq!(shapes(ch, w, h, t), [want]);
|
||||
}
|
||||
assert_eq!(shapes('▚', w, h, t).len(), 2);
|
||||
assert_eq!(shapes('▟', w, h, t).len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powerline_fills_the_cell_and_mirrors() {
|
||||
let (w, h) = (16., 32.);
|
||||
let right = vec![Step::Move(0., 0.), Step::Line(w, h / 2.), Step::Line(0., h)];
|
||||
let left = vec![Step::Move(w, 0.), Step::Line(0., h / 2.), Step::Line(w, h)];
|
||||
assert_eq!(shapes('\u{E0B0}', 16, 32, 2), [Shape::Fill(right.clone())]);
|
||||
assert_eq!(shapes('\u{E0B2}', 16, 32, 2), [Shape::Fill(left)]);
|
||||
assert_eq!(shapes('\u{E0B1}', 16, 32, 2), [Shape::Stroke(right, 2.)]);
|
||||
let Shape::Fill(round) = &shapes('\u{E0B6}', 16, 32, 2)[0] else {
|
||||
panic!("a filled half circle");
|
||||
};
|
||||
assert_eq!(round.first(), Some(&Step::Move(16., 0.)));
|
||||
let Shape::Stroke(outline, 2.) = &shapes('\u{E0B5}', 16, 32, 2)[0] else {
|
||||
panic!("a stroked half circle");
|
||||
};
|
||||
assert_eq!(
|
||||
outline.first(),
|
||||
Some(&Step::Move(0., 1.)),
|
||||
"inset by half a line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arcs_end_on_the_light_lines_centers() {
|
||||
let (w, h, t) = (16, 32, 2);
|
||||
let light = (w - t) / 2;
|
||||
let Shape::Stroke(path, width) = &shapes('╭', w, h, t)[0] else {
|
||||
panic!("╭ is a stroke");
|
||||
};
|
||||
assert_eq!(*width, t as f32);
|
||||
assert_eq!(path.first(), Some(&Step::Move(light as f32 + 1., h as f32)));
|
||||
assert_eq!(
|
||||
path.last(),
|
||||
Some(&Step::Line(w as f32, ((h - t) / 2) as f32 + 1.))
|
||||
);
|
||||
}
|
||||
+34
-3
@@ -6,7 +6,10 @@ use alacritty_terminal::{
|
||||
};
|
||||
use gpui::Hsla;
|
||||
|
||||
use super::ink::{DIM, Ink};
|
||||
use super::{
|
||||
boxes,
|
||||
ink::{DIM, Ink},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Underline {
|
||||
@@ -39,6 +42,8 @@ pub struct Row {
|
||||
pub runs: Vec<(usize, Style)>,
|
||||
/// Cells with combining marks, painted alone at their column.
|
||||
pub clusters: Vec<(usize, String, Style)>,
|
||||
/// Cells the terminal draws as shapes: column, char, style.
|
||||
pub boxes: Vec<(usize, char, Style)>,
|
||||
}
|
||||
|
||||
/// The cursor where it shows: its cell, shape, and what the cell holds.
|
||||
@@ -70,6 +75,9 @@ impl Frame {
|
||||
for (column, cluster, _) in &row.clusters {
|
||||
cells[*column] = cluster.clone();
|
||||
}
|
||||
for (column, ch, _) in &row.boxes {
|
||||
cells[*column] = ch.to_string();
|
||||
}
|
||||
cells.concat().trim_end().to_string()
|
||||
})
|
||||
.collect();
|
||||
@@ -101,7 +109,8 @@ pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink) -> Frame {
|
||||
.chain(marks.iter().copied())
|
||||
.collect::<String>()
|
||||
});
|
||||
let shown = if spacer || hidden || cluster.is_some() {
|
||||
let drawn = !hidden && cluster.is_none() && boxes::drawn(cell.c);
|
||||
let shown = if spacer || hidden || drawn || cluster.is_some() {
|
||||
' '
|
||||
} else {
|
||||
cell.c
|
||||
@@ -113,13 +122,18 @@ pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink) -> Frame {
|
||||
column: point.column.0,
|
||||
shape: content.cursor.shape,
|
||||
wide: cell.flags.contains(Flags::WIDE_CHAR),
|
||||
text: cluster.clone().unwrap_or_else(|| shown.to_string()),
|
||||
text: cluster
|
||||
.clone()
|
||||
.unwrap_or_else(|| if drawn { cell.c } else { shown }.to_string()),
|
||||
style: look,
|
||||
});
|
||||
}
|
||||
if let Some(cluster) = cluster {
|
||||
rows[row].clusters.push((point.column.0, cluster, look));
|
||||
}
|
||||
if drawn {
|
||||
rows[row].boxes.push((point.column.0, cell.c, look));
|
||||
}
|
||||
}
|
||||
Frame { rows, cursor }
|
||||
}
|
||||
@@ -256,6 +270,23 @@ mod tests {
|
||||
assert_eq!(hidden.cursor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawn_characters_leave_the_font() {
|
||||
let (frame, ink) = shown("a\u{2500}\u{E0B0}\x1b[1D".as_bytes());
|
||||
assert_eq!(&frame.rows[0].text[..3], "a ");
|
||||
let drawn: Vec<_> = frame.rows[0]
|
||||
.boxes
|
||||
.iter()
|
||||
.map(|(at, ch, _)| (*at, *ch))
|
||||
.collect();
|
||||
assert_eq!(drawn, [(1, '\u{2500}'), (2, '\u{E0B0}')]);
|
||||
assert_eq!(frame.rows[0].boxes[0].2.fg, ink.fg);
|
||||
assert_eq!(frame.text(), "a\u{2500}\u{E0B0}");
|
||||
assert_eq!(frame.cursor.expect("a cursor").text, "\u{E0B0}");
|
||||
let (hidden, _) = shown(b"\x1b[8m\xe2\x94\x80");
|
||||
assert!(hidden.rows[0].boxes.is_empty(), "hidden lines draw nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_cells_show_nothing_but_their_ground() {
|
||||
let (frame, _) = shown("\x1b[8;4;9;41mSe\u{301}\x1b[0m\x1b[2D".as_bytes());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod boxes;
|
||||
pub mod frame;
|
||||
pub mod ink;
|
||||
mod paint;
|
||||
|
||||
+78
-13
@@ -2,12 +2,13 @@ use std::rc::Rc;
|
||||
|
||||
use alacritty_terminal::vte::ansi::CursorShape;
|
||||
use gpui::{
|
||||
Bounds, Entity, Font, FontId, FontStyle, FontWeight, Hsla, IntoElement, Pixels, Point, Size,
|
||||
StrikethroughStyle, Styled, TextRun, UnderlineStyle, Window, canvas, fill, outline, point, px,
|
||||
size,
|
||||
Bounds, ContentMask, Entity, Font, FontId, FontStyle, FontWeight, Hsla, IntoElement,
|
||||
PathBuilder, Pixels, Point, Size, StrikethroughStyle, Styled, TextRun, UnderlineStyle, Window,
|
||||
canvas, fill, outline, point, px, size,
|
||||
};
|
||||
|
||||
use super::{
|
||||
boxes::{self, Shape, Step},
|
||||
frame::{Cursor, Frame, Style, Underline},
|
||||
view::TerminalView,
|
||||
};
|
||||
@@ -54,12 +55,15 @@ pub fn grid(
|
||||
bounds,
|
||||
frame.rows.first().map(|row| row.text.trim_end())
|
||||
);
|
||||
let scale = window.scale_factor();
|
||||
let snap = |length: Pixels| px((f32::from(length) * scale).round() / scale);
|
||||
let origin = point(snap(bounds.origin.x), snap(bounds.origin.y));
|
||||
for (index, row) in frame.rows.iter().enumerate() {
|
||||
let top = bounds.origin + point(px(0.), cell.height * index as f32);
|
||||
let top = origin + point(px(0.), cell.height * index as f32);
|
||||
paint_row(row, top, &look, window);
|
||||
}
|
||||
if let Some((cursor, shape)) = &cursor {
|
||||
paint_cursor(cursor, *shape, bounds.origin, &look, window);
|
||||
paint_cursor(cursor, *shape, origin, &look, window);
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -121,6 +125,9 @@ fn paint_row(row: &super::frame::Row, top: Point<Pixels>, look: &Look, window: &
|
||||
for (column, cluster, style) in &row.clusters {
|
||||
text(cluster, style.fg, style, at(*column), look, window);
|
||||
}
|
||||
for (column, ch, style) in &row.boxes {
|
||||
paint_drawn(*ch, style.fg, at(*column), look, window);
|
||||
}
|
||||
for (column, columns, style) in spans {
|
||||
let width = cell.width * columns as f32;
|
||||
decorate(at(column), width, &style, look, window);
|
||||
@@ -224,6 +231,57 @@ fn upward(y: Pixels) -> Pixels {
|
||||
y
|
||||
}
|
||||
|
||||
/// Paints `ch` as shapes in `color`, clipped to the cell at `corner`, a device pixel corner.
|
||||
fn paint_drawn(ch: char, color: Hsla, corner: Point<Pixels>, look: &Look, window: &mut Window) {
|
||||
let scale = window.scale_factor();
|
||||
let device = |length: Pixels| (f32::from(length) * scale).round() as u32;
|
||||
let (w, h, t) = (
|
||||
device(look.cell.width),
|
||||
device(look.cell.height),
|
||||
device(look.rule).max(1),
|
||||
);
|
||||
let at = |x: f32, y: f32| corner + point(px(x / scale), px(y / scale));
|
||||
let mask = ContentMask {
|
||||
bounds: Bounds::new(corner, look.cell),
|
||||
};
|
||||
window.with_content_mask(Some(mask), |window| {
|
||||
for piece in boxes::shapes(ch, w, h, t) {
|
||||
let (builder, steps, close) = match piece {
|
||||
Shape::Rect { x, y, w, h, alpha } => {
|
||||
let length = |device: u32| px(device as f32 / scale);
|
||||
let bounds = Bounds::new(at(x as f32, y as f32), size(length(w), length(h)));
|
||||
window.paint_quad(fill(bounds, color.opacity(alpha)));
|
||||
continue;
|
||||
}
|
||||
Shape::Fill(steps) => (PathBuilder::fill(), steps, true),
|
||||
Shape::Stroke(steps, width) => {
|
||||
(PathBuilder::stroke(px(width / scale)), steps, false)
|
||||
}
|
||||
};
|
||||
let mut builder = builder;
|
||||
for step in steps {
|
||||
match step {
|
||||
Step::Move(x, y) => builder.move_to(at(x, y)),
|
||||
Step::Line(x, y) => builder.line_to(at(x, y)),
|
||||
Step::Curve([ax, ay, bx, by, x, y]) => {
|
||||
builder.cubic_bezier_to(at(x, y), at(ax, ay), at(bx, by))
|
||||
}
|
||||
}
|
||||
}
|
||||
if close {
|
||||
builder.close();
|
||||
}
|
||||
match builder.build() {
|
||||
Ok(path) => window.paint_path(path, color),
|
||||
Err(error) => log::error!(
|
||||
"terminal: U+{:04X} failed to tessellate: {error:?}",
|
||||
ch as u32
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Underlines and strikethrough under `width` from `origin`, a cell's top-left.
|
||||
fn decorate(origin: Point<Pixels>, width: Pixels, style: &Style, look: &Look, window: &mut Window) {
|
||||
let rule = look.rule;
|
||||
@@ -297,14 +355,20 @@ fn paint_cursor(
|
||||
match shape {
|
||||
CursorShape::Block => {
|
||||
window.paint_quad(fill(whole, look.cursor));
|
||||
text(
|
||||
&cursor.text,
|
||||
look.cursor_text,
|
||||
&cursor.style,
|
||||
corner,
|
||||
look,
|
||||
window,
|
||||
);
|
||||
let mut chars = cursor.text.chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(ch), None) if boxes::drawn(ch) => {
|
||||
paint_drawn(ch, look.cursor_text, corner, look, window)
|
||||
}
|
||||
_ => text(
|
||||
&cursor.text,
|
||||
look.cursor_text,
|
||||
&cursor.style,
|
||||
corner,
|
||||
look,
|
||||
window,
|
||||
),
|
||||
}
|
||||
}
|
||||
CursorShape::HollowBlock => {
|
||||
window.paint_quad(outline(whole, look.cursor, gpui::BorderStyle::Solid));
|
||||
@@ -349,6 +413,7 @@ mod tests {
|
||||
text: "中 ab".into(),
|
||||
runs: vec![(4, style), (2, bold)],
|
||||
clusters: Vec::new(),
|
||||
boxes: Vec::new(),
|
||||
};
|
||||
let spans: Vec<_> = spans(&row).into_iter().map(|(at, n, _)| (at, n)).collect();
|
||||
assert_eq!(spans, [(0, 2), (2, 2)]);
|
||||
|
||||
Reference in new issue
Block a user