use anyhow::{Context, bail};
use image::{DynamicImage, GenericImageView, Pixel, Rgba};
use std::{
char::from_digit,
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
io::Cursor,
io::Read,
iter::FromIterator,
path::PathBuf,
};
use crate::{
formats::woven::from_woven,
geometry::{GridKind, Square, Tri},
puzzle::{
self, BACKGROUND, ClueStyle, Color, ColorInfo, Corner, Document, DynPuzzle, DynSolution,
Nono, NonogramFormat, Puzzle, Solution, Triano,
},
};
pub fn load_path(path: &PathBuf, format: Option<NonogramFormat>) -> anyhow::Result<Document> {
let mut bytes = vec![];
if path == &PathBuf::from("-") {
std::io::stdin().read_to_end(&mut bytes)?;
} else {
bytes = std::fs::read(path)?;
}
load(
path.to_str().context("path is not valid UTF-8")?,
bytes,
format,
)
}
pub fn load(
filename: &str,
bytes: Vec<u8>,
format: Option<NonogramFormat>,
) -> anyhow::Result<Document> {
use crate::formats::webpbn::webpbn_to_document;
let input_format = puzzle::infer_format(filename, format);
Ok(match input_format {
NonogramFormat::Html => {
bail!("HTML input is not supported.")
}
NonogramFormat::Image => {
let img = image::load_from_memory(&bytes).context("could not decode image")?;
let solution = image_to_solution(&img);
Document::from_solution(DynSolution::Square(solution), filename.to_string())
}
NonogramFormat::Webpbn => {
let webpbn_string = String::from_utf8(bytes).context("file is not valid UTF-8 text")?;
let mut doc = webpbn_to_document(&webpbn_string)?;
doc.file = filename.to_string();
doc
}
NonogramFormat::CharGrid => {
let grid_string = String::from_utf8(bytes).context("file is not valid UTF-8 text")?;
let solution = char_grid_to_solution(&grid_string);
Document::from_solution(DynSolution::Square(solution), filename.to_string())
}
NonogramFormat::Woven => {
let woven_string = String::from_utf8(bytes).context("file is not valid UTF-8 text")?;
from_woven(&woven_string, filename.to_string())?
}
NonogramFormat::Olsak => {
let olsak_string = String::from_utf8(bytes).context("file is not valid UTF-8 text")?;
let puzzle = olsak_to_puzzle(&olsak_string)?;
Document::from_puzzle(puzzle, filename.to_string())
}
})
}
pub fn image_to_solution(image: &DynamicImage) -> Solution<Square> {
let (width, height) = image.dimensions();
let mut palette = HashMap::<image::Rgba<u8>, ColorInfo>::new();
let mut grid: Vec<Vec<Color>> = vec![vec![BACKGROUND; height as usize]; width as usize];
palette.insert(
image::Rgba::<u8>([255, 255, 255, 255]),
ColorInfo::default_bg(),
);
let mut next_char = 'a';
let mut next_color_idx: u8 = 1;
for y in 0..height {
for x in 0..width {
let pixel: Rgba<u8> = image.get_pixel(x, y);
let color = palette.entry(pixel).or_insert_with(|| {
let this_char = next_char;
let [r, g, b] = pixel.channels()[0..3] else {
panic!("Image with fewer than three channels?")
};
let this_color = Color(next_color_idx);
next_color_idx = next_color_idx.wrapping_add(1);
if r == 0 && g == 0 && b == 0 {
return ColorInfo::default_fg(this_color);
}
next_char = (next_char as u8).wrapping_add(1) as char;
ColorInfo {
ch: this_char,
name: format!("{}{:02X}{:02X}{:02X}", this_char, r, g, b),
rgb: (r, g, b),
color: this_color,
corner: None,
}
});
grid[x as usize][y as usize] = color.color;
}
}
Solution::from_columns(
ClueStyle::Nono, palette
.into_values()
.map(|color_info| (color_info.color, color_info))
.collect(),
grid,
)
}
pub fn char_grid_to_solution(char_grid: &str) -> Solution<Square> {
let mut palette = HashMap::<char, ColorInfo>::new();
let mut any_uppercase = false;
let mut unused_chars = BTreeSet::<char>::new();
for ch in char_grid.chars() {
if ch == '\n' {
continue;
}
unused_chars.insert(ch);
if ch.is_ascii_uppercase() {
any_uppercase = true;
}
}
let mut bg_ch: Option<char> = None;
for possible_bg in [' ', '.', '_', 'w', 'W', '·', '☐', '0', 'x', '⬜'] {
if unused_chars.contains(&possible_bg) {
bg_ch = Some(possible_bg);
}
}
let bg_ch = match bg_ch {
Some(x) => x,
None => {
eprintln!(
"number-loom: Warning: unable to guess which character is supposed to be the background; using the upper-left corner"
);
char_grid.trim_start().chars().next().unwrap()
}
};
palette.insert(
bg_ch,
ColorInfo {
ch: bg_ch,
..ColorInfo::default_bg()
},
);
unused_chars.remove(&bg_ch);
let mut next_color: u8 = 1;
for possible_black in ['#', '.', '■', '█', '1', '⬛', 'B', 'b'] {
if unused_chars.contains(&possible_black) {
palette.insert(possible_black, ColorInfo::default_fg(Color(next_color)));
next_color += 1;
unused_chars.remove(&possible_black);
break;
}
}
let lower_right_tri = HashSet::<char>::from_iter(['◢', '🮞', '◿']);
let lower_left_tri = HashSet::<char>::from_iter(['◣', '🮟', '◺']);
let upper_left_tri = HashSet::<char>::from_iter(['◤', '🮜', '◸']);
let upper_right_tri = HashSet::<char>::from_iter(['◥', '🮝', '◹']);
let mut any_tri = HashSet::<char>::new();
any_tri.extend(lower_right_tri.iter());
any_tri.extend(lower_left_tri.iter());
any_tri.extend(upper_left_tri.iter());
any_tri.extend(upper_right_tri.iter());
let mut unused_colors = BTreeMap::<char, (u8, u8, u8)>::new();
if any_uppercase {
unused_colors.insert('R', (255, 0, 0));
unused_colors.insert('G', (0, 255, 0));
unused_colors.insert('B', (0, 0, 255));
unused_colors.insert('Y', (255, 255, 0));
unused_colors.insert('C', (0, 255, 255));
unused_colors.insert('M', (255, 0, 255));
} else {
unused_colors.insert('r', (255, 0, 0));
unused_colors.insert('g', (0, 255, 0));
unused_colors.insert('b', (0, 0, 255));
unused_colors.insert('y', (255, 255, 0));
unused_colors.insert('c', (0, 255, 255));
unused_colors.insert('m', (255, 0, 255));
}
unused_colors.insert('🟥', (255, 0, 0));
unused_colors.insert('🟩', (0, 255, 0));
unused_colors.insert('🟦', (0, 0, 255));
unused_colors.insert('🟨', (255, 255, 0));
unused_colors.insert('🟧', (255, 165, 0));
unused_colors.insert('🟪', (128, 0, 128));
unused_colors.insert('🟫', (139, 69, 19));
for ch in unused_chars {
if unused_colors.is_empty() {
for i in 1_u8..5_u8 {
unused_colors.insert(from_digit(i.into(), 10).unwrap(), (44 * i, 44 * i, 44 * i));
}
unused_colors.insert('R', (127, 0, 0));
unused_colors.insert('G', (0, 127, 0));
unused_colors.insert('B', (0, 0, 127));
unused_colors.insert('Y', (127, 127, 0));
unused_colors.insert('C', (0, 127, 127));
unused_colors.insert('M', (127, 0, 127));
}
let rgb = unused_colors
.remove(&ch)
.unwrap_or_else(|| unused_colors.pop_first().unwrap().1);
palette.insert(
ch,
ColorInfo {
ch,
name: ch.to_string(),
rgb,
color: Color(next_color),
corner: if any_tri.contains(&ch) {
Some(Corner {
upper: upper_left_tri.contains(&ch) || upper_right_tri.contains(&ch),
left: lower_left_tri.contains(&ch) || upper_left_tri.contains(&ch),
})
} else {
None
},
},
);
next_color += 1;
}
let mut grid: Vec<Vec<Color>> = vec![];
for (y, row) in char_grid
.split("\n")
.filter(|line| !line.is_empty())
.enumerate()
{
for (x, ch) in row.chars().enumerate() {
grid.resize(std::cmp::max(grid.len(), x + 1), vec![]);
let new_height = std::cmp::max(grid[x].len(), y + 1);
grid[x].resize(new_height, BACKGROUND);
grid[x][y] = palette[&ch].color;
}
}
let has_triangles = palette.values().any(|ci| ci.corner.is_some());
let clue_style = if has_triangles {
for color_info in palette.values_mut() {
if color_info.color == BACKGROUND {
continue;
}
color_info.rgb = (0, 0, 0);
}
ClueStyle::Triano
} else {
ClueStyle::Nono
};
Solution::from_columns(
clue_style,
palette
.into_values()
.map(|color_info| (color_info.color, color_info))
.collect(),
grid,
)
}
fn olsak_triddler(
palette: HashMap<Color, ColorInfo>,
mut groups: Vec<Vec<Vec<Nono>>>,
) -> anyhow::Result<Puzzle<Nono, Tri>> {
use crate::geometry::{ClueSet, ClueSetCounts, Geometry, Outline};
let counts = ClueSetCounts {
topleft: groups[0].len(),
bottomleft: groups[1].len(),
bottom: groups[2].len(),
bottomright: groups[3].len(),
topright: groups[4].len(),
top: groups[5].len(),
};
let outline = Outline::from_clue_set_counts(counts)?;
let geometry = Geometry::<Tri>::new(outline);
let mut lines = vec![vec![]; geometry.lane_map().lane_count()];
let assignment = [
(0, ClueSet::TopLeft, false, false),
(1, ClueSet::BottomLeft, false, false),
(2, ClueSet::Bottom, false, true),
(3, ClueSet::BottomRight, false, true),
(4, ClueSet::TopRight, true, false),
(5, ClueSet::Top, true, false),
];
for (group_idx, clue_set, lines_reversed, blocks_reversed) in assignment {
let mut group_lines = std::mem::take(&mut groups[group_idx]);
if lines_reversed {
group_lines.reverse();
}
for (lane, mut clue_line) in geometry
.lanes_in_clue_set(clue_set)
.into_iter()
.zip(group_lines)
{
if blocks_reversed {
clue_line.reverse();
}
lines[lane] = clue_line;
}
}
Ok(Puzzle::triangular(palette, outline, lines))
}
#[derive(Debug, PartialEq, Eq)]
enum OlsakStanza {
Preamble,
Palette,
Dimension(usize),
}
#[derive(Debug, PartialEq, Eq, Hash)]
enum Glue {
NoGlue,
Left,
Right,
}
pub fn olsak_to_puzzle(olsak: &str) -> anyhow::Result<DynPuzzle> {
use Glue::*;
use OlsakStanza::*;
let mut cur_stanza = Preamble;
let mut next_color: u8 = 1;
let named_colors = BTreeMap::<&str, (u8, u8, u8)>::from([
("white", (255, 255, 255)),
("black", (0, 0, 0)),
("red", (255, 0, 0)),
("green", (0, 255, 0)),
("blue", (0, 0, 255)),
("pink", (255, 128, 128)),
("yellow", (255, 255, 0)),
("r", (255, 0, 0)),
("g", (0, 255, 0)),
("b", (0, 0, 255)),
]);
let mut olsak_palette = HashMap::<char, ColorInfo>::new();
let mut olsak_glued_palettes = [
HashMap::<(char, Glue), ColorInfo>::new(),
HashMap::<(char, Glue), ColorInfo>::new(),
];
let mut clue_style = ClueStyle::Nono;
let mut triddler = false;
let mut nono_clues: Vec<Vec<Vec<Nono>>> = vec![vec![]; 6];
let mut triano_clues: Vec<Vec<Vec<Triano>>> = vec![vec![], vec![]];
let rrggbb = regex::Regex::new(r"^#(..)(..)(..)$").unwrap();
let palette_line = regex::Regex::new(r"^\s*(\S):(.)\s+(\S+)\s*(.*)$").unwrap();
for line in olsak.lines() {
if let Some(palette_ch) = line.strip_prefix("#") {
if cur_stanza != Preamble {
bail!("Palette initiator (line beginning with '#') must be the first content");
}
let palette_ch = palette_ch.to_lowercase();
if palette_ch.starts_with("t") {
triddler = true;
} else if palette_ch.starts_with("d") {
cur_stanza = Palette;
} else {
bail!("unrecognized directive: #{palette_ch}");
}
} else if line.starts_with(":") {
cur_stanza = Dimension(if let Dimension(n) = cur_stanza {
n + 1
} else {
0
});
} else if cur_stanza == Preamble {
} else if cur_stanza == Palette {
if line.trim().is_empty() {
continue;
}
let captures = palette_line
.captures(line)
.ok_or(anyhow::anyhow!("Malformed palette line {line}"))?;
let (_, [input_ch, unique_ch, color_name, comment]) = captures.extract();
let parse_glue = |c| match c {
'>' => Right,
'<' => Left,
_ => NoGlue,
};
let rising = color_name.contains('/');
let (corner, unique_ch) = match (color_name.split_once(['/', '\\']), rising) {
(None, _) => (None, unique_ch.chars().next().unwrap()),
(Some(("white", "black")), true) => (
Some(Corner {
upper: false,
left: false,
}),
'◢',
),
(Some(("white", "black")), false) => (
Some(Corner {
upper: true,
left: false,
}),
'◥',
),
(Some(("black", "white")), true) => (
Some(Corner {
upper: true,
left: true,
}),
'◤',
),
(Some(("black", "white")), false) => (
Some(Corner {
upper: false,
left: true,
}),
'◣',
),
(Some((_, _)), _) => {
eprintln!("Unsupported triangle color combination: {color_name}");
(None, unique_ch.chars().next().unwrap())
}
};
let rgb =
if let Some((_, [rs, gs, bs])) = rrggbb.captures(color_name).map(|c| c.extract()) {
(
u8::from_str_radix(rs, 16).context("expected hex digits in color")?,
u8::from_str_radix(gs, 16).context("expected hex digits in color")?,
u8::from_str_radix(bs, 16).context("expected hex digits in color")?,
)
} else if corner.is_some() {
(0, 0, 0) } else if let Some((r, g, b)) = named_colors.get(color_name) {
(*r, *g, *b)
} else if let Some((r, g, b)) = named_colors.get(input_ch) {
(*r, *g, *b)
} else {
(128, 128, 128)
};
let dim_0_glue = comment.chars().next().map(parse_glue).unwrap_or(NoGlue);
let dim_1_glue = comment.chars().nth(1).map(parse_glue).unwrap_or(NoGlue);
if dim_0_glue != NoGlue || dim_1_glue != NoGlue {
clue_style = ClueStyle::Triano;
}
let color = if input_ch == "0" {
BACKGROUND
} else {
Color(next_color)
};
let color_info = ColorInfo {
ch: unique_ch,
name: color_name.to_string(),
rgb,
color,
corner,
};
let input_ch = input_ch.chars().next().unwrap();
if dim_0_glue == NoGlue && dim_1_glue == NoGlue {
olsak_palette.insert(input_ch, color_info);
} else {
assert!(dim_0_glue != NoGlue && dim_1_glue != NoGlue);
olsak_glued_palettes[0].insert((input_ch, dim_0_glue), color_info.clone());
olsak_glued_palettes[1].insert((input_ch, dim_1_glue), color_info);
}
next_color += 1;
} else if let Dimension(d) = cur_stanza {
olsak_palette.entry('1').or_insert_with(|| ColorInfo {
ch: '#',
name: "black".to_string(),
rgb: (0, 0, 0),
color: Color(next_color),
corner: None,
});
if d >= if triddler { 6 } else { 2 } {
continue;
}
let clue_strs = line.split_whitespace();
match clue_style {
ClueStyle::Nono => {
let mut clues = vec![];
for clue_str in clue_strs {
if let Ok(count) = clue_str.parse::<u16>() {
clues.push(Nono {
color: olsak_palette[&'1'].color,
count,
})
} else {
let count: u8 = clue_str
.trim_end_matches(|c: char| !c.is_numeric())
.parse()?;
let input_ch = clue_str.chars().last().unwrap();
let color = olsak_palette
.get(&input_ch)
.with_context(|| format!("undefined color: {input_ch}"))?
.color;
clues.push(Nono {
color,
count: count as u16,
})
}
}
nono_clues[d].push(clues);
}
ClueStyle::Triano => {
let mut clues = vec![];
for clue_str in clue_strs {
let mut chars: Vec<char> = clue_str.chars().collect();
let front_cap = chars.first().and_then(|c| {
olsak_glued_palettes[d].get(&(*c, Left)).map(|c| c.color)
});
if front_cap.is_some() {
chars.remove(0);
}
let back_cap = chars.last().and_then(|c| {
olsak_glued_palettes[d].get(&(*c, Right)).map(|c| c.color)
});
if back_cap.is_some() {
chars.pop();
}
let last_char = *chars.last().context("clue has no body")?;
let body_color = if !last_char.is_numeric() {
let body_ch = chars.pop().unwrap();
olsak_palette
.get(&body_ch)
.with_context(|| format!("undefined color: {body_ch}"))?
.color
} else {
olsak_palette[&'1'].color
};
let body_len = chars.iter().collect::<String>().parse::<u16>()?
- (front_cap.is_some() as u16 + back_cap.is_some() as u16);
clues.push(Triano {
front_cap,
body_len,
body_color,
back_cap,
});
}
triano_clues[d].push(clues);
}
}
}
}
olsak_palette
.entry('0')
.or_insert_with(ColorInfo::default_bg);
let mut palette: HashMap<Color, ColorInfo> = olsak_palette
.into_values()
.map(|ci| (ci.color, ci))
.collect();
for glued_palette in olsak_glued_palettes {
for (_, ci) in glued_palette.iter() {
palette.insert(ci.color, ci.clone());
}
}
if triddler {
if clue_style == ClueStyle::Triano {
bail!("a puzzle can't be both a triddler and a trianogram");
}
return Ok(olsak_triddler(palette, nono_clues)?.into());
}
Ok(match clue_style {
ClueStyle::Nono => {
Puzzle::<Nono, Square>::square(palette, nono_clues[0].clone(), nono_clues[1].clone())
.into()
}
ClueStyle::Triano => Puzzle::<Triano, Square>::square(
palette,
triano_clues[0].clone(),
triano_clues[1].clone(),
)
.into(),
})
}
pub fn solution_to_triano_puzzle(solution: &Solution<Square>) -> Puzzle<Triano, Square> {
let width = solution.x_size();
let height = solution.y_size();
let mut rows: Vec<Vec<Triano>> = Vec::new();
let mut cols: Vec<Vec<Triano>> = Vec::new();
let blank_clue = Triano {
front_cap: None,
body_color: BACKGROUND,
body_len: 0,
back_cap: None,
};
for y in 0..height {
let mut clues = Vec::<Triano>::new();
let mut cur_clue = blank_clue;
for x in 0..width {
let color = solution[(x, y)];
let color_info = &solution.palette[&color];
if color_info.corner.is_some_and(|c| !c.left) {
if cur_clue != blank_clue {
clues.push(cur_clue);
cur_clue = blank_clue
}
cur_clue.front_cap = Some(color);
} else if color_info.corner.is_some_and(|c| c.left) {
cur_clue.back_cap = Some(color);
clues.push(cur_clue);
cur_clue = blank_clue;
} else if color == BACKGROUND {
if cur_clue != blank_clue {
clues.push(cur_clue);
cur_clue = blank_clue;
}
} else {
if cur_clue.body_color != BACKGROUND && cur_clue.body_color != color {
clues.push(cur_clue);
cur_clue = blank_clue;
}
cur_clue.body_color = color;
cur_clue.body_len += 1;
}
}
if cur_clue != blank_clue {
clues.push(cur_clue);
}
rows.push(clues);
}
for x in 0..width {
let mut clues = Vec::<Triano>::new();
let mut cur_clue = blank_clue;
for y in 0..height {
let color = solution[(x, y)];
let color_info = &solution.palette[&color];
if color_info.corner.is_some_and(|c| !c.upper) {
if cur_clue != blank_clue {
clues.push(cur_clue);
cur_clue = blank_clue
}
cur_clue.front_cap = Some(color);
} else if color_info.corner.is_some_and(|c| c.upper) {
cur_clue.back_cap = Some(color);
clues.push(cur_clue);
cur_clue = blank_clue;
} else if color == BACKGROUND {
if cur_clue != blank_clue {
clues.push(cur_clue);
cur_clue = blank_clue;
}
} else {
if cur_clue.body_color != BACKGROUND && cur_clue.body_color != color {
clues.push(cur_clue);
cur_clue = blank_clue;
}
cur_clue.body_color = color;
cur_clue.body_len += 1;
}
}
if cur_clue != blank_clue {
clues.push(cur_clue);
}
cols.push(clues);
}
Puzzle::square(solution.palette.clone(), rows, cols)
}
fn clues_along_lane<K: GridKind>(solution: &Solution<K>, cells: &[u32]) -> Vec<Nono> {
let mut clues = Vec::<Nono>::new();
let mut prev_color: Option<Color> = None;
let mut run = 1;
for i in 0..cells.len() + 1 {
let color = cells.get(i).map(|c| solution.cells[*c as usize]);
if prev_color == color {
run += 1;
continue;
}
match prev_color {
None => {}
Some(color) if color == BACKGROUND => {}
Some(color) => clues.push(Nono { color, count: run }),
}
prev_color = color;
run = 1;
}
clues
}
pub fn solution_to_nono_puzzle<K: GridKind>(solution: &Solution<K>) -> Puzzle<Nono, K> {
let lanes = solution.geometry.lane_map();
let lines = (0..lanes.lane_count())
.map(|lane| clues_along_lane(solution, &lanes.lane(lane).cells))
.collect();
Puzzle {
palette: solution.palette.clone(),
geometry: solution.geometry.clone(),
lines,
}
}
pub fn solution_to_puzzle(solution: &Solution<Square>) -> Puzzle<Nono, Square> {
solution_to_nono_puzzle(solution)
}
pub fn solution_to_tri_puzzle(solution: &Solution<Tri>) -> Puzzle<Nono, Tri> {
solution_to_nono_puzzle(solution)
}
pub fn bw_palette() -> HashMap<Color, ColorInfo> {
let mut palette = HashMap::new();
palette.insert(BACKGROUND, ColorInfo::default_bg());
palette.insert(Color(1), ColorInfo::default_fg(Color(1)));
palette
}
pub async fn puzzles_from_github() -> anyhow::Result<Vec<Document>> {
let client = reqwest::Client::new();
let puzzles_url =
"https://api.github.com/repos/paulstansifer/number-loom/contents/puzzles?ref=main";
let contents = client
.get(puzzles_url)
.header("User-Agent", "number-loom")
.send()
.await?
.bytes()
.await?;
let files: Vec<serde_json::Value> = serde_json::from_slice(&contents)?;
let mut res: Vec<Document> = vec![];
for file in files {
if file["type"] == "file" {
let name = file["name"].as_str().unwrap();
let download_url = file["download_url"].as_str().unwrap();
let content = client.get(download_url).send().await?.bytes().await?;
res.push(load(name, content.to_vec(), None)?);
}
}
Ok(res)
}
pub async fn load_zip_from_url(url: &str) -> anyhow::Result<Vec<Document>> {
let response = reqwest::get(url).await?;
let zip_bytes = response.bytes().await?;
let zip_cursor = Cursor::new(zip_bytes);
let mut archive = zip::ZipArchive::new(zip_cursor)?;
let mut documents = vec![];
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let filename = file.name().to_string();
if file.is_dir() {
continue;
}
let mut bytes = vec![];
file.read_to_end(&mut bytes)?;
documents.push(load(&filename, bytes, None)?);
}
Ok(documents)
}
pub fn triano_palette() -> HashMap<Color, ColorInfo> {
let mut palette = HashMap::new();
palette.insert(BACKGROUND, ColorInfo::default_bg());
palette.insert(Color(1), ColorInfo::default_fg(Color(1)));
palette.insert(
Color(3),
ColorInfo {
ch: '◤',
name: r#"black/white"#.to_string(),
rgb: (0, 0, 0),
color: Color(3),
corner: Some(Corner {
upper: true,
left: true,
}),
},
);
palette.insert(
Color(4),
ColorInfo {
ch: '◥',
name: r#"white\black"#.to_string(),
rgb: (0, 0, 0),
color: Color(4),
corner: Some(Corner {
upper: true,
left: false,
}),
},
);
palette.insert(
Color(5),
ColorInfo {
ch: '◣',
name: r#"black\white"#.to_string(),
rgb: (0, 0, 0),
color: Color(5),
corner: Some(Corner {
upper: false,
left: true,
}),
},
);
palette.insert(
Color(6),
ColorInfo {
ch: '◢',
name: r#"white/black"#.to_string(),
rgb: (0, 0, 0),
color: Color(6),
corner: Some(Corner {
upper: false,
left: false,
}),
},
);
palette
}