hex triplets for colors
This commit is contained in:
+2
-17
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user