use crate::components::{Sprite, TextInput, TextLabel};
use crate::ecs::asset_id::AssetId;
use crate::gfx::text;
#[derive(Debug, Default)]
pub(super) struct WidgetScratch {
pub(super) sprites: Vec<Sprite>,
pub(super) labels: Vec<TextLabel>,
strings: Vec<String>,
}
impl WidgetScratch {
fn begin(&mut self) {
self.sprites.clear();
for label in self.labels.drain(..) {
let mut s = label.content;
s.clear();
self.strings.push(s);
}
}
fn string(&mut self, content: &str) -> String {
let mut s = self.strings.pop().unwrap_or_default();
s.push_str(content);
s
}
}
pub(super) fn build_dropdown_overlay(
screen: &crate::ecs::DropdownView,
loaded_fonts: &text::FontSet,
out: &mut WidgetScratch,
) {
const PANEL_BG: [f32; 4] = [0.06, 0.08, 0.14, 0.98];
const BORDER: [f32; 4] = [0.28, 0.34, 0.52, 1.0];
const SELECTED_BG: [f32; 4] = [0.14, 0.20, 0.34, 1.0];
const HOVER_BG: [f32; 4] = [0.22, 0.30, 0.48, 1.0];
const TRACK: [f32; 4] = [0.12, 0.15, 0.25, 0.95];
const THUMB: [f32; 4] = [0.45, 0.52, 0.70, 0.9];
const TEXT_PAD: f32 = 10.0;
const BORDER_PX: f32 = 2.0;
use crate::ui::dropdown;
let count = screen.options.len();
let layout = dropdown::layout(screen.anchor, count);
let first = screen.first.min(dropdown::max_first(count));
let row_of = |option: usize| {
option
.checked_sub(first)
.filter(|r| *r < layout.items.len())
};
out.begin();
let mk_sprite = |rect: [f32; 4], tint: [f32; 4]| Sprite {
asset_id: AssetId::default(),
x: rect[0],
y: rect[1],
width: rect[2],
height: rect[3],
texture: None,
tint,
follow_cursor: false,
visible: true,
screen: screen.screen,
fit: crate::components::SpriteFit::Fit,
corner_radius: 0.0,
border_width: 0.0,
border_color: [0.0, 0.0, 0.0, 1.0],
};
let [lx, ly, lw, lh] = layout.list;
out.sprites.push(mk_sprite(
[
lx - BORDER_PX,
ly - BORDER_PX,
lw + 2.0 * BORDER_PX,
lh + 2.0 * BORDER_PX,
],
BORDER,
));
out.sprites.push(mk_sprite(layout.list, PANEL_BG));
if let Some(rect) = row_of(screen.selected).and_then(|r| layout.items.get(r)) {
out.sprites.push(mk_sprite(*rect, SELECTED_BG));
}
if let Some(rect) = screen
.hovered
.and_then(row_of)
.and_then(|r| layout.items.get(r))
{
out.sprites.push(mk_sprite(*rect, HOVER_BG));
}
if let Some(rect) = dropdown::track_rect(&layout, count) {
out.sprites.push(mk_sprite(rect, TRACK));
}
if let Some(rect) = dropdown::thumb_rect(&layout, first, count) {
out.sprites.push(mk_sprite(rect, THUMB));
}
let line_h = loaded_fonts
.resolve(screen.font)
.map(|f| f.size_px * screen.scale)
.unwrap_or(0.0);
for (opt, rect) in screen.options.iter().skip(first).zip(&layout.items) {
let content = out.string(opt);
out.labels.push(TextLabel {
asset_id: AssetId::default(),
font: screen.font,
content,
x: rect[0] + TEXT_PAD,
y: rect[1] + (rect[3] - line_h) / 2.0,
color: screen.color,
scale: screen.scale,
centered: false,
align: crate::components::TextAlign::Left,
fit: crate::components::SpriteFit::Fit,
background: [0.0, 0.0, 0.0, 0.0],
padding: 0.0,
wrap_width: (rect[2] - 2.0 * TEXT_PAD).max(0.0),
max_lines: 1,
visible: true,
screen: screen.screen,
});
}
}
fn fit_line(
content: &str,
caret_byte: usize,
avail: f32,
focused: bool,
advance: impl Fn(&str) -> f32,
out: &mut String,
) -> (f32, f32) {
const ELLIPSIS: &str = "...";
let caret_byte = caret_byte.min(content.len());
let full = advance(content);
if avail <= 0.0 || full <= avail {
out.push_str(content);
return (0.0, advance(&content[..caret_byte]));
}
if focused {
let caret_w = advance(&content[..caret_byte]);
let scroll = (caret_w - avail).max(0.0);
let bounds = || {
core::iter::once(0usize).chain(content.char_indices().map(|(b, c)| b + c.len_utf8()))
};
let start = bounds()
.find(|&b| advance(&content[..b]) >= scroll)
.unwrap_or(0);
let mut end = None;
for b in bounds() {
if advance(&content[..b]) - scroll <= avail {
end = Some(b);
}
}
let end = end.unwrap_or(content.len()).max(start);
out.push_str(content.get(start..end).unwrap_or(""));
(advance(&content[..start]) - scroll, caret_w - scroll)
} else {
let ell = advance(ELLIPSIS);
let mut end = 0usize;
for (b, c) in content.char_indices() {
let nb = b + c.len_utf8();
if advance(&content[..nb]) + ell > avail {
break;
}
end = nb;
}
out.push_str(&content[..end]);
out.push_str(ELLIPSIS);
(0.0, 0.0)
}
}
pub(super) fn build_text_input_overlay(
ti: &TextInput,
loaded_fonts: &text::FontSet,
caret_visible: bool,
out: &mut WidgetScratch,
) {
const CARET_W: f32 = 2.0;
let font = loaded_fonts.resolve(ti.font);
let line_h = font
.map(|f| f.size_px * ti.scale)
.unwrap_or(ti.height * 0.6);
let text_y = ti.y + (ti.height - line_h) / 2.0;
out.begin();
out.sprites.push(Sprite {
asset_id: AssetId::default(),
x: ti.x,
y: ti.y,
width: ti.width,
height: ti.height,
texture: None,
tint: ti.background,
follow_cursor: false,
visible: true,
screen: ti.screen,
fit: ti.fit,
corner_radius: ti.corner_radius,
border_width: 0.0,
border_color: [0.0, 0.0, 0.0, 1.0],
});
let showing_placeholder = ti.content.is_empty() && !ti.focused;
let (raw, color) = if showing_placeholder {
(ti.placeholder.as_str(), ti.placeholder_color)
} else {
(ti.content.as_str(), ti.text_color)
};
let caret_byte = {
let caret = ti.caret.min(ti.content.chars().count());
ti.content
.char_indices()
.nth(caret)
.map(|(b, _)| b)
.unwrap_or(ti.content.len())
};
let avail = (ti.width - 2.0 * ti.padding - CARET_W).max(0.0);
let mut content = out.string("");
let (x_offset, caret_off) = match font {
Some(f) => fit_line(
raw,
caret_byte,
avail,
ti.focused,
|s| text::text_advance_width(s, f, ti.scale),
&mut content,
),
None => {
content.push_str(raw);
(0.0, 0.0)
}
};
out.labels.push(TextLabel {
asset_id: AssetId::default(),
font: ti.font,
content,
x: ti.x + ti.padding + x_offset,
y: text_y,
color,
scale: ti.scale,
centered: false,
align: crate::components::TextAlign::Left,
fit: ti.fit,
background: [0.0, 0.0, 0.0, 0.0],
padding: 0.0,
wrap_width: 0.0,
max_lines: 0,
visible: true,
screen: ti.screen,
});
if ti.focused
&& !ti.ghost.is_empty()
&& !showing_placeholder
&& let Some(f) = font
{
let advance = |s: &str| text::text_advance_width(s, f, ti.scale);
let content_w = advance(&ti.content);
if content_w <= avail {
let room = avail - content_w - CARET_W;
let mut end = 0usize;
for (b, c) in ti.ghost.char_indices() {
let nb = b + c.len_utf8();
if advance(&ti.ghost[..nb]) > room {
break;
}
end = nb;
}
if end > 0 {
let content = out.string(&ti.ghost[..end]);
out.labels.push(TextLabel {
asset_id: AssetId::default(),
font: ti.font,
content,
x: ti.x + ti.padding + content_w + CARET_W,
y: text_y,
color: ti.placeholder_color,
scale: ti.scale,
centered: false,
align: crate::components::TextAlign::Left,
fit: ti.fit,
background: [0.0, 0.0, 0.0, 0.0],
padding: 0.0,
wrap_width: 0.0,
max_lines: 0,
visible: true,
screen: ti.screen,
});
}
}
}
if ti.focused && font.is_some() && caret_visible {
let caret_x = ti.x + ti.padding + caret_off;
out.sprites.push(Sprite {
asset_id: AssetId::default(),
x: caret_x,
y: text_y,
width: CARET_W,
height: line_h,
texture: None,
tint: [ti.caret_color[0], ti.caret_color[1], ti.caret_color[2], 1.0],
follow_cursor: false,
visible: true,
screen: ti.screen,
fit: ti.fit,
corner_radius: 0.0,
border_width: 0.0,
border_color: [0.0, 0.0, 0.0, 1.0],
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::{DropdownView, FontHandle};
fn fit_line(
content: &str,
caret_byte: usize,
avail: f32,
focused: bool,
advance: impl Fn(&str) -> f32,
) -> (String, f32, f32) {
let mut out = String::new();
let (x_offset, caret_off) =
super::fit_line(content, caret_byte, avail, focused, advance, &mut out);
(out, x_offset, caret_off)
}
fn build_dropdown_overlay(
screen: &DropdownView,
loaded_fonts: &text::FontSet,
) -> (Vec<Sprite>, Vec<TextLabel>) {
let mut out = WidgetScratch::default();
super::build_dropdown_overlay(screen, loaded_fonts, &mut out);
(out.sprites, out.labels)
}
fn build_text_input_overlay(
ti: &TextInput,
loaded_fonts: &text::FontSet,
caret_visible: bool,
) -> (Vec<Sprite>, Vec<TextLabel>) {
let mut out = WidgetScratch::default();
super::build_text_input_overlay(ti, loaded_fonts, caret_visible, &mut out);
(out.sprites, out.labels)
}
fn mock_advance(s: &str) -> f32 {
s.chars().count() as f32 * 10.0
}
const FONT: FontHandle = FontHandle(0);
fn make_glyph(advance_px: f32) -> crate::gfx::font::GlyphMetrics {
crate::gfx::font::GlyphMetrics {
char_code: 0,
atlas_x: 0,
atlas_y: 0,
atlas_w: 8,
atlas_h: 12,
advance_px,
bearing_x: 0.0,
bearing_y: 12.0,
}
}
fn loaded_fonts() -> text::FontSet {
let metrics: crate::gfx::text::FontMetrics = ('a'..='z')
.chain('A'..='Z')
.chain('0'..='9')
.chain(['.', ' '])
.map(|c| (c as u32, make_glyph(10.0)))
.collect();
let cap_px = text::derive_cap_px(&metrics, 16.0);
let mut fonts = text::FontSet::default();
fonts.insert(
FONT,
text::LoadedFont {
atlas_slot: 0,
cap_px,
metrics,
atlas_w: 128,
atlas_h: 128,
size_px: 16.0,
supersample: 1.0,
},
);
fonts
}
fn no_fonts() -> text::FontSet {
text::FontSet::default()
}
fn dropdown_view(options: &[&str]) -> DropdownView {
DropdownView {
anchor: [400.0, 100.0, 200.0, 40.0],
options: options.iter().map(|s| s.to_string()).collect(),
selected: 0,
first: 0,
hovered: None,
screen: Some(AssetId(5)),
font: Some(FONT),
scale: 1.0,
color: [1.0, 1.0, 1.0],
}
}
fn text_input() -> TextInput {
TextInput {
font: Some(FONT),
placeholder: "type here".to_string(),
..Default::default()
}
}
fn rect(s: &Sprite) -> [f32; 4] {
[s.x, s.y, s.width, s.height]
}
#[test]
fn a_reused_scratch_builds_the_same_elements_as_a_fresh_one() {
let view = dropdown_view(&["aa", "bb"]);
let fonts = loaded_fonts();
let mut scratch = WidgetScratch::default();
super::build_dropdown_overlay(&view, &fonts, &mut scratch);
let sprites = scratch.sprites.len();
let contents: Vec<String> = scratch.labels.iter().map(|l| l.content.clone()).collect();
super::build_text_input_overlay(&text_input(), &fonts, true, &mut scratch);
super::build_dropdown_overlay(&view, &fonts, &mut scratch);
assert_eq!(scratch.sprites.len(), sprites);
let again: Vec<String> = scratch.labels.iter().map(|l| l.content.clone()).collect();
assert_eq!(again, contents);
}
#[test]
fn fit_line_passes_through_text_that_fits() {
let (text, xoff, caret) = fit_line("abc", 3, 100.0, false, mock_advance);
assert_eq!(text, "abc");
assert_eq!(xoff, 0.0);
assert_eq!(caret, 30.0);
}
#[test]
fn fit_line_truncates_unfocused_overflow_with_ellipsis() {
let (text, xoff, _) = fit_line("abcdefghij", 0, 65.0, false, mock_advance);
assert_eq!(text, "abc...");
assert_eq!(xoff, 0.0);
}
#[test]
fn fit_line_scrolls_focused_overflow_to_keep_the_caret_visible() {
let (text, xoff, caret) = fit_line("abcdefghij", 10, 50.0, true, mock_advance);
assert_eq!(text, "fghij");
assert_eq!(xoff, 0.0);
assert_eq!(caret, 50.0, "caret pinned to the right edge");
assert!(caret <= 50.0, "caret never past the box");
}
#[test]
fn fit_line_focused_caret_at_start_shows_the_head() {
let (text, xoff, caret) = fit_line("abcdefghij", 0, 50.0, true, mock_advance);
assert_eq!(text, "abcde");
assert_eq!(xoff, 0.0);
assert_eq!(caret, 0.0);
}
#[test]
fn build_dropdown_overlay_layers_border_panel_then_highlights() {
let mut view = dropdown_view(&["aa", "bb", "cc"]);
view.selected = 1;
view.hovered = Some(2);
let (sprites, labels) = build_dropdown_overlay(&view, &loaded_fonts());
assert_eq!(sprites.len(), 4);
assert_eq!(rect(&sprites[0]), [398.0, 138.0, 204.0, 124.0]);
assert_eq!(rect(&sprites[1]), [400.0, 140.0, 200.0, 120.0]);
assert_eq!(rect(&sprites[2]), [400.0, 180.0, 200.0, 40.0]);
assert_eq!(rect(&sprites[3]), [400.0, 220.0, 200.0, 40.0]);
assert_ne!(sprites[2].tint, sprites[3].tint);
assert!(sprites.iter().all(|s| s.screen == view.screen && s.visible));
assert_eq!(labels.len(), 3);
assert_eq!(labels[0].content, "aa");
assert_eq!((labels[0].x, labels[0].y), (410.0, 152.0));
assert_eq!((labels[2].x, labels[2].y), (410.0, 232.0));
assert!(labels.iter().all(|l| l.font == view.font && l.visible));
}
#[test]
fn build_dropdown_overlay_windows_a_scrolled_list() {
let options: Vec<String> = (0..16).map(|i| format!("o{i}")).collect();
let refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
let mut view = dropdown_view(&refs);
view.first = 8;
view.selected = 0;
view.hovered = Some(3);
let (sprites, labels) = build_dropdown_overlay(&view, &loaded_fonts());
assert_eq!(sprites.len(), 4);
assert_eq!(labels.len(), 8);
assert_eq!(labels[0].content, "o8");
assert_eq!(labels[7].content, "o15");
let (track, thumb) = (&sprites[2], &sprites[3]);
assert_eq!((track.y, track.height), (140.0, 320.0));
assert!(thumb.x >= track.x && thumb.x + thumb.width <= track.x + track.width);
assert_eq!(thumb.y + thumb.height, track.y + track.height);
}
#[test]
fn build_dropdown_overlay_fitting_list_has_no_scrollbar() {
let mut view = dropdown_view(&["aa", "bb", "cc"]);
view.first = 99;
let (sprites, labels) = build_dropdown_overlay(&view, &loaded_fonts());
assert_eq!(sprites.len(), 3);
assert_eq!(labels.len(), 3);
assert_eq!(labels[0].content, "aa");
}
#[test]
fn build_dropdown_overlay_without_a_loaded_font_centres_on_a_zero_line() {
let view = dropdown_view(&["aa"]);
let (_, labels) = build_dropdown_overlay(&view, &no_fonts());
assert_eq!(labels[0].y, 160.0);
}
#[test]
fn build_text_input_overlay_shows_the_placeholder_while_empty_and_unfocused() {
let ti = text_input();
let (sprites, labels) = build_text_input_overlay(&ti, &loaded_fonts(), true);
assert_eq!(sprites.len(), 1);
assert_eq!(rect(&sprites[0]), [ti.x, ti.y, ti.width, ti.height]);
assert_eq!(sprites[0].tint, ti.background);
assert_eq!(sprites[0].corner_radius, ti.corner_radius);
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].content, "type here");
assert_eq!(labels[0].color, ti.placeholder_color);
assert_eq!((labels[0].x, labels[0].y), (8.0, 12.0));
}
#[test]
fn build_text_input_overlay_shows_content_over_the_placeholder() {
let mut ti = text_input();
ti.content = "abc".to_string();
let (_, labels) = build_text_input_overlay(&ti, &loaded_fonts(), false);
assert_eq!(labels[0].content, "abc");
assert_eq!(labels[0].color, ti.text_color);
let focused = TextInput {
focused: true,
..text_input()
};
let (_, labels) = build_text_input_overlay(&focused, &loaded_fonts(), false);
assert_eq!(labels[0].content, "");
assert_eq!(labels[0].color, focused.text_color);
}
#[test]
fn build_text_input_overlay_draws_the_caret_only_on_a_focused_visible_blink() {
let mut ti = text_input();
ti.content = "abc".to_string();
ti.focused = true;
ti.caret = 3;
let (sprites, _) = build_text_input_overlay(&ti, &loaded_fonts(), true);
assert_eq!(sprites.len(), 2);
assert_eq!(rect(&sprites[1]), [38.0, 12.0, 2.0, 16.0]);
assert_eq!(sprites[1].tint[3], 1.0, "the caret always draws opaque");
assert_eq!(
build_text_input_overlay(&ti, &loaded_fonts(), false)
.0
.len(),
1
);
ti.focused = false;
assert_eq!(
build_text_input_overlay(&ti, &loaded_fonts(), true).0.len(),
1
);
}
#[test]
fn build_text_input_overlay_draws_the_ghost_after_the_caret() {
let mut ti = text_input();
ti.content = "abc".to_string();
ti.ghost = "def".to_string();
ti.focused = true;
ti.caret = 3;
let (_, labels) = build_text_input_overlay(&ti, &loaded_fonts(), true);
assert_eq!(labels.len(), 2);
assert_eq!(labels[0].content, "abc");
assert_eq!(labels[0].color, ti.text_color);
assert_eq!(labels[1].content, "def");
assert_eq!(labels[1].color, ti.placeholder_color);
assert_eq!((labels[1].x, labels[1].y), (40.0, 12.0));
ti.focused = false;
assert_eq!(
build_text_input_overlay(&ti, &loaded_fonts(), true).1.len(),
1,
"an unfocused field never shows the ghost"
);
}
#[test]
fn build_text_input_overlay_clips_the_ghost_to_the_box() {
let mut ti = text_input();
ti.width = 66.0;
ti.content = "abc".to_string();
ti.ghost = "defgh".to_string();
ti.focused = true;
ti.caret = 3;
let (_, labels) = build_text_input_overlay(&ti, &loaded_fonts(), true);
assert_eq!(labels.len(), 2);
assert_eq!(labels[1].content, "d");
ti.width = 40.0;
let (_, labels) = build_text_input_overlay(&ti, &loaded_fonts(), true);
assert_eq!(labels.len(), 1);
}
#[test]
fn build_text_input_overlay_without_a_loaded_font_passes_text_through() {
let mut ti = text_input();
ti.content = "abcdefghijklmnopqrstuvwxyz".to_string();
ti.focused = true;
let (sprites, labels) = build_text_input_overlay(&ti, &no_fonts(), true);
assert_eq!(sprites.len(), 1);
assert_eq!(labels[0].content, ti.content);
assert_eq!((labels[0].x, labels[0].y), (8.0, 8.0));
}
}