use super::registry::{self, PanelKey};
use super::theme;
use super::widget::{self, place_rounded, point_in};
use crate::components::TextAlign;
use crate::ecs::World;
use crate::ecs::asset_id::AssetId;
const BASE: u32 = registry::base(PanelKey::Story);
pub(crate) const PANEL_BG: AssetId = AssetId(BASE);
pub(crate) const TITLE_LABEL: AssetId = AssetId(BASE + 2);
pub(crate) const CLOSE_BG: AssetId = AssetId(BASE + 3);
pub(crate) const CLOSE_LABEL: AssetId = AssetId(BASE + 4);
pub(crate) const APPLY_BG: AssetId = AssetId(BASE + 5);
pub(crate) const APPLY_LABEL: AssetId = AssetId(BASE + 6);
pub(crate) const PATH_LABEL: AssetId = AssetId(BASE + 7);
pub(crate) const STATUS_LABEL: AssetId = AssetId(BASE + 8);
pub(crate) const LINE_TRACK: AssetId = AssetId(BASE + 9);
pub(crate) const LINE_THUMB: AssetId = AssetId(BASE + 10);
pub(crate) const LINE_INPUT: AssetId = AssetId(BASE + 11);
pub(crate) fn row_bg(i: usize) -> AssetId {
AssetId(BASE + 0x20 + i as u32)
}
pub(crate) fn row_label(i: usize) -> AssetId {
AssetId(BASE + 0x40 + i as u32)
}
pub(crate) const STORY_W: f32 = 560.0;
const PAD: f32 = 10.0;
const HEADER_H: f32 = 32.0;
const STATUS_H: f32 = 2.0 * widget::LINE_H + 6.0;
const BTN_W: f32 = 70.0;
const LINE_H: f32 = 24.0;
const SCROLLBAR_W: f32 = 5.0;
pub(crate) const LINE_POOL: usize = 16;
pub(crate) const LINE_POOL_MAX: usize = 30;
const MAX_LINE_CHARS: usize = 62;
const CHAR_W: f32 = 8.5;
const CHROME_H: f32 = widget::TITLE_H + HEADER_H + STATUS_H;
pub(crate) fn visible_rows(h: f32) -> usize {
(((h - CHROME_H - PAD) / LINE_H).floor() as usize).clamp(LINE_POOL, LINE_POOL_MAX)
}
fn max_line_chars(w: f32) -> usize {
(MAX_LINE_CHARS as f32 + (w - STORY_W) / CHAR_W).max(8.0) as usize
}
pub(crate) fn max_size() -> [f32; 2] {
[
f32::INFINITY,
size()[1] + (LINE_POOL_MAX - LINE_POOL) as f32 * LINE_H,
]
}
const ROW_TINT: [f32; 4] = [0.0, 0.0, 0.0, 0.0];
const ROW_TINT_CURRENT: [f32; 4] = theme::SELECTED_TINT;
const ROW_TINT_HOVER: [f32; 4] = theme::HOVER_TINT;
const BTN_TINT: [f32; 4] = [0.22, 0.40, 0.56, 1.0];
const BTN_TINT_HOVER: [f32; 4] = [0.28, 0.48, 0.66, 1.0];
const TRACK_TINT: [f32; 4] = [0.12, 0.12, 0.15, 0.9];
const THUMB_TINT: [f32; 4] = [0.40, 0.44, 0.56, 0.95];
const LABEL: [f32; 3] = [0.90, 0.90, 0.92];
const PATH_COLOR: [f32; 3] = [0.62, 0.66, 0.76];
const ADD_LABEL: [f32; 3] = [0.55, 0.80, 0.60];
const ERROR_LABEL: [f32; 3] = [0.95, 0.55, 0.55];
pub(crate) struct StoryView<'a> {
pub lines: &'a [String],
pub scroll: usize,
pub current: usize,
pub focus: bool,
pub path: &'a str,
pub status: Option<&'a str>,
pub create: bool,
pub dirty: bool,
pub mouse: [f32; 2],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StoryAction {
Line(usize),
Create,
Apply,
Consume,
}
pub(crate) fn default_origin(vw: f32) -> [f32; 2] {
[(vw - STORY_W) * 0.5, super::hud::body_top() + 8.0]
}
pub(crate) fn size() -> [f32; 2] {
[
STORY_W,
widget::TITLE_H + HEADER_H + STATUS_H + LINE_POOL as f32 * LINE_H + PAD,
]
}
pub(crate) fn apply_rect(o: [f32; 2], w: f32) -> [f32; 4] {
[
o[0] + w - PAD - BTN_W,
o[1] + widget::TITLE_H + 4.0,
BTN_W,
HEADER_H - 8.0,
]
}
fn body_top(o: [f32; 2]) -> f32 {
o[1] + CHROME_H
}
pub(crate) fn line_rect(o: [f32; 2], w: f32, slot: usize) -> [f32; 4] {
[
o[0],
body_top(o) + slot as f32 * LINE_H,
w - SCROLLBAR_W - 2.0,
LINE_H,
]
}
pub(crate) fn cursor_over_lines(mx: f32, my: f32, o: [f32; 2], s: [f32; 2]) -> bool {
let p = widget::outer_rect(o, s);
mx >= p[0] && mx < p[0] + p[2] && my >= body_top(o) && my < p[1] + p[3]
}
pub(crate) fn hit_test(
view: &StoryView,
mx: f32,
my: f32,
o: [f32; 2],
s: [f32; 2],
) -> Option<StoryAction> {
let w = s[0];
if point_in(mx, my, apply_rect(o, w)) && !view.create {
return Some(StoryAction::Apply);
}
for slot in 0..visible_rows(s[1]) {
if !point_in(mx, my, line_rect(o, w, slot)) {
continue;
}
if view.create {
return Some(if slot == 0 {
StoryAction::Create
} else {
StoryAction::Consume
});
}
let i = view.scroll + slot;
return Some(if i < view.lines.len() {
StoryAction::Line(i)
} else {
StoryAction::Consume
});
}
point_in(mx, my, widget::outer_rect(o, s)).then_some(StoryAction::Consume)
}
pub(crate) fn apply(world: &mut World, view: Option<&StoryView>, o: [f32; 2], s: [f32; 2]) {
let Some(view) = view else {
hide_all(world);
return;
};
let w = s[0];
let rows_shown = visible_rows(s[1]);
widget::place_panel(world, PANEL_BG, widget::outer_rect(o, s));
let title = widget::title_rect(o, w);
let heading = if view.dirty { "Story *" } else { "Story" };
widget::place_heading(world, TITLE_LABEL, title, heading);
let close_hover = point_in(view.mouse[0], view.mouse[1], widget::close_rect(title));
widget::place_close(world, CLOSE_BG, CLOSE_LABEL, title, close_hover);
if let Some(l) = widget::label_mut(world, PATH_LABEL) {
l.x = o[0] + PAD;
l.y = o[1] + widget::TITLE_H + HEADER_H * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Left;
l.color = PATH_COLOR;
l.visible = !view.create;
l.content = widget::clip_text(view.path, 52);
}
let apply_btn = apply_rect(o, w);
let hover = point_in(view.mouse[0], view.mouse[1], apply_btn);
let tint = if hover { BTN_TINT_HOVER } else { BTN_TINT };
place_rounded(
world,
APPLY_BG,
apply_btn,
tint,
theme::CONTROL_RADIUS,
!view.create,
);
if let Some(l) = widget::label_mut(world, APPLY_LABEL) {
l.x = apply_btn[0] + apply_btn[2] * 0.5;
l.y = apply_btn[1] + apply_btn[3] * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Center;
l.color = [1.0, 1.0, 1.0];
l.visible = !view.create;
l.content = "Apply".to_string();
}
widget::place_message(
world,
STATUS_LABEL,
[
o[0] + PAD,
o[1] + widget::TITLE_H + HEADER_H + 1.0,
(w - 2.0 * PAD).max(0.0),
STATUS_H - 4.0,
],
view.status.unwrap_or(""),
ERROR_LABEL,
view.status.is_some(),
);
widget::hide_field(world, LINE_INPUT);
for slot in 0..LINE_POOL_MAX {
if slot >= rows_shown {
widget::set_sprite_visible(world, row_bg(slot), false);
widget::set_label_visible(world, row_label(slot), false);
continue;
}
let r = line_rect(o, w, slot);
if view.create {
let on = slot == 0;
let hovered = on && point_in(view.mouse[0], view.mouse[1], r);
let tint = if hovered { ROW_TINT_HOVER } else { ROW_TINT };
place_rounded(
world,
row_bg(slot),
theme::highlight_rect(r),
tint,
theme::CONTROL_RADIUS,
on,
);
if let Some(l) = widget::label_mut(world, row_label(slot)) {
l.x = r[0] + PAD;
l.y = r[1] + LINE_H * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Left;
l.color = ADD_LABEL;
l.visible = on;
l.content = "+ Create story".to_string();
}
continue;
}
let i = view.scroll + slot;
if i >= view.lines.len() {
widget::set_sprite_visible(world, row_bg(slot), false);
widget::set_label_visible(world, row_label(slot), false);
continue;
}
let current = i == view.current;
let tint = if current { ROW_TINT_CURRENT } else { ROW_TINT };
place_rounded(
world,
row_bg(slot),
theme::highlight_rect(r),
tint,
theme::CONTROL_RADIUS,
true,
);
if current {
widget::set_label_visible(world, row_label(slot), false);
widget::show_field(
world,
LINE_INPUT,
[r[0] + 2.0, r[1] + 1.0, r[2] - 4.0, r[3] - 2.0],
view.focus,
);
} else if let Some(l) = widget::label_mut(world, row_label(slot)) {
l.x = r[0] + PAD;
l.y = r[1] + LINE_H * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Left;
l.color = LABEL;
l.visible = true;
l.content = widget::clip_text(&view.lines[i], max_line_chars(w));
}
}
layout_scrollbar(world, view, o, w, rows_shown);
}
fn layout_scrollbar(world: &mut World, view: &StoryView, o: [f32; 2], w: f32, rows_shown: usize) {
let total = view.lines.len();
if view.create || total <= rows_shown {
widget::set_sprite_visible(world, LINE_TRACK, false);
widget::set_sprite_visible(world, LINE_THUMB, false);
return;
}
let x = o[0] + w - SCROLLBAR_W;
let top = body_top(o);
let h = rows_shown as f32 * LINE_H;
place_rounded(
world,
LINE_TRACK,
[x, top, SCROLLBAR_W, h],
TRACK_TINT,
SCROLLBAR_W * 0.5,
true,
);
let frac = rows_shown as f32 / total as f32;
let thumb_h = (h * frac).max(18.0);
let max_scroll = (total - rows_shown) as f32;
let off = (h - thumb_h) * (view.scroll as f32 / max_scroll);
place_rounded(
world,
LINE_THUMB,
[x, top + off, SCROLLBAR_W, thumb_h],
THUMB_TINT,
SCROLLBAR_W * 0.5,
true,
);
}
pub(crate) fn hide_all(world: &mut World) {
widget::hide_all(world, &all_sprite_ids(), &all_label_ids(), &all_field_ids());
}
pub(crate) fn all_sprite_ids() -> Vec<AssetId> {
let mut ids = vec![PANEL_BG, CLOSE_BG, APPLY_BG];
ids.extend((0..LINE_POOL_MAX).map(row_bg));
ids.extend([LINE_TRACK, LINE_THUMB]);
ids
}
pub(crate) fn all_label_ids() -> Vec<AssetId> {
let mut ids = vec![
TITLE_LABEL,
CLOSE_LABEL,
APPLY_LABEL,
PATH_LABEL,
STATUS_LABEL,
];
ids.extend((0..LINE_POOL_MAX).map(row_label));
ids
}
pub(crate) fn all_field_ids() -> Vec<AssetId> {
vec![LINE_INPUT]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{Sprite, TextInput, TextLabel};
fn injected_world() -> World {
let mut world = World::new();
for id in all_sprite_ids() {
world.add_component(Sprite {
asset_id: id,
..Default::default()
});
}
for id in all_label_ids() {
world.add_component(TextLabel {
asset_id: id,
..Default::default()
});
}
for id in all_field_ids() {
world.add_component(TextInput {
asset_id: id,
..Default::default()
});
}
world
}
fn lines(n: usize) -> Vec<String> {
(0..n).map(|i| format!("line {i}")).collect()
}
fn view<'a>(lines: &'a [String], scroll: usize, current: usize) -> StoryView<'a> {
StoryView {
lines,
scroll,
current,
focus: true,
path: "story.md",
status: None,
create: false,
dirty: false,
mouse: [0.0, 0.0],
}
}
#[test]
fn hit_test_resolves_lines_apply_and_create() {
let l = lines(4);
let v = view(&l, 0, 1);
let o = [40.0, 40.0];
let s = size();
let a = apply_rect(o, STORY_W);
assert_eq!(
hit_test(&v, a[0] + 5.0, a[1] + 5.0, o, s),
Some(StoryAction::Apply)
);
let r2 = line_rect(o, STORY_W, 2);
assert_eq!(
hit_test(&v, r2[0] + 5.0, r2[1] + 5.0, o, s),
Some(StoryAction::Line(2))
);
let r5 = line_rect(o, STORY_W, 5);
assert_eq!(
hit_test(&v, r5[0] + 5.0, r5[1] + 5.0, o, s),
Some(StoryAction::Consume)
);
assert_eq!(hit_test(&v, 5000.0, 5000.0, o, s), None);
let create = StoryView {
create: true,
..view(&l, 0, 0)
};
let r0 = line_rect(o, STORY_W, 0);
assert_eq!(
hit_test(&create, r0[0] + 5.0, r0[1] + 5.0, o, s),
Some(StoryAction::Create)
);
assert_eq!(
hit_test(&create, a[0] + 5.0, a[1] + 5.0, o, s),
Some(StoryAction::Consume),
"no Apply in create mode"
);
}
#[test]
fn scrolled_window_maps_slots_to_absolute_lines() {
let l = lines(40);
let v = view(&l, 10, 12);
let o = [0.0, 0.0];
let r0 = line_rect(o, STORY_W, 0);
assert_eq!(
hit_test(&v, r0[0] + 5.0, r0[1] + 5.0, o, size()),
Some(StoryAction::Line(10))
);
}
#[test]
fn apply_draws_the_edit_line_as_the_input_and_others_as_labels() {
let mut world = injected_world();
let l = lines(4);
apply(&mut world, Some(&view(&l, 0, 1)), [20.0, 20.0], size());
let input = world
.query::<TextInput>()
.find(|t| t.asset_id == LINE_INPUT)
.unwrap();
assert!(
input.visible && input.focused,
"edit line is the live input"
);
let slot1 = world
.query::<TextLabel>()
.find(|l| l.asset_id == row_label(1))
.unwrap();
assert!(!slot1.visible, "the edit slot's label yields to the input");
let slot2 = world
.query::<TextLabel>()
.find(|l| l.asset_id == row_label(2))
.unwrap();
assert!(slot2.visible);
assert_eq!(slot2.content, "line 2");
let slot5 = world
.query::<Sprite>()
.find(|s| s.asset_id == row_bg(5))
.unwrap();
assert!(!slot5.visible);
assert!(
!world
.query::<Sprite>()
.find(|s| s.asset_id == LINE_THUMB)
.unwrap()
.visible
);
}
#[test]
fn edit_line_outside_the_window_hides_the_input() {
let mut world = injected_world();
let l = lines(40);
apply(&mut world, Some(&view(&l, 20, 3)), [20.0, 20.0], size());
let input = world
.query::<TextInput>()
.find(|t| t.asset_id == LINE_INPUT)
.unwrap();
assert!(!input.visible, "a scrolled-away edit line has no control");
assert!(
world
.query::<Sprite>()
.find(|s| s.asset_id == LINE_THUMB)
.unwrap()
.visible,
"a long story shows the scrollbar"
);
}
#[test]
fn create_mode_shows_the_create_row_only() {
let mut world = injected_world();
let l = lines(1);
let v = StoryView {
create: true,
..view(&l, 0, 0)
};
apply(&mut world, Some(&v), [20.0, 20.0], size());
let r0 = world
.query::<TextLabel>()
.find(|l| l.asset_id == row_label(0))
.unwrap();
assert!(r0.visible && r0.content == "+ Create story");
assert!(
!world
.query::<Sprite>()
.find(|s| s.asset_id == APPLY_BG)
.unwrap()
.visible,
"Apply hidden in create mode"
);
assert!(
!world
.query::<TextInput>()
.find(|t| t.asset_id == LINE_INPUT)
.unwrap()
.visible
);
}
#[test]
fn taller_size_reveals_more_lines_up_to_the_pool() {
assert_eq!(visible_rows(size()[1]), LINE_POOL);
assert_eq!(visible_rows(size()[1] + 4.0 * LINE_H), LINE_POOL + 4);
assert_eq!(visible_rows(max_size()[1]), LINE_POOL_MAX);
assert_eq!(visible_rows(10_000.0), LINE_POOL_MAX, "capped at the pool");
}
#[test]
fn hide_all_blanks_every_element() {
let mut world = injected_world();
let l = lines(4);
apply(&mut world, Some(&view(&l, 0, 1)), [20.0, 20.0], size());
apply(&mut world, None, [0.0, 0.0], size());
assert!(world.query::<Sprite>().all(|s| !s.visible));
assert!(world.query::<TextLabel>().all(|l| !l.visible));
assert!(world.query::<TextInput>().all(|t| !t.visible));
}
}