Skip to content

Commit 5f3cb26

Browse files
committed
Adds colorized rendering option to widget
Adds an option to render the asciiquarium widget with colorized glyphs. Introduces `AsciiquariumPalette` to map glyphs to colors, and adds a toggle in the demo to enable/disable colorized rendering along with color customization controls. When color is disabled or no palette is provided, the widget falls back to plain text rendering.
1 parent 9552a90 commit 5f3cb26

File tree

3 files changed

+237
-19
lines changed

3 files changed

+237
-19
lines changed

README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,72 @@ Tests cover:
160160
- The original Perl Asciiquarium (Kirk Baucom) materials are archived under `archive/original/`.
161161
- This crate provides an `egui`-based Rust implementation with a stateless, themeable widget design.
162162
- ASCII art in this crate is a minimal starter set for demonstration. Expand or replace as needed per your project’s licensing requirements.
163+
164+
## Features
165+
166+
- Stateless egui widget: renders to a single monospace label string
167+
- Deterministic animation loop with fixed timestep for smooth pacing
168+
- Environment:
169+
- Waterlines with subtle wave motion
170+
- Seaweed with gentle sway
171+
- Castle at bottom-right
172+
- Ship at the surface; shark and whale underwater with spout animation
173+
- Fish bubbles (desynced per fish)
174+
- Fish behavior:
175+
- Bounce physics with occasional direction variance on wall bounces
176+
- Schools: groups traverse and despawn off-screen
177+
- Orientation correction: fish ASCII auto-mirrors so they face their travel direction
178+
- Despawn and respawn cycles for large entities (ship, shark, whale)
179+
- Minimal defaults, no required configuration
180+
- Tests and CI (rustfmt, clippy, build, test)
181+
182+
## Colorized rendering (optional)
183+
184+
By default, the widget renders a plain, single-color ASCII string. You can opt into a colorized renderer that maps certain glyphs to colors using a palette:
185+
186+
- Enable by setting `enable_color = true` and providing a `palette`
187+
- Renders via an internal color `LayoutJob` while keeping the API unchanged
188+
- Glyph color mapping (initial pass):
189+
- Water surface: `~` and `^``palette.water`
190+
- Seaweed: `(` and `)``palette.seaweed`
191+
- Bubbles: `.``palette.bubble`
192+
- Original mask placeholders: `?``palette.water_trail` (to mimic motion trails)
193+
- All other glyphs default to `theme.text_color` (castle, ship, fish body, etc.)
194+
195+
Example (palette + colorized theme):
196+
197+
use asciiquarium_rust::{AsciiquariumTheme};
198+
use asciiquarium_rust::widgets::asciiquarium::AsciiquariumPalette;
199+
200+
let palette = AsciiquariumPalette {
201+
water: egui::Color32::from_rgb(120, 180, 255),
202+
water_trail: egui::Color32::from_rgba_unmultiplied(120, 180, 255, 120),
203+
seaweed: egui::Color32::from_rgb(60, 180, 120),
204+
castle: egui::Color32::from_rgb(200, 200, 200),
205+
ship: egui::Color32::from_rgb(230, 230, 230),
206+
bubble: egui::Color32::from_rgb(200, 230, 255),
207+
shark: egui::Color32::from_rgb(180, 200, 210),
208+
whale: egui::Color32::from_rgb(160, 190, 210),
209+
fish: egui::Color32::from_rgb(255, 200, 120),
210+
};
211+
212+
let theme = AsciiquariumTheme {
213+
text_color: egui::Color32::from_rgb(180, 220, 255),
214+
background: Some(egui::Color32::from_rgb(8, 12, 16)),
215+
wrap: false,
216+
enable_color: true,
217+
palette: Some(palette),
218+
};
219+
220+
Then render as usual with `AsciiquariumWidget { state, assets, theme }`.
221+
222+
Notes:
223+
- Color mapping will evolve. Future iterations may introduce per-art or per-part mapping for higher fidelity while keeping defaults minimal.
224+
- When `enable_color = false` or `palette = None`, the widget falls back to plain text rendering.
225+
226+
## Notes on original mask characters
227+
228+
The classic Asciiquarium art uses special characters (like `?`) in separate color mask layers. In this crate:
229+
230+
- With color disabled (default), mask characters are skipped in rendering for a clean monochrome output.
231+
- With color enabled, mask characters render using `palette.water_trail` to mimic subtle motion trails behind large entities.

examples/egui_demo.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ impl MyApp {
4747
text_color: egui::Color32::from_rgb(180, 220, 255),
4848
background: Some(egui::Color32::from_rgb(8, 12, 16)),
4949
wrap: false,
50+
enable_color: false,
51+
palette: None,
5052
};
5153

