new rendering engine
This commit is contained in:
41
crates/client-domain/src/alignment.rs
Normal file
41
crates/client-domain/src/alignment.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use domain::{HAlign, VAlign};
|
||||
|
||||
#[allow(private_bounds)]
|
||||
pub fn align_offset(container: u16, content: u16, align: impl Into<AlignMode>) -> u16 {
|
||||
let mode = align.into();
|
||||
if content >= container {
|
||||
return 0;
|
||||
}
|
||||
let space = container - content;
|
||||
match mode {
|
||||
AlignMode::Start => 0,
|
||||
AlignMode::Center => space / 2,
|
||||
AlignMode::End => space,
|
||||
}
|
||||
}
|
||||
|
||||
enum AlignMode {
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
impl From<HAlign> for AlignMode {
|
||||
fn from(a: HAlign) -> Self {
|
||||
match a {
|
||||
HAlign::Left => AlignMode::Start,
|
||||
HAlign::Center => AlignMode::Center,
|
||||
HAlign::Right => AlignMode::End,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VAlign> for AlignMode {
|
||||
fn from(a: VAlign) -> Self {
|
||||
match a {
|
||||
VAlign::Top => AlignMode::Start,
|
||||
VAlign::Middle => AlignMode::Center,
|
||||
VAlign::Bottom => AlignMode::End,
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/client-domain/src/color.rs
Normal file
2
crates/client-domain/src/color.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Color(pub u8, pub u8, pub u8);
|
||||
31
crates/client-domain/src/font.rs
Normal file
31
crates/client-domain/src/font.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FontSize {
|
||||
Small,
|
||||
Large,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FontMetrics {
|
||||
pub small: (u16, u16),
|
||||
pub large: (u16, u16),
|
||||
}
|
||||
|
||||
impl FontMetrics {
|
||||
pub fn char_width(&self, size: FontSize) -> u16 {
|
||||
match size {
|
||||
FontSize::Small => self.small.0,
|
||||
FontSize::Large => self.large.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn char_height(&self, size: FontSize) -> u16 {
|
||||
match size {
|
||||
FontSize::Small => self.small.1,
|
||||
FontSize::Large => self.large.1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_width(&self, text: &str, size: FontSize) -> u16 {
|
||||
text.len() as u16 * self.char_width(size)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{BoundingBox, RenderTree};
|
||||
use domain::{ContainerNode, Direction, LayoutNode, Sizing};
|
||||
use domain::{ContainerNode, Direction, JustifyContent, LayoutNode, Sizing};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct LayoutEngine;
|
||||
@@ -61,10 +61,10 @@ impl LayoutEngine {
|
||||
})
|
||||
.sum();
|
||||
|
||||
let mut offset = 0u16;
|
||||
|
||||
for child in children {
|
||||
let child_size = match child.sizing {
|
||||
// Compute each child's main-axis size
|
||||
let child_sizes: Vec<u16> = children
|
||||
.iter()
|
||||
.map(|child| match child.sizing {
|
||||
Sizing::Fixed(px) => px,
|
||||
Sizing::Flex(w) => {
|
||||
if flex_total > 0 {
|
||||
@@ -73,8 +73,22 @@ impl LayoutEngine {
|
||||
0
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
.collect();
|
||||
|
||||
let children_total: u16 = child_sizes.iter().sum();
|
||||
let remaining = total_axis.saturating_sub(children_total + total_gap);
|
||||
|
||||
// Compute starting offset and gap based on justify_content
|
||||
let (mut offset, justify_gap) = Self::justify(
|
||||
container.justify_content,
|
||||
remaining,
|
||||
container.gap as u16,
|
||||
children.len(),
|
||||
);
|
||||
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
let child_size = child_sizes[i];
|
||||
let child_bounds = if is_row {
|
||||
BoundingBox::new(inner.x + offset, inner.y, child_size, inner.height)
|
||||
} else {
|
||||
@@ -82,7 +96,35 @@ impl LayoutEngine {
|
||||
};
|
||||
|
||||
Self::compute_node(&child.node, child_bounds, out);
|
||||
offset += child_size + container.gap as u16;
|
||||
offset += child_size + justify_gap;
|
||||
}
|
||||
}
|
||||
|
||||
fn justify(
|
||||
mode: JustifyContent,
|
||||
remaining: u16,
|
||||
explicit_gap: u16,
|
||||
count: usize,
|
||||
) -> (u16, u16) {
|
||||
if count == 0 {
|
||||
return (0, explicit_gap);
|
||||
}
|
||||
match mode {
|
||||
JustifyContent::Start => (0, explicit_gap),
|
||||
JustifyContent::Center => (remaining / 2, explicit_gap),
|
||||
JustifyContent::End => (remaining, explicit_gap),
|
||||
JustifyContent::SpaceBetween => {
|
||||
if count <= 1 {
|
||||
return (0, explicit_gap);
|
||||
}
|
||||
let gap = remaining / (count as u16 - 1);
|
||||
(0, explicit_gap + gap)
|
||||
}
|
||||
JustifyContent::SpaceEvenly => {
|
||||
let slots = count as u16 + 1;
|
||||
let gap = remaining / slots;
|
||||
(gap, explicit_gap + gap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
mod alignment;
|
||||
mod bounding_box;
|
||||
mod color;
|
||||
mod font;
|
||||
mod layout_engine;
|
||||
mod markup;
|
||||
pub mod ports;
|
||||
mod render_engine;
|
||||
mod render_tree;
|
||||
mod scroll;
|
||||
mod text_layout;
|
||||
mod theme;
|
||||
|
||||
pub use alignment::align_offset;
|
||||
pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
|
||||
pub use bounding_box::BoundingBox;
|
||||
pub use color::Color;
|
||||
pub use font::{FontMetrics, FontSize};
|
||||
pub use layout_engine::LayoutEngine;
|
||||
pub use markup::{parse_markup, TextSpan};
|
||||
pub use render_engine::{DrawCommand, RenderEngine};
|
||||
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
|
||||
pub use render_tree::RenderTree;
|
||||
pub use scroll::ScrollState;
|
||||
pub use text_layout::wrap_lines;
|
||||
pub use theme::ThemeConfig;
|
||||
|
||||
64
crates/client-domain/src/markup.rs
Normal file
64
crates/client-domain/src/markup.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use crate::{Color, ThemeConfig};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TextSpan {
|
||||
pub text: String,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec<TextSpan> {
|
||||
if input.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut spans = Vec::new();
|
||||
let mut current_color = theme.text;
|
||||
let mut current_text = String::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '{' {
|
||||
let mut tag = String::new();
|
||||
for c in chars.by_ref() {
|
||||
if c == '}' {
|
||||
break;
|
||||
}
|
||||
tag.push(c);
|
||||
}
|
||||
|
||||
if let Some(new_color) = resolve_tag(&tag, theme) {
|
||||
if !current_text.is_empty() {
|
||||
spans.push(TextSpan { text: current_text.clone(), color: current_color });
|
||||
current_text.clear();
|
||||
}
|
||||
current_color = new_color;
|
||||
}
|
||||
} else {
|
||||
current_text.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
if !current_text.is_empty() {
|
||||
spans.push(TextSpan { text: current_text, color: current_color });
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
fn resolve_tag(tag: &str, theme: &ThemeConfig) -> Option<Color> {
|
||||
match tag {
|
||||
"/" => Some(theme.text),
|
||||
"primary" => Some(theme.primary),
|
||||
"secondary" => Some(theme.secondary),
|
||||
"accent" => Some(theme.accent),
|
||||
s if s.starts_with('#') && s.len() == 7 => parse_hex_color(&s[1..]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex_color(hex: &str) -> Option<Color> {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
Some(Color(r, g, b))
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
use crate::BoundingBox;
|
||||
use crate::{BoundingBox, Color, FontSize};
|
||||
|
||||
pub trait DisplayPort {
|
||||
type Error;
|
||||
|
||||
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
|
||||
fn draw_text(
|
||||
fn draw_text_span(
|
||||
&mut self,
|
||||
text: &str,
|
||||
x: u16,
|
||||
y: u16,
|
||||
bounds: BoundingBox,
|
||||
color: Color,
|
||||
font: FontSize,
|
||||
) -> Result<(), Self::Error>;
|
||||
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error>;
|
||||
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>;
|
||||
|
||||
fn fill_rect(&mut self, bounds: BoundingBox, color: Color) -> Result<(), Self::Error>;
|
||||
|
||||
fn flush(&mut self) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
||||
194
crates/client-domain/src/render_engine.rs
Normal file
194
crates/client-domain/src/render_engine.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use crate::{
|
||||
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig,
|
||||
alignment::align_offset, markup::parse_markup, text_layout::wrap_lines,
|
||||
};
|
||||
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DrawCommand {
|
||||
pub text: String,
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
pub color: Color,
|
||||
pub font: FontSize,
|
||||
}
|
||||
|
||||
pub struct RenderEngine {
|
||||
metrics: FontMetrics,
|
||||
theme: ThemeConfig,
|
||||
}
|
||||
|
||||
impl RenderEngine {
|
||||
pub fn new(metrics: FontMetrics, theme: ThemeConfig) -> Self {
|
||||
Self { metrics, theme }
|
||||
}
|
||||
|
||||
pub fn theme(&self) -> &ThemeConfig {
|
||||
&self.theme
|
||||
}
|
||||
|
||||
pub fn set_theme(&mut self, theme: ThemeConfig) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
pub fn render_text(
|
||||
&self,
|
||||
text: &str,
|
||||
bounds: BoundingBox,
|
||||
h_align: HAlign,
|
||||
v_align: VAlign,
|
||||
) -> Vec<DrawCommand> {
|
||||
let spans = parse_markup(text, &self.theme);
|
||||
let plain: String = spans.iter().map(|s| s.text.as_str()).collect();
|
||||
|
||||
let lines = wrap_lines(&plain, bounds.width, FontSize::Small, &self.metrics);
|
||||
let line_h = self.metrics.char_height(FontSize::Small);
|
||||
let total_h = lines.len() as u16 * line_h;
|
||||
let y_offset = align_offset(bounds.height, total_h, v_align);
|
||||
|
||||
let mut cmds = Vec::new();
|
||||
let mut plain_pos = 0usize;
|
||||
|
||||
for (line_idx, line) in lines.iter().enumerate() {
|
||||
let line_w = self.metrics.text_width(line, FontSize::Small);
|
||||
let x_offset = align_offset(bounds.width, line_w, h_align);
|
||||
let y = bounds.y + y_offset + line_idx as u16 * line_h;
|
||||
|
||||
let line_start = plain_pos;
|
||||
let line_end = line_start + line.len();
|
||||
|
||||
// Map line characters back to colored spans
|
||||
let mut char_pos = line_start;
|
||||
while char_pos < line_end {
|
||||
let (color, span_end) = self.color_at(&spans, char_pos, line_end, &plain);
|
||||
let segment = &plain[char_pos..span_end];
|
||||
let seg_offset = (char_pos - line_start) as u16 * self.metrics.char_width(FontSize::Small);
|
||||
|
||||
cmds.push(DrawCommand {
|
||||
text: segment.to_string(),
|
||||
x: bounds.x + x_offset + seg_offset,
|
||||
y,
|
||||
color,
|
||||
font: FontSize::Small,
|
||||
});
|
||||
|
||||
char_pos = span_end;
|
||||
}
|
||||
|
||||
plain_pos = line_end;
|
||||
// Skip whitespace between lines (the space that caused the wrap)
|
||||
if plain_pos < plain.len() && plain.as_bytes()[plain_pos] == b' ' {
|
||||
plain_pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
cmds
|
||||
}
|
||||
|
||||
pub fn render_widget(
|
||||
&self,
|
||||
hint: &DisplayHint,
|
||||
data: &[(String, Value)],
|
||||
bounds: BoundingBox,
|
||||
scroll_offset: u16,
|
||||
) -> Vec<DrawCommand> {
|
||||
let text = self.format_widget(hint, data);
|
||||
let mut cmds = self.render_text(&text, bounds, hint.h_align, hint.v_align);
|
||||
|
||||
if scroll_offset > 0 {
|
||||
for cmd in &mut cmds {
|
||||
cmd.y = cmd.y.saturating_sub(scroll_offset);
|
||||
}
|
||||
// Drop commands that scrolled above bounds
|
||||
cmds.retain(|cmd| cmd.y + self.metrics.char_height(cmd.font) > bounds.y && cmd.y < bounds.y + bounds.height);
|
||||
}
|
||||
|
||||
cmds
|
||||
}
|
||||
|
||||
pub fn content_height(
|
||||
&self,
|
||||
hint: &DisplayHint,
|
||||
data: &[(String, Value)],
|
||||
width: u16,
|
||||
) -> u16 {
|
||||
let text = self.format_widget(hint, data);
|
||||
let plain: String = parse_markup(&text, &self.theme)
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect();
|
||||
let lines = wrap_lines(&plain, width, FontSize::Small, &self.metrics);
|
||||
lines.len() as u16 * self.metrics.char_height(FontSize::Small)
|
||||
}
|
||||
|
||||
fn format_widget(&self, hint: &DisplayHint, data: &[(String, Value)]) -> String {
|
||||
match hint.kind {
|
||||
DisplayHintKind::TextBlock => {
|
||||
data.iter()
|
||||
.filter_map(|(_, v)| value_to_string(v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
DisplayHintKind::KeyValue => {
|
||||
data.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let val = value_to_string(v)?;
|
||||
Some(format!("{{secondary}}{k}{{/}}: {val}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
DisplayHintKind::IconValue => {
|
||||
let mut parts = Vec::new();
|
||||
for (k, v) in data {
|
||||
if k == "icon" {
|
||||
if let Some(s) = value_to_string(v) {
|
||||
parts.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, v) in data {
|
||||
if k != "icon" {
|
||||
if let Some(s) = value_to_string(v) {
|
||||
parts.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn color_at(
|
||||
&self,
|
||||
spans: &[crate::markup::TextSpan],
|
||||
pos: usize,
|
||||
line_end: usize,
|
||||
_plain: &str,
|
||||
) -> (Color, usize) {
|
||||
let mut offset = 0usize;
|
||||
for span in spans {
|
||||
let span_end = offset + span.text.len();
|
||||
if pos >= offset && pos < span_end {
|
||||
let end = span_end.min(line_end);
|
||||
return (span.color, end);
|
||||
}
|
||||
offset = span_end;
|
||||
}
|
||||
(self.theme.text, line_end)
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_string(v: &Value) -> Option<String> {
|
||||
match v {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
Value::Bool(b) => Some(b.to_string()),
|
||||
Value::Null => None,
|
||||
Value::Array(arr) => {
|
||||
let items: Vec<String> = arr.iter().filter_map(value_to_string).collect();
|
||||
if items.is_empty() { None } else { Some(items.join(", ")) }
|
||||
}
|
||||
Value::Object(_) => None,
|
||||
}
|
||||
}
|
||||
82
crates/client-domain/src/scroll.rs
Normal file
82
crates/client-domain/src/scroll.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::time::Duration;
|
||||
|
||||
const PAUSE_DURATION: Duration = Duration::from_secs(2);
|
||||
const SCROLL_SPEED_PX_PER_SEC: f32 = 30.0;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScrollState {
|
||||
overflow: u16,
|
||||
offset: f32,
|
||||
direction: ScrollDirection,
|
||||
pause_elapsed: Duration,
|
||||
pausing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ScrollDirection {
|
||||
Forward,
|
||||
Backward,
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
pub fn new(container: u16, content: u16) -> Self {
|
||||
Self {
|
||||
overflow: content.saturating_sub(container),
|
||||
offset: 0.0,
|
||||
direction: ScrollDirection::Forward,
|
||||
pause_elapsed: Duration::ZERO,
|
||||
pausing: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.overflow > 0
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> u16 {
|
||||
self.offset as u16
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, container: u16, content: u16) {
|
||||
*self = Self::new(container, content);
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, elapsed: Duration) -> bool {
|
||||
if !self.is_active() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.pausing {
|
||||
self.pause_elapsed += elapsed;
|
||||
if self.pause_elapsed < PAUSE_DURATION {
|
||||
return false;
|
||||
}
|
||||
self.pausing = false;
|
||||
self.pause_elapsed = Duration::ZERO;
|
||||
}
|
||||
|
||||
let prev_offset = self.offset as u16;
|
||||
let delta = SCROLL_SPEED_PX_PER_SEC * elapsed.as_secs_f32();
|
||||
|
||||
match self.direction {
|
||||
ScrollDirection::Forward => {
|
||||
self.offset += delta;
|
||||
if self.offset >= self.overflow as f32 {
|
||||
self.offset = self.overflow as f32;
|
||||
self.direction = ScrollDirection::Backward;
|
||||
self.pausing = true;
|
||||
}
|
||||
}
|
||||
ScrollDirection::Backward => {
|
||||
self.offset -= delta;
|
||||
if self.offset <= 0.0 {
|
||||
self.offset = 0.0;
|
||||
self.direction = ScrollDirection::Forward;
|
||||
self.pausing = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.offset as u16 != prev_offset
|
||||
}
|
||||
}
|
||||
115
crates/client-domain/src/text_layout.rs
Normal file
115
crates/client-domain/src/text_layout.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use crate::{FontMetrics, FontSize};
|
||||
|
||||
pub fn wrap_lines<'a>(text: &'a str, max_width: u16, font: FontSize, metrics: &FontMetrics) -> Vec<&'a str> {
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let char_w = metrics.char_width(font);
|
||||
let max_chars = (max_width / char_w) as usize;
|
||||
|
||||
if max_chars == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut line_start = 0;
|
||||
let mut line_end = 0;
|
||||
|
||||
for word_start in WordStarts::new(text) {
|
||||
let word_end = text[word_start..].find(' ').map_or(text.len(), |i| word_start + i);
|
||||
|
||||
if line_start == line_end {
|
||||
// First word on this line
|
||||
if word_end - word_start > max_chars {
|
||||
// Word itself doesn't fit — character break
|
||||
let mut pos = word_start;
|
||||
while pos < word_end {
|
||||
let end = (pos + max_chars).min(word_end);
|
||||
lines.push(&text[pos..end]);
|
||||
pos = end;
|
||||
}
|
||||
line_start = word_end;
|
||||
line_end = word_end;
|
||||
// Skip trailing space
|
||||
if line_start < text.len() && text.as_bytes()[line_start] == b' ' {
|
||||
line_start += 1;
|
||||
line_end = line_start;
|
||||
}
|
||||
} else {
|
||||
line_end = word_end;
|
||||
}
|
||||
} else {
|
||||
// Adding word to existing line: line_end + " " + word
|
||||
let new_len = word_end - line_start;
|
||||
if new_len <= max_chars {
|
||||
line_end = word_end;
|
||||
} else {
|
||||
// Flush current line, start new one with this word
|
||||
lines.push(&text[line_start..line_end]);
|
||||
if word_end - word_start > max_chars {
|
||||
let mut pos = word_start;
|
||||
while pos < word_end {
|
||||
let end = (pos + max_chars).min(word_end);
|
||||
lines.push(&text[pos..end]);
|
||||
pos = end;
|
||||
}
|
||||
line_start = word_end;
|
||||
line_end = word_end;
|
||||
if line_start < text.len() && text.as_bytes()[line_start] == b' ' {
|
||||
line_start += 1;
|
||||
line_end = line_start;
|
||||
}
|
||||
} else {
|
||||
line_start = word_start;
|
||||
line_end = word_end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if line_end > line_start {
|
||||
lines.push(&text[line_start..line_end]);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
struct WordStarts<'a> {
|
||||
text: &'a str,
|
||||
pos: usize,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl<'a> WordStarts<'a> {
|
||||
fn new(text: &'a str) -> Self {
|
||||
Self { text, pos: 0, started: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for WordStarts<'_> {
|
||||
type Item = usize;
|
||||
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
if !self.started {
|
||||
self.started = true;
|
||||
if self.pos < self.text.len() {
|
||||
return Some(0);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
while self.pos < self.text.len() {
|
||||
if self.text.as_bytes()[self.pos] == b' ' {
|
||||
self.pos += 1;
|
||||
if self.pos < self.text.len() {
|
||||
let start = self.pos;
|
||||
return Some(start);
|
||||
}
|
||||
} else {
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
22
crates/client-domain/src/theme.rs
Normal file
22
crates/client-domain/src/theme.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::Color;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ThemeConfig {
|
||||
pub primary: Color,
|
||||
pub secondary: Color,
|
||||
pub accent: Color,
|
||||
pub text: Color,
|
||||
pub background: Color,
|
||||
}
|
||||
|
||||
impl Default for ThemeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
primary: Color(0x00, 0x7A, 0xCC),
|
||||
secondary: Color(0x88, 0x88, 0x88),
|
||||
accent: Color(0xE9, 0x45, 0x60),
|
||||
text: Color(0xFF, 0xFF, 0xFF),
|
||||
background: Color(0x00, 0x00, 0x00),
|
||||
}
|
||||
}
|
||||
}
|
||||
47
crates/client-domain/tests/alignment_tests.rs
Normal file
47
crates/client-domain/tests/alignment_tests.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use client_domain::{BoundingBox, HAlign, VAlign, align_offset};
|
||||
|
||||
#[test]
|
||||
fn halign_left_is_zero_offset() {
|
||||
let offset = align_offset(100, 60, HAlign::Left);
|
||||
assert_eq!(offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halign_center_centers_content() {
|
||||
// 100px container, 60px content → 20px offset
|
||||
let offset = align_offset(100, 60, HAlign::Center);
|
||||
assert_eq!(offset, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halign_right_pushes_to_end() {
|
||||
// 100px container, 60px content → 40px offset
|
||||
let offset = align_offset(100, 60, HAlign::Right);
|
||||
assert_eq!(offset, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valign_top_is_zero_offset() {
|
||||
let offset = align_offset(200, 30, VAlign::Top);
|
||||
assert_eq!(offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valign_middle_centers_content() {
|
||||
// 200px container, 30px content → 85px offset
|
||||
let offset = align_offset(200, 30, VAlign::Middle);
|
||||
assert_eq!(offset, 85);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valign_bottom_pushes_to_end() {
|
||||
// 200px container, 30px content → 170px offset
|
||||
let offset = align_offset(200, 30, VAlign::Bottom);
|
||||
assert_eq!(offset, 170);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_larger_than_container_clamps_to_zero() {
|
||||
let offset = align_offset(50, 100, HAlign::Center);
|
||||
assert_eq!(offset, 0);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
|
||||
use domain::{ContainerNode, Direction, LayoutChild, LayoutNode, Sizing};
|
||||
use domain::{AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing};
|
||||
|
||||
fn screen() -> BoundingBox {
|
||||
BoundingBox::screen(240, 320)
|
||||
@@ -24,6 +24,8 @@ fn row(children: Vec<LayoutChild>) -> LayoutNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children,
|
||||
})
|
||||
}
|
||||
@@ -33,6 +35,8 @@ fn column(children: Vec<LayoutChild>) -> LayoutNode {
|
||||
direction: Direction::Column,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children,
|
||||
})
|
||||
}
|
||||
@@ -42,6 +46,8 @@ fn row_with_gap(gap: u8, children: Vec<LayoutChild>) -> LayoutNode {
|
||||
direction: Direction::Row,
|
||||
gap,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children,
|
||||
})
|
||||
}
|
||||
@@ -51,6 +57,8 @@ fn row_with_padding(padding: u8, children: Vec<LayoutChild>) -> LayoutNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children,
|
||||
})
|
||||
}
|
||||
@@ -174,6 +182,8 @@ fn weighted_flex_distributes_proportionally() {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![
|
||||
LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
@@ -204,3 +214,106 @@ fn weighted_flex_distributes_proportionally() {
|
||||
Some(&BoundingBox::new(180, 0, 60, 320))
|
||||
);
|
||||
}
|
||||
|
||||
// --- JustifyContent tests ---
|
||||
|
||||
#[test]
|
||||
fn justify_center_centers_fixed_children_on_main_axis() {
|
||||
// Row 240px, two fixed 40px children → 160px remaining, offset = 80
|
||||
let layout = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![leaf_fixed(1, 40), leaf_fixed(2, 40)],
|
||||
});
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(80, 0, 40, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(120, 0, 40, 320)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justify_end_pushes_to_end() {
|
||||
// Row 240px, one fixed 40px → offset = 200
|
||||
let layout = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::End,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![leaf_fixed(1, 40)],
|
||||
});
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(200, 0, 40, 320)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justify_space_between_distributes_gaps() {
|
||||
// Row 240px, three fixed 40px → 120px used, 120px remaining, 2 gaps of 60px
|
||||
let layout = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![leaf_fixed(1, 40), leaf_fixed(2, 40), leaf_fixed(3, 40)],
|
||||
});
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(100, 0, 40, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(200, 0, 40, 320)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justify_space_evenly_distributes_with_edges() {
|
||||
// Row 240px, two fixed 40px → 80px used, 160px remaining, 3 slots of 53px (int div)
|
||||
let layout = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::SpaceEvenly,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![leaf_fixed(1, 40), leaf_fixed(2, 40)],
|
||||
});
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
// 160 / 3 = 53px per slot
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(53, 0, 40, 320)));
|
||||
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(146, 0, 40, 320)));
|
||||
}
|
||||
|
||||
// --- AlignItems tests ---
|
||||
|
||||
#[test]
|
||||
fn align_items_center_centers_on_cross_axis() {
|
||||
// Row 240×320, fixed child 40px wide. AlignItems::Center → child centered vertically
|
||||
// Cross axis = 320, child height stays 320 for Stretch.
|
||||
// With Center, child gets its natural size. For a leaf, "natural" = full cross.
|
||||
// Actually: fixed children have explicit main-axis size. Cross-axis with Center
|
||||
// should give the child the full cross-axis (we don't know natural cross size for leaves).
|
||||
// So for leaves, Center behaves like Stretch. This test verifies columns:
|
||||
// Column 240×320, fixed child 100px tall. AlignItems::Center → centered on 240px width.
|
||||
// But again, leaf has no natural width. For now: non-Stretch gives child full cross-axis.
|
||||
// Let's test with a nested container that has known size instead.
|
||||
// Actually, the simplest useful behavior: AlignItems on a row affects child y-position.
|
||||
// For a fixed-height child in a column, Center means child doesn't stretch to full width.
|
||||
// But we have no "natural width" concept for leaves. Let's just verify Stretch = full cross
|
||||
// and Center = full cross (since we can't shrink without natural size).
|
||||
// This is a design limitation we can revisit.
|
||||
let layout = LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![leaf_fixed(1, 40)],
|
||||
});
|
||||
let tree = LayoutEngine::compute(&layout, screen());
|
||||
|
||||
// Stretch: child gets full cross-axis height
|
||||
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320)));
|
||||
}
|
||||
|
||||
67
crates/client-domain/tests/markup_tests.rs
Normal file
67
crates/client-domain/tests/markup_tests.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use client_domain::{Color, TextSpan, ThemeConfig, parse_markup};
|
||||
|
||||
fn theme() -> ThemeConfig {
|
||||
ThemeConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_produces_single_span() {
|
||||
let spans = parse_markup("hello world", &theme());
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "hello world".into(), color: theme().text },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_color_span() {
|
||||
let spans = parse_markup("temp: {#FF0000}72°F{/}", &theme());
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "temp: ".into(), color: theme().text },
|
||||
TextSpan { text: "72°F".into(), color: Color(0xFF, 0, 0) },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_color_spans() {
|
||||
let t = theme();
|
||||
let spans = parse_markup("{primary}hello{/} {accent}world{/}", &t);
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "hello".into(), color: t.primary },
|
||||
TextSpan { text: " ".into(), color: t.text },
|
||||
TextSpan { text: "world".into(), color: t.accent },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_to_text_color() {
|
||||
let t = theme();
|
||||
let spans = parse_markup("{accent}hi{/}bye", &t);
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "hi".into(), color: t.accent },
|
||||
TextSpan { text: "bye".into(), color: t.text },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_produces_no_spans() {
|
||||
let spans = parse_markup("", &theme());
|
||||
assert_eq!(spans, Vec::<TextSpan>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_color_spans_no_text_between() {
|
||||
let t = theme();
|
||||
let spans = parse_markup("{primary}a{secondary}b{/}", &t);
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "a".into(), color: t.primary },
|
||||
TextSpan { text: "b".into(), color: t.secondary },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tag_treated_as_literal() {
|
||||
let spans = parse_markup("{unknown}text", &theme());
|
||||
assert_eq!(spans, vec![
|
||||
TextSpan { text: "text".into(), color: theme().text },
|
||||
]);
|
||||
}
|
||||
83
crates/client-domain/tests/render_engine_tests.rs
Normal file
83
crates/client-domain/tests/render_engine_tests.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use client_domain::{
|
||||
BoundingBox, Color, DrawCommand, FontMetrics, FontSize, HAlign, RenderEngine,
|
||||
ThemeConfig, VAlign,
|
||||
};
|
||||
|
||||
fn metrics() -> FontMetrics {
|
||||
FontMetrics {
|
||||
small: (6, 10),
|
||||
large: (10, 20),
|
||||
}
|
||||
}
|
||||
|
||||
fn theme() -> ThemeConfig {
|
||||
ThemeConfig::default()
|
||||
}
|
||||
|
||||
fn bounds(w: u16, h: u16) -> BoundingBox {
|
||||
BoundingBox::new(0, 0, w, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textblock_renders_plain_text() {
|
||||
let engine = RenderEngine::new(metrics(), theme());
|
||||
let cmds = engine.render_text("hello", bounds(100, 40), HAlign::Left, VAlign::Top);
|
||||
|
||||
assert_eq!(cmds.len(), 1);
|
||||
assert_eq!(cmds[0].text, "hello");
|
||||
assert_eq!(cmds[0].x, 0);
|
||||
assert_eq!(cmds[0].y, 0);
|
||||
assert_eq!(cmds[0].color, theme().text);
|
||||
assert_eq!(cmds[0].font, FontSize::Small);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_centered_horizontally() {
|
||||
let engine = RenderEngine::new(metrics(), theme());
|
||||
// "hi" = 12px, bounds = 100px → offset = 44
|
||||
let cmds = engine.render_text("hi", bounds(100, 40), HAlign::Center, VAlign::Top);
|
||||
|
||||
assert_eq!(cmds[0].x, 44);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_centered_vertically() {
|
||||
let engine = RenderEngine::new(metrics(), theme());
|
||||
// 1 line = 10px height, bounds = 40px → offset = 15
|
||||
let cmds = engine.render_text("hi", bounds(100, 40), HAlign::Left, VAlign::Middle);
|
||||
|
||||
assert_eq!(cmds[0].y, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_wraps_and_stacks_lines() {
|
||||
let engine = RenderEngine::new(metrics(), theme());
|
||||
// "hello world" at 40px wide → "hello" + "world", each at 6x10
|
||||
let cmds = engine.render_text("hello world", bounds(40, 100), HAlign::Left, VAlign::Top);
|
||||
|
||||
assert_eq!(cmds.len(), 2);
|
||||
assert_eq!(cmds[0].text, "hello");
|
||||
assert_eq!(cmds[0].y, 0);
|
||||
assert_eq!(cmds[1].text, "world");
|
||||
assert_eq!(cmds[1].y, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_markup_produces_colored_spans() {
|
||||
let engine = RenderEngine::new(metrics(), theme());
|
||||
let cmds = engine.render_text("{accent}hi{/}", bounds(100, 40), HAlign::Left, VAlign::Top);
|
||||
|
||||
assert_eq!(cmds.len(), 1);
|
||||
assert_eq!(cmds[0].text, "hi");
|
||||
assert_eq!(cmds[0].color, theme().accent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_offset_applied() {
|
||||
let engine = RenderEngine::new(metrics(), theme());
|
||||
let b = BoundingBox::new(10, 20, 100, 40);
|
||||
let cmds = engine.render_text("hi", b, HAlign::Left, VAlign::Top);
|
||||
|
||||
assert_eq!(cmds[0].x, 10);
|
||||
assert_eq!(cmds[0].y, 20);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use client_domain::{BoundingBox, LayoutEngine};
|
||||
use domain::{ContainerNode, Direction, LayoutChild, LayoutNode, Sizing};
|
||||
use domain::{AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing};
|
||||
|
||||
fn screen() -> BoundingBox {
|
||||
BoundingBox::screen(240, 320)
|
||||
@@ -11,6 +11,8 @@ fn diff_detects_moved_widget_after_layout_change() {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![
|
||||
LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
@@ -27,6 +29,8 @@ fn diff_detects_moved_widget_after_layout_change() {
|
||||
direction: Direction::Column,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![
|
||||
LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
@@ -53,6 +57,8 @@ fn diff_returns_empty_for_identical_layouts() {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![
|
||||
LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
@@ -77,6 +83,8 @@ fn diff_detects_added_and_removed_widgets() {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
node: LayoutNode::Leaf(1),
|
||||
@@ -87,6 +95,8 @@ fn diff_detects_added_and_removed_widgets() {
|
||||
direction: Direction::Row,
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
children: vec![LayoutChild {
|
||||
sizing: Sizing::Flex(1),
|
||||
node: LayoutNode::Leaf(2),
|
||||
|
||||
72
crates/client-domain/tests/scroll_tests.rs
Normal file
72
crates/client-domain/tests/scroll_tests.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use client_domain::ScrollState;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn no_overflow_means_zero_offset() {
|
||||
let scroll = ScrollState::new(100, 80);
|
||||
assert_eq!(scroll.offset(), 0);
|
||||
assert!(!scroll.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_starts_at_zero_offset() {
|
||||
let scroll = ScrollState::new(100, 200);
|
||||
assert_eq!(scroll.offset(), 0);
|
||||
assert!(scroll.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_advances_offset_after_initial_pause() {
|
||||
let mut scroll = ScrollState::new(100, 200);
|
||||
// Overflow = 100px. Initial pause = 2s.
|
||||
// Tick past the pause
|
||||
assert!(scroll.tick(Duration::from_secs(3)));
|
||||
assert!(scroll.offset() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_returns_false_when_no_movement() {
|
||||
let mut scroll = ScrollState::new(100, 80);
|
||||
assert!(!scroll.tick(Duration::from_millis(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_never_exceeds_overflow() {
|
||||
let mut scroll = ScrollState::new(100, 200);
|
||||
// Tick many times — offset should cap at overflow (100)
|
||||
for _ in 0..1000 {
|
||||
scroll.tick(Duration::from_millis(100));
|
||||
}
|
||||
assert!(scroll.offset() <= 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounces_back_after_reaching_end() {
|
||||
let mut scroll = ScrollState::new(100, 150);
|
||||
// Overflow = 50px. Tick until we reach the end and bounce back.
|
||||
// After enough ticks, offset should return to 0.
|
||||
let mut seen_nonzero = false;
|
||||
let mut returned_to_zero = false;
|
||||
for _ in 0..2000 {
|
||||
scroll.tick(Duration::from_millis(50));
|
||||
if scroll.offset() > 0 {
|
||||
seen_nonzero = true;
|
||||
}
|
||||
if seen_nonzero && scroll.offset() == 0 {
|
||||
returned_to_zero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(seen_nonzero, "should have scrolled");
|
||||
assert!(returned_to_zero, "should have bounced back to 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_restarts_scroll() {
|
||||
let mut scroll = ScrollState::new(100, 200);
|
||||
for _ in 0..100 {
|
||||
scroll.tick(Duration::from_millis(100));
|
||||
}
|
||||
scroll.reset(100, 200);
|
||||
assert_eq!(scroll.offset(), 0);
|
||||
}
|
||||
51
crates/client-domain/tests/text_layout_tests.rs
Normal file
51
crates/client-domain/tests/text_layout_tests.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use client_domain::{FontMetrics, FontSize, wrap_lines};
|
||||
|
||||
fn metrics() -> FontMetrics {
|
||||
FontMetrics {
|
||||
small: (6, 10),
|
||||
large: (10, 20),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_fits_returns_single_line() {
|
||||
// "hello" = 5 chars × 6px = 30px, available = 100px
|
||||
let lines = wrap_lines("hello", 100, FontSize::Small, &metrics());
|
||||
assert_eq!(lines, vec!["hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_wraps_at_word_boundary() {
|
||||
// "hello world" = 11 chars × 6px = 66px, available = 40px
|
||||
// "hello" = 30px fits, "world" = 30px fits on next line
|
||||
let lines = wrap_lines("hello world", 40, FontSize::Small, &metrics());
|
||||
assert_eq!(lines, vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_word_breaks_by_character() {
|
||||
// "abcdefghij" = 10 chars × 6px = 60px, available = 36px (6 chars)
|
||||
let lines = wrap_lines("abcdefghij", 36, FontSize::Small, &metrics());
|
||||
assert_eq!(lines, vec!["abcdef", "ghij"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_text_returns_empty() {
|
||||
let lines = wrap_lines("", 100, FontSize::Small, &metrics());
|
||||
assert_eq!(lines, Vec::<&str>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_words_wrap_across_lines() {
|
||||
// available = 42px (7 chars)
|
||||
// "one two three" → "one two" (7 chars = 42px), "three" (5 chars = 30px)
|
||||
let lines = wrap_lines("one two three", 42, FontSize::Small, &metrics());
|
||||
assert_eq!(lines, vec!["one two", "three"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_large_font_metrics() {
|
||||
// "hi" = 2 chars × 10px = 20px, available = 15px (1 char)
|
||||
let lines = wrap_lines("hi", 15, FontSize::Large, &metrics());
|
||||
assert_eq!(lines, vec!["h", "i"]);
|
||||
}
|
||||
Reference in New Issue
Block a user