yoda: elements de bloc U+2580-259F peints en quads natifs

Le glyphe de police d'un bloc ne couvre que ~1 em alors que la cellule
fait line_height (~1.4 em) : rangées de blocs trouees par l'interligne,
bords creneles, rendu flou aux echelles fractionnees.

Desormais le caractere est remplace par une espace (aucun glyphe) et le
remplissage voyage dans Metadata jusqu'au tracé, ou terminal_box le
peint en quad GPU couvrant exactement la fraction de cellule (huitiemes,
quadrants, trames alpha). Consecutive cells de meme metadata fusionnent
en un seul quad : jointif par construction, aligne sur la grille, net a
toute echelle. C'est l'approche de kitty/WezTerm. Tout TUI a blocs
profite (bannieres, jauges htop/btop, barres de progression).

Couverture totale de la plage + geometrie bornee, 2 tests.
This commit is contained in:
Lionel DARNIS 2026-09-04 16:53:35 +02:00
parent 41203ab5f1
commit b8fce5a272
2 changed files with 173 additions and 3 deletions

View file

@ -218,11 +218,17 @@ impl TerminalPaneGrid {
}
}
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Metadata {
pub bg: cosmic_text::Color,
pub underline_color: cosmic_text::Color,
pub flags: Flags,
/// Remplissage natif (élément de bloc Unicode) — voir [`BlockFill`].
pub block: Option<BlockFill>,
/// Couleur du remplissage natif. Hors bloc, sans effet ; participant à
/// l'égalité pour que deux blocs adjacents de couleurs différentes ne
/// fusionnent pas en un seul quad.
pub block_color: cosmic_text::Color,
}
impl Metadata {
@ -232,6 +238,8 @@ impl Metadata {
bg,
underline_color,
flags,
block: None,
block_color: underline_color,
}
}
@ -245,6 +253,73 @@ impl Metadata {
fn with_flags(self, flags: Flags) -> Self {
Self { flags, ..self }
}
fn with_block(mut self, block: Option<BlockFill>, color: cosmic_text::Color) -> Self {
self.block = block;
self.block_color = color;
self
}
}
/// Élément de bloc Unicode (U+2580–U+259F) rendu nativement.
///
/// Ces caractères ne passent PAS par le glyphe de la police : le `█` d'une
/// police couvre à peine l'em (≈ 1× la taille de police) alors que la cellule
/// fait `line_height` (≈ 1,4×) — d'où des trous entre les rangées et un rendu
/// crénelé. À la place, chaque bloc est remplacé par une espace (le glyphe
/// disparaît) et peint en quad GPU couvrant exactement la fraction voulue de
/// la cellule : bords alignés sur la grille, jointif par construction, net à
/// toute échelle. C'est l'approche de kitty/WezTerm.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BlockFill {
/// Un ou deux rectangles en huitièmes de cellule : `(x0, x1, y0, y1)`,
/// abscisse et ordonnée depuis le coin haut-gauche. Un rectangle vide
/// (`x0 >= x1` ou `y0 >= y1`) est ignoré au tracé — deux rectangles
/// suffisent pour toute la famille des quadrants.
Rects([(u8, u8, u8, u8); 2]),
/// Trame (`░▒▓`) : rectangle plein, alpha en quarts (1–3).
Shade(u8),
}
/// Mappe un caractère vers son remplissage natif, s'il est un élément de bloc.
pub fn block_fill(c: char) -> Option<BlockFill> {
use BlockFill::{Rects, Shade};
const NONE: (u8, u8, u8, u8) = (8, 8, 8, 8);
match c {
'▀' => Some(Rects([(0, 8, 0, 4), NONE])),
'▁' => Some(Rects([(0, 8, 7, 8), NONE])),
'▂' => Some(Rects([(0, 8, 6, 8), NONE])),
'▃' => Some(Rects([(0, 8, 5, 8), NONE])),
'▄' => Some(Rects([(0, 8, 4, 8), NONE])),
'▅' => Some(Rects([(0, 8, 3, 8), NONE])),
'▆' => Some(Rects([(0, 8, 2, 8), NONE])),
'▇' => Some(Rects([(0, 8, 1, 8), NONE])),
'█' => Some(Rects([(0, 8, 0, 8), NONE])),
'▉' => Some(Rects([(0, 7, 0, 8), NONE])),
'▊' => Some(Rects([(0, 6, 0, 8), NONE])),
'▋' => Some(Rects([(0, 5, 0, 8), NONE])),
'▌' => Some(Rects([(0, 4, 0, 8), NONE])),
'▍' => Some(Rects([(0, 3, 0, 8), NONE])),
'▎' => Some(Rects([(0, 2, 0, 8), NONE])),
'▏' => Some(Rects([(0, 1, 0, 8), NONE])),
'▐' => Some(Rects([(4, 8, 0, 8), NONE])),
'░' => Some(Shade(1)),
'▒' => Some(Shade(2)),
'▓' => Some(Shade(3)),
'▔' => Some(Rects([(0, 8, 0, 1), NONE])),
'▕' => Some(Rects([(7, 8, 0, 8), NONE])),
'▖' => Some(Rects([(0, 4, 4, 8), NONE])),
'▗' => Some(Rects([(4, 8, 4, 8), NONE])),
'▘' => Some(Rects([(0, 4, 0, 4), NONE])),
'▝' => Some(Rects([(4, 8, 0, 4), NONE])),
'▙' => Some(Rects([(0, 4, 0, 8), (4, 8, 4, 8)])),
'▚' => Some(Rects([(0, 4, 0, 4), (4, 8, 4, 8)])),
'▛' => Some(Rects([(0, 8, 0, 4), (0, 4, 4, 8)])),
'▜' => Some(Rects([(0, 8, 0, 4), (4, 8, 4, 8)])),
'▞' => Some(Rects([(4, 8, 0, 4), (0, 4, 4, 8)])),
'▟' => Some(Rects([(0, 8, 4, 8), (4, 8, 0, 4)])),
_ => None,
}
}
pub struct Terminal {
@ -905,9 +980,14 @@ impl Terminal {
}
let start = text.len();
// Tab skip/stop is handled by alacritty_terminal
// Tab skip/stop is handled by alacritty_terminal.
// Les éléments de bloc ne deviennent pas des glyphes : la
// police ne couvre pas la cellule (trous d'interligne) ;
// ils sont peints en quads natifs au tracé, via Metadata.
let block = block_fill(indexed.cell.c);
text.push(match indexed.cell.c {
'\t' => ' ',
c if block.is_some() => ' ',
c => c,
});
if let Some(zerowidth) = indexed.cell.zerowidth() {
@ -1025,6 +1105,7 @@ impl Terminal {
}
let metadata = Metadata::new(bg, fg)
.with_block(block, fg)
.with_flags(flags)
.with_underline_color(underline_color);
let (meta_idx, _) = self.metadata_set.insert_full(metadata);
@ -1344,3 +1425,53 @@ impl Drop for Terminal {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn block_fill_couvre_toute_la_plage_unicode() {
// U+2580–U+259F : chaque élément de bloc a sa géométrie native.
for code in 0x2580..=0x259F {
let c = char::from_u32(code).unwrap();
assert!(
block_fill(c).is_some(),
"{c} (U+{code:04X}) sans remplissage"
);
}
// Hors de la plage : filets, espaces, lettres — pas de remplissage.
for c in [' ', '─', '│', 'M', '●', '\u{25A0}'] {
assert!(
block_fill(c).is_none(),
"{c} ne doit pas être un bloc natif"
);
}
}
#[test]
fn block_fill_geometry_toujours_valide() {
// Fractions croissantes, bornées à la cellule — sinon le quad
// déborderait sur la cellule voisine ou serait rejeté au tracé.
for c in (char::from_u32(0x2580).unwrap()..=char::from_u32(0x259F).unwrap())
.chain(['░', '▒', '▓'].iter().copied())
{
let Some(fill) = block_fill(c) else { continue };
match fill {
BlockFill::Rects(rects) => {
for (x0, x1, y0, y1) in rects {
// Rectangle vide (sentinelle) ou borné à la cellule.
let empty = x0 >= x1 || y0 >= y1;
assert!(
empty || (x1 <= 8 && y1 <= 8),
"{c}: géométrie déborde la cellule"
);
}
}
BlockFill::Shade(quarters) => {
assert!((1..=3).contains(&quarters), "{c}: alpha de trame invalide")
}
}
}
}
}

View file

@ -46,7 +46,11 @@ use std::{
time::{Duration, Instant},
};
use crate::{Action, Terminal, TerminalScroll, menu::MenuState, terminal::Metadata};
use crate::{
Action, Terminal, TerminalScroll,
menu::MenuState,
terminal::{BlockFill, Metadata},
};
const AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(100);
@ -590,6 +594,41 @@ where
);
}
// Éléments de bloc peints nativement : fractions de la
// cellule (ou de la course fusionnée, à géométrie
// séparable), bords calés sur la grille — zéro glyphe,
// zéro interligne.
if let Some(block) = metadata.block {
let span_w = self.end_x - self.start_x;
let mut color = cosmic_text_to_iced_color(metadata.block_color);
let rects = match block {
BlockFill::Rects(rects) => rects,
BlockFill::Shade(quarters) => {
color.a *= f32::from(quarters) / 4.0;
[(0, 8, 0, 8), (8, 8, 8, 8)]
}
};
for (x0, x1, y0, y1) in rects {
if x0 >= x1 || y0 >= y1 {
continue;
}
let left = self.start_x + span_w * f32::from(x0) / 8.0;
let right = self.start_x + span_w * f32::from(x1) / 8.0;
let top = self.line_top + self.line_height * f32::from(y0) / 8.0;
let bottom = self.line_top + self.line_height * f32::from(y1) / 8.0;
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
self.view_position + Vector::new(left.floor(), top),
Size::new(right.ceil() - left.floor(), bottom - top),
),
..Default::default()
},
color,
);
}
}
if !metadata.flags.is_empty() {
let style_line_height = (self.glyph_font_size / 10.0).clamp(1.0, 16.0);