5254
Self {
@@ -111,6 +113,56 @@ impl eframe::App for MyApp {
111113
self.theme.background = None;
112114
}
113115

116+
// Colorized rendering toggle and palette controls
117+
ui.checkbox(&mut self.theme.enable_color, "Color");
118+
if self.theme.enable_color {
119+
if self.theme.palette.is_none() {
120+
self.theme.palette = Some(
121+
asciiquarium_rust::widgets::asciiquarium::AsciiquariumPalette {
122+
water: egui::Color32::from_rgb(120, 180, 255),
123+
water_trail: egui::Color32::from_rgba_unmultiplied(
124+
120, 180, 255, 120,
125+
),
126+
seaweed: egui::Color32::from_rgb(60, 180, 120),
127+
castle: egui::Color32::from_rgb(200, 200, 200),
128+
ship: egui::Color32::from_rgb(230, 230, 230),
129+
bubble: egui::Color32::from_rgb(200, 230, 255),
130+
shark: egui::Color32::from_rgb(180, 200, 210),
131+
whale: egui::Color32::from_rgb(160, 190, 210),
132+
fish: egui::Color32::from_rgb(255, 200, 120),
133+
},
134+
);
135+
}
136+
if let Some(p) = &mut self.theme.palette {
137+
ui.separator();
138+
ui.label("Palette:");
139+
ui.horizontal(|ui| {
140+
ui.label("Water");
141+
ui.color_edit_button_srgba(&mut p.water);
142+
ui.label("Trail");
143+
ui.color_edit_button_srgba(&mut p.water_trail);
144+
ui.label("Seaweed");
145+
ui.color_edit_button_srgba(&mut p.seaweed);
146+
});
147+
ui.horizontal(|ui| {
148+
ui.label("Bubble");
149+
ui.color_edit_button_srgba(&mut p.bubble);
150+
ui.label("Fish");
151+
ui.color_edit_button_srgba(&mut p.fish);
152+
ui.label("Shark");
153+
ui.color_edit_button_srgba(&mut p.shark);
154+
ui.label("Whale");
155+
ui.color_edit_button_srgba(&mut p.whale);
156+
});
157+
ui.horizontal(|ui| {
158+
ui.label("Ship");
159+
ui.color_edit_button_srgba(&mut p.ship);
160+
ui.label("Castle");
161+
ui.color_edit_button_srgba(&mut p.castle);
162+
});
163+
}
164+
}
165+
114166
ui.separator();
115167

116168
if ui.button("Add fish").clicked() {

src/widgets/asciiquarium.rs

Lines changed: 116 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,30 @@ pub struct AquariumState {
142142
}
143143

144144
/// Theme passed during render. No hardcoded styles in the component.
145+
#[derive(Clone, Debug)]
146+
pub struct AsciiquariumPalette {
147+
pub water: egui::Color32,
148+
pub water_trail: egui::Color32,
149+
pub seaweed: egui::Color32,
150+
pub castle: egui::Color32,
151+
pub ship: egui::Color32,
152+
pub bubble: egui::Color32,
153+
pub shark: egui::Color32,
154+
pub whale: egui::Color32,
155+
pub fish: egui::Color32,
156+
}
157+
145158
#[derive(Clone, Debug)]
146159
pub struct AsciiquariumTheme {
147160
pub text_color: egui::Color32,
148161
/// Optional background fill for the label area.
149162
pub background: Option<egui::Color32>,
150163
/// Whether to wrap lines in the ASCII label. Usually false for grids.
151164
pub wrap: bool,
165+
/// Enable colorized rendering using a LayoutJob instead of a plain string.
166+
pub enable_color: bool,
167+
/// Optional palette for colorized rendering. When None, falls back to text_color.
168+
pub palette: Option<AsciiquariumPalette>,
152169
}
153170

154171
impl Default for AsciiquariumTheme {
@@ -157,6 +174,8 @@ impl Default for AsciiquariumTheme {
157174
text_color: egui::Color32::LIGHT_GRAY,
158175
background: None,
159176
wrap: false,
177+
enable_color: false,
178+
palette: None,
160179
}
161180
}
162181
}
@@ -770,7 +789,7 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
770789
continue;
771790
}
772791
for (dx, ch) in line.chars().enumerate() {
773-
if ch == ' ' {
792+
if ch == ' ' || ch == '?' {
774793
continue;
775794
}
776795
let x = x0 + dx as isize;
@@ -793,7 +812,7 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
793812
continue;
794813
}
795814
for (dx, ch) in line.chars().enumerate() {
796-
if ch == ' ' {
815+
if ch == ' ' || ch == '?' {
797816
continue;
798817
}
799818
let x = base_x + dx;
@@ -866,7 +885,7 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
866885
continue;
867886
}
868887
for (dx, ch) in line.chars().enumerate() {
869-
if ch == ' ' {
888+
if ch == ' ' || ch == '?' {
870889
continue;
871890
}
872891
let x = x0 + dx as isize;
@@ -888,7 +907,7 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
888907
continue;
889908
}
890909
for (dx, ch) in line.chars().enumerate() {
891-
if ch == ' ' {
910+
if ch == ' ' || ch == '?' {
892911
continue;
893912
}
894913
let x = spx + dx as isize;
@@ -914,7 +933,7 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
914933
continue;
915934
}
916935
for (dx, ch) in line.chars().enumerate() {
917-
if ch == ' ' {
936+
if ch == ' ' || ch == '?' {
918937
continue;
919938
}
920939
let x = x0 + dx as isize;
@@ -952,7 +971,7 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
952971
};
953972

954973
for (dx, ch) in line_str.chars().enumerate() {
955-
if ch == ' ' {
974+
if ch == ' ' || ch == '?' {
956975
continue;
957976
}
958977
let x = x0 + dx as isize;
@@ -996,20 +1015,98 @@ pub struct AsciiquariumWidget<'a> {
9961015

9971016
impl<'a> egui::Widget for AsciiquariumWidget<'a> {
9981017
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
999-
let rendered = render_aquarium_to_string(self.state, self.assets);
1000-
let text = egui::RichText::new(rendered)
1001-
.monospace()
1002-
.color(self.theme.text_color);
1003-
let label = egui::Label::new(text).wrap(self.theme.wrap);
1004-
1005-
if let Some(fill) = self.theme.background {
1006-
egui::Frame::default()
1007-
.fill(fill)
1008-
.show(ui, |ui| ui.add(label))
1009-
.response
1018+
// Always render the ASCII grid string first
1019+
let rendered_string = render_aquarium_to_string(self.state, self.assets);
1020+
1021+
// If color is enabled and a palette is provided, build a colorized LayoutJob.
1022+
let response = if self.theme.enable_color {
1023+
if let Some(pal) = &self.theme.palette {
1024+
let mut job = egui::text::LayoutJob::default();
1025+
// Make sure this uses a monospace font
1026+
let mono = egui::FontId::monospace(12.0);
1027+
1028+
for (row_idx, line) in rendered_string.lines().enumerate() {
1029+
for ch in line.chars() {
1030+
let color = match ch {
1031+
// Water surface
1032+
'~' | '^' => pal.water,
1033+
// Seaweed
1034+
'(' | ')' => pal.seaweed,
1035+
// Castle (fallback to text color; many different chars)
1036+
// We leave castle to default unless specifically themed elsewhere.
1037+
// Ship (same approach as castle)
1038+
// Bubbles
1039+
'.' => pal.bubble,
1040+
// Mask placeholders from original assets: color as subtle water trail
1041+
'?' => pal.water_trail,
1042+
// Default fish and all other glyphs
1043+
_ => self.theme.text_color,
1044+
};
1045+
job.append(
1046+
&ch.to_string(),
1047+
0.0,
1048+
egui::TextFormat {
1049+
font_id: mono.clone(),
1050+
color,
1051+
..Default::default()
1052+
},
1053+
);
1054+
}
1055+
if row_idx + 1 < self.state.size.1 {
1056+
job.append(
1057+
"\n",
1058+
0.0,
1059+
egui::TextFormat {
1060+
font_id: mono.clone(),
1061+
color: self.theme.text_color,
1062+
..Default::default()
1063+
},
1064+
);
1065+
}
1066+
}
1067+
1068+
let label =
1069+
egui::Label::new(egui::WidgetText::LayoutJob(job)).wrap(self.theme.wrap);
1070+
if let Some(fill) = self.theme.background {
1071+
egui::Frame::default()
1072+
.fill(fill)
1073+
.show(ui, |ui| ui.add(label))
1074+
.response
1075+
} else {
1076+
ui.add(label)
1077+
}
1078+
} else {
1079+
// Palette missing, fall back to plain text
1080+
let text = egui::RichText::new(rendered_string)
1081+
.monospace()
1082+
.color(self.theme.text_color);
1083+
let label = egui::Label::new(text).wrap(self.theme.wrap);
1084+
if let Some(fill) = self.theme.background {
1085+
egui::Frame::default()
1086+
.fill(fill)
1087+
.show(ui, |ui| ui.add(label))
1088+
.response
1089+
} else {
1090+
ui.add(label)
1091+
}
1092+
}
10101093
} else {
1011-
ui.add(label)
1012-
}
1094+
// Plain text path (default)
1095+
let text = egui::RichText::new(rendered_string)
1096+
.monospace()
1097+
.color(self.theme.text_color);
1098+
let label = egui::Label::new(text).wrap(self.theme.wrap);
1099+
if let Some(fill) = self.theme.background {
1100+
egui::Frame::default()
1101+
.fill(fill)
1102+
.show(ui, |ui| ui.add(label))
1103+
.response
1104+
} else {
1105+
ui.add(label)
1106+
}
1107+
};
1108+
1109+
response
10131110
}
10141111
}
10151112

0 commit comments

Comments
 (0)