use crate::colorize::{pixel_color, ColorFn, PixelAnimation};
use crate::palette::{self, Quantized};
use crate::text::Rgb as TextRgb;
use iascii::grid::Grid;
use iascii::render::ColorDepth;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl Rect {
pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
Self {
x,
y,
width,
height,
}
}
#[inline]
pub fn contains(&self, x: u32, y: u32) -> bool {
x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height
}
}
#[derive(Debug, Clone)]
pub struct Segment {
pub id: usize,
pub name: Option<String>,
pub bounds: Rect,
pub cell_count: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct SegmentOptions {
pub color_tolerance: u32,
pub min_cells: usize,
pub max_segments: usize,
}
impl Default for SegmentOptions {
fn default() -> Self {
Self {
color_tolerance: 40 * 40 * 3,
min_cells: 3,
max_segments: 64,
}
}
}
#[derive(Debug, Clone)]
pub struct SegmentMap {
width: u32,
height: u32,
segments: Vec<Segment>,
owner: Vec<Option<usize>>,
}
impl SegmentMap {
pub fn detect(grid: &Grid, options: SegmentOptions) -> Self {
let width = grid.width();
let height = grid.height();
let w = width as usize;
let h = height as usize;
let mut owner: Vec<Option<usize>> = vec![None; w * h];
let mut visited = vec![false; w * h];
let mut raw_segments: Vec<(Vec<(u32, u32)>, [f64; 3])> = Vec::new();
for start_y in 0..height {
for start_x in 0..width {
let idx = (start_y as usize) * w + start_x as usize;
if visited[idx] {
continue;
}
let Some(cell) = grid.get(start_x, start_y) else {
continue;
};
if cell.ch == ' ' {
visited[idx] = true;
continue;
}
let mut members: Vec<(u32, u32)> = Vec::new();
let mut sum = [0f64; 3];
let mut stack = vec![(start_x, start_y)];
visited[idx] = true;
while let Some((x, y)) = stack.pop() {
let cur_idx = (y as usize) * w + x as usize;
let Some(c) = grid.get(x, y) else { continue };
let n = members.len().max(1) as f64;
let avg = [sum[0] / n, sum[1] / n, sum[2] / n];
if !members.is_empty()
&& sq_dist(avg, rgb_f64(c.color)) > options.color_tolerance as f64
{
visited[cur_idx] = false;
continue;
}
members.push((x, y));
sum[0] += c.color.r as f64;
sum[1] += c.color.g as f64;
sum[2] += c.color.b as f64;
for (nx, ny) in neighbors(x, y, width, height) {
let nidx = (ny as usize) * w + nx as usize;
if visited[nidx] {
continue;
}
let Some(nc) = grid.get(nx, ny) else { continue };
if nc.ch == ' ' {
visited[nidx] = true;
continue;
}
visited[nidx] = true;
stack.push((nx, ny));
}
}
if !members.is_empty() {
raw_segments.push((members, sum));
}
}
}
raw_segments.retain(|(members, _)| members.len() >= options.min_cells);
if options.max_segments > 0 && raw_segments.len() > options.max_segments {
raw_segments.sort_by_key(|(members, _)| std::cmp::Reverse(members.len()));
raw_segments.truncate(options.max_segments);
raw_segments.sort_by_key(|(members, _)| members[0]);
}
let mut segments = Vec::with_capacity(raw_segments.len());
for (i, (members, _)) in raw_segments.into_iter().enumerate() {
let id = i + 1;
let (min_x, min_y, max_x, max_y) = members.iter().fold(
(u32::MAX, u32::MAX, 0u32, 0u32),
|(minx, miny, maxx, maxy), &(x, y)| {
(minx.min(x), miny.min(y), maxx.max(x), maxy.max(y))
},
);
for &(x, y) in &members {
owner[(y as usize) * w + x as usize] = Some(id);
}
segments.push(Segment {
id,
name: None,
bounds: Rect::new(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1),
cell_count: members.len(),
});
}
Self {
width,
height,
segments,
owner,
}
}
pub fn manual(width: u32, height: u32, regions: Vec<(Option<String>, Rect)>) -> Self {
let w = width as usize;
let h = height as usize;
let mut owner: Vec<Option<usize>> = vec![None; w * h];
let mut segments = Vec::with_capacity(regions.len());
for (i, (name, bounds)) in regions.into_iter().enumerate() {
let id = i + 1;
let clamped = Rect::new(
bounds.x.min(width),
bounds.y.min(height),
bounds.width.min(width.saturating_sub(bounds.x)),
bounds.height.min(height.saturating_sub(bounds.y)),
);
let mut cell_count = 0;
for y in clamped.y..clamped.y + clamped.height {
for x in clamped.x..clamped.x + clamped.width {
owner[(y as usize) * w + x as usize] = Some(id);
cell_count += 1;
}
}
segments.push(Segment {
id,
name,
bounds: clamped,
cell_count,
});
}
Self {
width,
height,
segments,
owner,
}
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn segments(&self) -> &[Segment] {
&self.segments
}
pub fn get(&self, id: usize) -> Option<&Segment> {
self.segments.iter().find(|s| s.id == id)
}
pub fn find_by_name(&self, name: &str) -> Option<&Segment> {
self.segments
.iter()
.find(|s| s.name.as_deref() == Some(name))
}
pub fn segment_id_at(&self, x: u32, y: u32) -> Option<usize> {
if x >= self.width || y >= self.height {
return None;
}
self.owner[(y as usize) * (self.width as usize) + x as usize]
}
pub fn set_name(&mut self, id: usize, name: impl Into<String>) -> bool {
match self.segments.iter_mut().find(|s| s.id == id) {
Some(seg) => {
seg.name = Some(name.into());
true
}
None => false,
}
}
pub fn overlay_ids(&self) -> String {
let mut out = String::with_capacity((self.height as usize) * (self.width as usize + 1));
for y in 0..self.height {
for x in 0..self.width {
match self.segment_id_at(x, y) {
Some(id) => out.push(char::from_digit((id % 10) as u32, 10).unwrap_or('?')),
None => out.push('.'),
}
}
out.push('\n');
}
out
}
}
fn rgb_f64(c: iascii::grid::Rgb) -> [f64; 3] {
[c.r as f64, c.g as f64, c.b as f64]
}
fn sq_dist(a: [f64; 3], b: [f64; 3]) -> f64 {
let dr = a[0] - b[0];
let dg = a[1] - b[1];
let db = a[2] - b[2];
dr * dr + dg * dg + db * db
}
fn neighbors(x: u32, y: u32, width: u32, height: u32) -> impl Iterator<Item = (u32, u32)> {
let mut out = Vec::with_capacity(4);
if x + 1 < width {
out.push((x + 1, y));
}
if x > 0 {
out.push((x - 1, y));
}
if y + 1 < height {
out.push((x, y + 1));
}
if y > 0 {
out.push((x, y - 1));
}
out.into_iter()
}
#[derive(Clone)]
pub enum SegmentStyle {
Bold,
Color(TextRgb),
BoldColor(TextRgb),
Animation(PixelAnimation),
}
impl SegmentStyle {
pub fn custom_animation(
f: impl Fn(usize, usize, u64, char) -> TextRgb + Send + Sync + 'static,
) -> Self {
let cf: ColorFn = Arc::new(f);
Self::Animation(PixelAnimation::Custom(cf))
}
}
#[derive(Default, Clone)]
pub struct SegmentStyles(HashMap<usize, SegmentStyle>);
impl SegmentStyles {
pub fn new() -> Self {
Self::default()
}
pub fn set(mut self, id: usize, style: SegmentStyle) -> Self {
self.0.insert(id, style);
self
}
pub fn set_named(mut self, map: &SegmentMap, name: &str, style: SegmentStyle) -> Self {
if let Some(seg) = map.find_by_name(name) {
self.0.insert(seg.id, style);
}
self
}
pub fn get(&self, id: usize) -> Option<&SegmentStyle> {
self.0.get(&id)
}
}
#[derive(Default, Clone)]
pub struct SequenceStyles(HashMap<(usize, usize), SegmentStyle>);
impl SequenceStyles {
pub fn new() -> Self {
Self::default()
}
pub fn set(mut self, frame_index: usize, segment_id: usize, style: SegmentStyle) -> Self {
self.0.insert((frame_index, segment_id), style);
self
}
pub fn get(&self, frame_index: usize, segment_id: usize) -> Option<&SegmentStyle> {
self.0.get(&(frame_index, segment_id))
}
}
fn cell_style_escape(color: Option<Quantized>, bold: bool) -> Option<String> {
if color.is_none() && !bold {
return None;
}
let mut s = String::new();
if bold {
s.push_str("\x1b[1m");
}
if let Some(c) = color {
s.push_str(&c.escape());
}
Some(s)
}
pub fn render_segments(
grid: &Grid,
map: &SegmentMap,
styles: &SegmentStyles,
depth: Option<ColorDepth>,
frame: u64,
) -> String {
let w = grid.width() as usize;
let h = grid.height() as usize;
let mut out = String::with_capacity(h * (w * 12 + 8));
let mut current: Option<String> = None;
for y in 0..h {
for x in 0..w {
let Some(cell) = grid.get(x as u32, y as u32) else {
continue;
};
if cell.ch == ' ' {
if current.take().is_some() {
out.push_str("\x1b[0m");
}
out.push(' ');
continue;
}
let seg_id = map.segment_id_at(x as u32, y as u32);
let style = seg_id.and_then(|id| styles.get(id));
let seg_bounds = seg_id.and_then(|id| map.get(id)).map(|s| s.bounds);
let (bold, color): (bool, Option<Quantized>) = match (style, depth) {
(None, Some(d)) => (
false,
Some(palette::quantize_for_depth(rgb_tuple(cell.color), d, None)),
),
(None, None) => (false, None),
(Some(SegmentStyle::Bold), Some(d)) => (
true,
Some(palette::quantize_for_depth(rgb_tuple(cell.color), d, None)),
),
(Some(SegmentStyle::Bold), None) => (true, None),
(Some(SegmentStyle::Color(c)), Some(d)) => (
false,
Some(palette::quantize_for_depth((c.0, c.1, c.2), d, None)),
),
(Some(SegmentStyle::Color(_)), None) => (false, None),
(Some(SegmentStyle::BoldColor(c)), Some(d)) => (
true,
Some(palette::quantize_for_depth((c.0, c.1, c.2), d, None)),
),
(Some(SegmentStyle::BoldColor(_)), None) => (true, None),
(Some(SegmentStyle::Animation(anim)), Some(d)) => {
let bounds = seg_bounds.unwrap_or(Rect::new(0, 0, w as u32, h as u32));
let lx = (x as u32).saturating_sub(bounds.x) as usize;
let ly = (y as u32).saturating_sub(bounds.y) as usize;
let rgb = pixel_color(
anim,
lx,
ly,
frame,
cell.ch,
bounds.width.max(1) as usize,
bounds.height.max(1) as usize,
);
(
false,
Some(palette::quantize_for_depth((rgb.0, rgb.1, rgb.2), d, None)),
)
}
(Some(SegmentStyle::Animation(_)), None) => (false, None),
};
let escape = cell_style_escape(color, bold);
if escape != current {
out.push_str("\x1b[0m");
if let Some(e) = &escape {
out.push_str(e);
}
current = escape;
}
out.push(cell.ch);
}
out.push_str("\x1b[0m\n");
current = None;
}
out
}
fn rgb_tuple(c: iascii::grid::Rgb) -> (u8, u8, u8) {
(c.r, c.g, c.b)
}
pub fn render_segment_sequence(
grids: &[Grid],
maps: &[SegmentMap],
styles: &SequenceStyles,
depth: Option<ColorDepth>,
) -> Vec<String> {
grids
.iter()
.zip(maps.iter())
.enumerate()
.map(|(frame_index, (grid, map))| {
let mut per_frame = SegmentStyles::new();
for seg in map.segments() {
if let Some(style) = styles.get(frame_index, seg.id) {
per_frame = per_frame.set(seg.id, style.clone());
}
}
render_segments(grid, map, &per_frame, depth, frame_index as u64)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use iascii::config::{ConfigBuilder as AsciiConfigBuilder, OutputSizing};
use iascii::convert::convert_image;
fn two_block_grid() -> Grid {
let cfg = AsciiConfigBuilder::new()
.output_sizing(OutputSizing::Explicit {
width: 4,
height: 2,
})
.build()
.unwrap();
#[rustfmt::skip]
let buf: [u8; 4 * 2 * 3] = [
255,0,0, 255,0,0, 0,0,255, 0,0,255,
255,0,0, 255,0,0, 0,0,255, 0,0,255,
];
convert_image(4, 2, &buf, &cfg).unwrap()
}
#[test]
fn detect_splits_two_distinct_color_blocks_into_two_segments() {
let grid = two_block_grid();
let map = SegmentMap::detect(&grid, SegmentOptions::default());
assert_eq!(map.segments().len(), 2);
assert_eq!(map.segment_id_at(0, 0), map.segment_id_at(1, 1));
assert_ne!(map.segment_id_at(0, 0), map.segment_id_at(3, 0));
}
#[test]
fn manual_segments_are_numbered_in_given_order_and_last_wins_on_overlap() {
let map = SegmentMap::manual(
10,
10,
vec![
(Some("a".into()), Rect::new(0, 0, 5, 5)),
(Some("b".into()), Rect::new(3, 3, 5, 5)),
],
);
assert_eq!(map.find_by_name("a").unwrap().id, 1);
assert_eq!(map.find_by_name("b").unwrap().id, 2);
assert_eq!(map.segment_id_at(3, 3), Some(2));
assert_eq!(map.segment_id_at(0, 0), Some(1));
}
#[test]
fn overlay_ids_marks_unsegmented_cells_with_a_dot() {
let map = SegmentMap::manual(3, 1, vec![(None, Rect::new(0, 0, 1, 1))]);
let overlay = map.overlay_ids();
assert_eq!(overlay.trim_end(), "1..");
}
#[test]
fn render_segments_only_recolors_styled_segments() {
let grid = two_block_grid();
let map = SegmentMap::detect(&grid, SegmentOptions::default());
let red_id = map.segment_id_at(0, 0).unwrap();
let styles = SegmentStyles::new().set(red_id, SegmentStyle::Color(TextRgb::new(9, 9, 9)));
let out = render_segments(&grid, &map, &styles, Some(ColorDepth::TrueColor), 0);
assert!(out.contains("38;2;9;9;9"));
assert!(out.contains("38;2;0;0;255"));
}
#[test]
fn min_cells_drops_tiny_noise_regions() {
let grid = two_block_grid();
let opts = SegmentOptions {
min_cells: 100,
..SegmentOptions::default()
};
let map = SegmentMap::detect(&grid, opts);
assert!(map.segments().is_empty());
assert_eq!(map.segment_id_at(0, 0), None);
}
}