hex triplets for colors

This commit is contained in:
2026-07-26 11:18:30 -05:00
parent ba8c72d5a5
commit 9844671c46
5 changed files with 173 additions and 67 deletions
+2 -17
View File
@@ -32,20 +32,5 @@ pub const NAMED_COLORS: [(&str, Rgba8); 16] = [
("White", rgb(0xFF, 0xFF, 0xFF)), ("White", rgb(0xFF, 0xFF, 0xFF)),
]; ];
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`]. // Hex color parsing/formatting lives in `glyph.rs` alongside the `Glyph` serde
/// Returns opaque black on any parse failure. // adapter that uses it — see `glyph::parse_color` / `glyph::color_to_hex`.
pub(crate) fn parse_color(hex: &str) -> Rgba8 {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Rgba8 {
r: 0,
g: 0,
b: 0,
a: 255,
};
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
Rgba8 { r, g, b, a: 255 }
}
+144 -32
View File
@@ -22,8 +22,14 @@ pub struct Glyph {
/// Which tile to draw /// Which tile to draw
pub tile: u32, pub tile: u32,
/// Foreground color, applied to non-background pixels of the tile. /// Foreground color, applied to non-background pixels of the tile.
///
/// Serialized as an `"#RRGGBB"` hex string — see [`parse_color`].
#[serde(with = "hex_color")]
pub fg: Rgba8, pub fg: Rgba8,
/// Background color, drawn as a filled rectangle behind the tile. /// Background color, drawn as a filled rectangle behind the tile.
///
/// Serialized as an `"#RRGGBB"` hex string — see [`parse_color`].
#[serde(with = "hex_color")]
pub bg: Rgba8, pub bg: Rgba8,
} }
@@ -93,43 +99,149 @@ impl Glyph {
} }
} }
// TODO make TileIndex work again // TODO make TileIndex work again: `tile` should accept either a single-character
/* // string (used directly as the display char) or a `u8` (looked up in CP437). That
/// A tile index in a palette entry: either a plain integer or a character literal. // change turns `Glyph::tile` into a `char`, with `'\0'` as the transparent
// sentinel and `CP437[0]` remapped to `'\0'` to match.
/// Parses an `"#RRGGBB"` hex color into an opaque [`Rgba8`].
/// ///
/// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char /// The leading `#` is optional. Returns a human-readable `Err` describing the
/// is converted to its Unicode scalar) interchangeably. /// problem — map loading surfaces it with the file position, and the script color
#[derive(Deserialize, Serialize, Eq, Clone, Copy, Debug)] /// API logs it.
#[serde(untagged)] ///
pub enum TileIndex { /// **Alpha is deliberately not part of the format.** A cell draws exactly one
/// A direct tile index (e.g. `tile = 35`). /// glyph, chosen by the fixed precedence in
Num(u32), /// [`Board::glyph_at`](crate::board::Board::glyph_at) — there is no compositing
/// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar. /// pass, so there is nothing for a translucent color to blend against. (A
Chr(char), /// see-through cell is expressed by tile `0`, which falls through to whatever the
/// next precedence level draws; that is a different mechanism entirely.) Every
/// front-end drops alpha at the render boundary, so accepting an `"#RRGGBBAA"`
/// here would silently do nothing — an `Err` is more honest.
pub fn parse_color(hex: &str) -> Result<Rgba8, String> {
let digits = hex.strip_prefix('#').unwrap_or(hex);
if digits.len() != 6 {
return Err(format!(
"expected a hex color like \"#RRGGBB\", got {hex:?}"
));
}
// Two hex digits at `offset`, or an error naming the offending channel.
let byte = |offset: usize, channel: &str| -> Result<u8, String> {
u8::from_str_radix(&digits[offset..offset + 2], 16)
.map_err(|_| format!("{channel} channel of {hex:?} is not a hex byte"))
};
Ok(Rgba8 {
r: byte(0, "red")?,
g: byte(2, "green")?,
b: byte(4, "blue")?,
a: 255,
})
} }
impl TileIndex { /// Formats an [`Rgba8`] as `"#RRGGBB"`. The inverse of [`parse_color`].
/// Returns the tile index as a `u32`, converting a char to its scalar value. ///
pub(crate) fn into_u32(self) -> u32 { /// Alpha is not emitted — see [`parse_color`] for why it is not part of the
match self { /// format. Colors in a board are always opaque, so nothing is lost.
TileIndex::Num(n) => n, pub fn color_to_hex(c: Rgba8) -> String {
TileIndex::Chr(c) => c as u32, format!("#{:02X}{:02X}{:02X}", c.r, c.g, c.b)
} }
/// `serde` adapter letting [`Glyph`]'s color fields round-trip as hex strings.
///
/// `Rgba8` comes from the `color` crate, so it cannot implement `Serialize` /
/// `Deserialize` for our format directly; `#[serde(with = "hex_color")]` on each
/// field routes through [`parse_color`] / [`color_to_hex`] instead of the
/// structural `{ r, g, b, a }` form.
mod hex_color {
use super::{color_to_hex, parse_color};
use color::Rgba8;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(color: &Rgba8, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&color_to_hex(*color))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Rgba8, D::Error> {
let text = String::deserialize(d)?;
parse_color(&text).map_err(serde::de::Error::custom)
} }
} }
impl PartialEq for TileIndex { #[cfg(test)]
fn eq(&self, other: &Self) -> bool { mod tests {
self.into_u32() == other.into_u32() use super::{color_to_hex, parse_color, Glyph};
} use color::Rgba8;
}
impl Into<u32> for TileIndex { #[test]
fn into(self) -> u32 { fn parse_color_accepts_six_digits_with_optional_hash() {
match self { let expected = Rgba8 { r: 0x11, g: 0x22, b: 0x33, a: 255 };
TileIndex::Num(n) => n, assert_eq!(parse_color("#112233").unwrap(), expected);
TileIndex::Chr(c) => c as u32, assert_eq!(parse_color("112233").unwrap(), expected, "leading # is optional");
} assert_eq!(parse_color("#AABBCC").unwrap(), parse_color("#aabbcc").unwrap());
} }
}
*/ #[test]
fn parse_color_rejects_bad_input() {
// Wrong length, non-hex digits, and empty all report rather than
// silently yielding black (which would be an invisible authoring bug).
assert!(parse_color("#12345").is_err());
assert!(parse_color("#1122334").is_err());
assert!(parse_color("#gg2233").is_err());
assert!(parse_color("").is_err());
}
#[test]
fn parse_color_rejects_an_alpha_channel() {
// A cell draws one glyph by precedence with no compositing pass, and every
// front-end drops alpha at the render boundary — so "#RRGGBBAA" would be
// silently ignored. Rejecting it tells the author instead.
assert!(parse_color("#11223380").is_err());
}
#[test]
fn color_to_hex_never_emits_alpha() {
assert_eq!(color_to_hex(Rgba8 { r: 1, g: 2, b: 3, a: 255 }), "#010203");
// Even a non-opaque value (which a board never holds) stays six digits,
// so output always round-trips back through parse_color.
assert_eq!(color_to_hex(Rgba8 { r: 1, g: 2, b: 3, a: 4 }), "#010203");
}
#[test]
fn glyph_colors_round_trip_through_toml_as_hex() {
let glyph = Glyph {
tile: 35,
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
};
let text = toml::to_string(&glyph).unwrap();
assert!(text.contains(r##"fg = "#808080""##), "fg emitted as hex: {text}");
assert_eq!(toml::from_str::<Glyph>(&text).unwrap(), glyph);
}
#[test]
fn glyph_deserializes_from_hand_written_hex() {
let glyph: Glyph = toml::from_str(
r##"
tile = 35
fg = "#808080"
bg = "#606060"
"##,
)
.unwrap();
assert_eq!(glyph.fg, Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 });
assert_eq!(glyph.bg, Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 });
}
#[test]
fn glyph_reports_a_malformed_color_instead_of_defaulting() {
let err = toml::from_str::<Glyph>(
r##"
tile = 35
fg = "#nothex"
bg = "#606060"
"##,
)
.unwrap_err();
assert!(err.to_string().contains("hex"), "error mentions hex: {err}");
}
}
+22 -5
View File
@@ -49,7 +49,8 @@ use crate::api::player::PlayerWithPos;
use crate::api::queue::ObjQueue; use crate::api::queue::ObjQueue;
use crate::api::registry::Registry; use crate::api::registry::Registry;
use crate::builtin::BUILTIN_SOURCES; use crate::builtin::BUILTIN_SOURCES;
use crate::colors::parse_color; use color::Rgba8;
use crate::glyph::parse_color;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::keys::Keyring; use crate::keys::Keyring;
use crate::player::PlayerRef; use crate::player::PlayerRef;
@@ -506,8 +507,22 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
} }
); );
// Parses a script-supplied "#RRGGBB" color, logging and dropping a malformed
// one (a `None` channel leaves that color unchanged) rather than silently
// painting it black, which is invisible to debug.
fn color_arg(text: &str, fn_name: &str, log_sink: &LogSink) -> Option<Rgba8> {
match parse_color(text) {
Ok(color) => Some(color),
Err(err) => {
log_sink.error(format!("{fn_name}: {err}"));
None
}
}
}
// set_fg(fg): change foreground color only. // set_fg(fg): change foreground color only.
let b = board.clone(); let b = board.clone();
let sink = log_sink.clone();
engine.register_fn( engine.register_fn(
"set_fg", "set_fg",
move |ctx: NativeCallContext, fg: ImmutableString| { move |ctx: NativeCallContext, fg: ImmutableString| {
@@ -515,7 +530,7 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
&b, &b,
source_of(&ctx), source_of(&ctx),
Action::SetColor { Action::SetColor {
fg: Some(parse_color(fg.as_str())), fg: color_arg(fg.as_str(), "set_fg", &sink),
bg: None, bg: None,
}, },
); );
@@ -524,6 +539,7 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
// set_bg(bg): change background color only. // set_bg(bg): change background color only.
let b = board.clone(); let b = board.clone();
let sink = log_sink.clone();
engine.register_fn( engine.register_fn(
"set_bg", "set_bg",
move |ctx: NativeCallContext, bg: ImmutableString| { move |ctx: NativeCallContext, bg: ImmutableString| {
@@ -532,7 +548,7 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
source_of(&ctx), source_of(&ctx),
Action::SetColor { Action::SetColor {
fg: None, fg: None,
bg: Some(parse_color(bg.as_str())), bg: color_arg(bg.as_str(), "set_bg", &sink),
}, },
); );
}, },
@@ -540,6 +556,7 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
// set_color(fg, bg): change both colors. // set_color(fg, bg): change both colors.
let b = board.clone(); let b = board.clone();
let sink = log_sink.clone();
engine.register_fn( engine.register_fn(
"set_color", "set_color",
move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| { move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| {
@@ -547,8 +564,8 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
&b, &b,
source_of(&ctx), source_of(&ctx),
Action::SetColor { Action::SetColor {
fg: Some(parse_color(fg.as_str())), fg: color_arg(fg.as_str(), "set_color", &sink),
bg: Some(parse_color(bg.as_str())), bg: color_arg(bg.as_str(), "set_color", &sink),
}, },
); );
}, },
+3 -12
View File
@@ -197,13 +197,6 @@ mod tests {
spec.into_board(&HashSet::new()).expect("board spec converts") spec.into_board(&HashSet::new()).expect("board spec converts")
} }
/// An `{ r, g, b, a }` color table. `Glyph` derives serde straight onto
/// `color::Rgba8`, so map TOML spells colors out structurally rather than as
/// `"#rrggbb"` strings.
fn rgb(r: u8, g: u8, b: u8) -> String {
format!("{{ r = {r}, g = {g}, b = {b}, a = 255 }}")
}
/// Builds a tiny dark board: a 5×1 corridor with the player at the left end /// Builds a tiny dark board: a 5×1 corridor with the player at the left end
/// and a wall at x=2 occluding the two cells behind it. /// and a wall at x=2 occluding the two cells behind it.
fn dark_corridor() -> Board { fn dark_corridor() -> Board {
@@ -267,7 +260,7 @@ mod tests {
// and a lamp you can walk through belongs off-grid. The player sits at x=0 // and a lamp you can walk through belongs off-grid. The player sits at x=0
// (Board::lighting reads player_pos for the sightline) but carries no torch, // (Board::lighting reads player_pos for the sightline) but carries no torch,
// so the sensor is the only light. // so the sensor is the only light.
let board = board_from(&format!( let board = board_from(
r##" r##"
name = "lit" name = "lit"
width = 6 width = 6
@@ -284,11 +277,9 @@ mod tests {
draw_layer = "Above" draw_layer = "Above"
opaque = false opaque = false
glow = 2 glow = 2
glyph = {{ tile = 1, fg = {fg}, bg = {bg} }} glyph = { tile = 1, fg = "#FF0000", bg = "#000000" }
"##, "##,
fg = rgb(255, 0, 0), );
bg = rgb(0, 0, 0),
));
let fov = board.lighting(0); // no player torch — only the sensor lights let fov = board.lighting(0); // no player torch — only the sensor lights
let area = Rect::new(0, 0, 6, 1); let area = Rect::new(0, 0, 6, 1);
+2 -1
View File
@@ -31,7 +31,7 @@ grid = """
##################### #####################
# # # #
# G @ # # G @ #
# oo # # oo hh #
# # # #
##################### #####################
""" """
@@ -40,4 +40,5 @@ grid = """
"o" = { type = "builtin", kind = "crate", glyph = { tile = 254, fg = "#aaaaaa", bg = "#000000" } } "o" = { type = "builtin", kind = "crate", glyph = { tile = 254, fg = "#aaaaaa", bg = "#000000" } }
"@" = { type = "player" } "@" = { type = "player" }
# "G" = { type = "object", tile = 35, fg = "#aa3333", bg = "#000000", enter = "block", script_name = "greeter" } # "G" = { type = "object", tile = 35, fg = "#aa3333", bg = "#000000", enter = "block", script_name = "greeter" }
"h" = { type = "builtin", kind = "heart" }
"G" = { type = "builtin", kind = "gem" } "G" = { type = "builtin", kind = "gem" }