use crate::compat::{format, Cow, RefCell, String, ToString, Vec};
use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::{RenderContext, TEXT_FIT_MARGIN};
use crate::signal::Signal1;
use crate::style::EdgeOffsets;
use crate::widget::capability::coercion::{
alignment_to_str, expect_alignment, expect_bool, expect_string, expect_u32,
};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{
dimensions, effective_font, estimate_line_height, estimate_text_width, ControlMetrics,
};
use crate::widget::text_utils::{
byte_index_of_char, elide_middle_to_width, elide_to_width, wrap_to_width,
};
use crate::widget::{BaseWidget, Draw, InteractionFade, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const LABEL_INTERACTION_TINT: f32 = 0.22;
const LABEL_PADDING: EdgeOffsets = EdgeOffsets { left: 2, top: 2, right: 2, bottom: 2 };
const LABEL_MIN_WIDTH: u32 = 16;
const LABEL_LINK_TINT: f32 = 0.55;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextFormat {
#[default]
PlainText,
RichText,
}
impl TextFormat {
pub const fn as_str(self) -> &'static str {
match self {
TextFormat::PlainText => "plain",
TextFormat::RichText => "rich",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"plain" => TextFormat::PlainText,
"rich" => TextFormat::RichText,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineLink {
pub start: usize,
pub end: usize,
pub href: String,
}
#[derive(Debug, Clone, PartialEq)]
struct RichTextRun {
text: String,
bold: bool,
italic: bool,
link: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct RichTextStyle {
bold: bool,
italic: bool,
link: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LabelLine {
text: String,
start: usize,
end: usize,
}
#[derive(Debug, Clone)]
struct LineLayout {
text_revision: u32,
width: u32,
font: Font,
line_height: u32,
lines: Vec<LabelLine>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextElideMode {
None,
ElideLeft,
ElideMiddle,
#[default]
ElideRight,
}
impl TextElideMode {
pub const fn as_str(self) -> &'static str {
match self {
TextElideMode::None => "none",
TextElideMode::ElideLeft => "left",
TextElideMode::ElideMiddle => "middle",
TextElideMode::ElideRight => "right",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"none" => TextElideMode::None,
"left" => TextElideMode::ElideLeft,
"middle" => TextElideMode::ElideMiddle,
"right" => TextElideMode::ElideRight,
_ => return None,
})
}
}
pub(crate) fn elide_in_mode<'a>(
text: &'a str,
width: u32,
mode: TextElideMode,
measure: &mut dyn FnMut(&str) -> u32,
) -> Cow<'a, str> {
match mode {
TextElideMode::None => Cow::Borrowed(text),
TextElideMode::ElideRight => elide_to_width(text, width, measure),
TextElideMode::ElideMiddle => elide_middle_to_width(text, width, measure),
TextElideMode::ElideLeft => {
if measure(text) <= width {
return Cow::Borrowed(text);
}
let count = text.chars().count();
if width == 0 || measure("\u{2026}") > width {
return Cow::Borrowed("");
}
let mut low = 0usize;
let mut high = count;
while low < high {
let mid = (low + high).div_ceil(2);
let start = byte_index_of_char(text, count - mid);
let mut candidate = String::with_capacity(text.len() - start + 4);
candidate.push('\u{2026}');
candidate.push_str(&text[start..]);
if measure(&candidate) <= width {
low = mid;
} else {
high = mid - 1;
}
}
let start = byte_index_of_char(text, count - low);
let mut out = String::with_capacity(text.len() - start + 4);
out.push('\u{2026}');
out.push_str(&text[start..]);
Cow::Owned(out)
}
}
}
fn wrap_text_ranges(
text: &str,
width: u32,
measure: &mut dyn FnMut(&str) -> u32,
) -> Vec<LabelLine> {
if width == 0 {
return vec![LabelLine { text: text.to_string(), start: 0, end: text.len() }];
}
let (normalised, map) = collapse_whitespace_with_map(text);
wrap_to_width(&normalised, width, measure)
.into_iter()
.map(|range| {
let start = map.get(range.start).copied().unwrap_or(text.len());
let end = if range.end > range.start {
map.get(range.end - 1)
.copied()
.map(|last| {
let char_len = text[last..].chars().next().map_or(0, |ch| ch.len_utf8());
last + char_len
})
.unwrap_or(text.len())
} else {
start
};
LabelLine { text: normalised[range].to_string(), start, end }
})
.collect()
}
fn collapse_whitespace_with_map(text: &str) -> (String, Vec<usize>) {
let mut out = String::with_capacity(text.len());
let mut map: Vec<usize> = Vec::with_capacity(text.len());
let mut paragraph_has_content = false;
let mut pending_space: Option<usize> = None;
let mut index = 0usize;
while index < text.len() {
let rest = &text[index..];
let Some(ch) = rest.chars().next() else {
break;
};
let len = ch.len_utf8();
if ch == '\n' {
pending_space = None;
out.push('\n');
map.push(index);
paragraph_has_content = false;
index += len;
continue;
}
if ch.is_whitespace() {
if paragraph_has_content && pending_space.is_none() {
pending_space = Some(index);
}
index += len;
continue;
}
if let Some(space_at) = pending_space.take() {
out.push(' ');
map.push(space_at);
}
out.push(ch);
for _ in 0..len {
map.push(index);
}
paragraph_has_content = true;
index += len;
}
(out, map)
}
fn offset_in_text(text: &str, local_x: i32, font: &Font) -> Option<usize> {
if local_x < 0 {
return None;
}
let mut x = 0i32;
for (index, ch) in text.char_indices() {
let mut buffer = [0u8; 4];
let advance = estimate_text_width(ch.encode_utf8(&mut buffer), font, 1.0) as i32;
if local_x < x + advance {
return Some(index);
}
x += advance;
}
None
}
fn map_line_offset(line: &LabelLine, within: usize, source: &str) -> usize {
if within == 0 || line.start >= line.end || line.end > source.len() {
return line.start;
}
let body = &source[line.start..line.end];
let mut line_at = 0usize;
let mut body_at = 0usize;
while line_at < within {
let Some(src_ch) = body[body_at..].chars().next() else {
break;
};
let src_len = src_ch.len_utf8();
if src_ch.is_whitespace() {
body_at += src_len;
while body_at < body.len() {
let Some(next) = body[body_at..].chars().next() else {
break;
};
if !next.is_whitespace() {
break;
}
body_at += next.len_utf8();
}
if line.text.as_bytes().get(line_at) == Some(&b' ') {
line_at += 1;
}
continue;
}
if line.text[line_at..].starts_with(src_ch) {
line_at += src_len;
} else {
break;
}
body_at += src_len;
}
line.start + body_at
}
fn parse_rich_runs(text: &str) -> Vec<RichTextRun> {
let mut runs: Vec<RichTextRun> = Vec::new();
let mut style = RichTextStyle::default();
let mut stack: Vec<(char, RichTextStyle)> = Vec::new();
let mut literal = String::new();
let mut index = 0usize;
while index < text.len() {
let rest = &text[index..];
let Some(ch) = rest.chars().next() else {
break;
};
if ch == '<' {
let begins_tag = matches!(rest[1..].chars().next(), Some(next) if next.is_ascii_alphabetic() || next == '/');
if begins_tag {
if let Some(close) = rest.find('>') {
push_rich_text(&mut runs, &literal, &style);
literal.clear();
apply_rich_tag(&rest[1..close], &mut style, &mut stack);
index += close + 1;
continue;
}
}
literal.push('<');
index += 1;
continue;
}
literal.push(ch);
index += ch.len_utf8();
}
push_rich_text(&mut runs, &literal, &style);
runs
}
fn push_rich_text(runs: &mut Vec<RichTextRun>, text: &str, style: &RichTextStyle) {
if text.is_empty() {
return;
}
let decoded = decode_entities(text);
if decoded.is_empty() {
return;
}
if let Some(last) = runs.last_mut() {
if last.bold == style.bold && last.italic == style.italic && last.link == style.link {
last.text.push_str(&decoded);
return;
}
}
runs.push(RichTextRun {
text: decoded,
bold: style.bold,
italic: style.italic,
link: style.link.clone(),
});
}
fn apply_rich_tag(tag: &str, style: &mut RichTextStyle, stack: &mut Vec<(char, RichTextStyle)>) {
let tag = tag.trim();
if let Some(name) = tag.strip_prefix('/') {
let key = match name.trim() {
"b" => 'b',
"i" => 'i',
"a" => 'a',
_ => return,
};
if let Some(position) = stack.iter().rposition(|(open, _)| *open == key) {
let (_, saved) = stack.remove(position);
*style = saved;
stack.truncate(position);
} else {
match key {
'b' => style.bold = false,
'i' => style.italic = false,
'a' => style.link = None,
_ => {}
}
}
return;
}
let key = if tag == "b" || tag.starts_with("b ") {
'b'
} else if tag == "i" || tag.starts_with("i ") {
'i'
} else if tag == "a" || tag.starts_with("a ") {
'a'
} else {
return;
};
stack.push((key, style.clone()));
match key {
'b' => style.bold = true,
'i' => style.italic = true,
'a' => style.link = link_target(tag),
_ => {}
}
}
fn link_target(tag: &str) -> Option<String> {
let start = tag.find("href")? + "href".len();
let rest = tag[start..].trim_start();
let rest = rest.strip_prefix('=')?.trim_start();
let quote = rest.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let body = &rest[1..];
let end = body.find(quote)?;
Some(body[..end].to_string())
}
fn decode_entities(text: &str) -> String {
const NAMED: [(&str, &str); 5] =
[("&", "&"), ("<", "<"), (">", ">"), (""", "\""), ("'", "'")];
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(position) = rest.find('&') {
out.push_str(&rest[..position]);
rest = &rest[position..];
let mut matched = false;
for &(encoded, decoded) in NAMED.iter() {
if let Some(after) = rest.strip_prefix(encoded) {
out.push_str(decoded);
rest = after;
matched = true;
break;
}
}
if !matched {
out.push('&');
rest = &rest[1..];
}
}
out.push_str(rest);
out
}
pub(crate) fn draw_elided_line(
context: &mut RenderContext,
bounds: Rect,
text: &str,
font: &Font,
color: Color,
alignment: HorizontalAlignment,
mode: TextElideMode,
) {
let available = bounds.width.saturating_sub(2 * TEXT_FIT_MARGIN);
let fitted = {
let mut measure = |candidate: &str| context.measure_text(candidate, font).width;
elide_in_mode(text, available, mode, &mut measure)
};
context.draw_text_fitted_cow(bounds, &fitted, font, color, alignment);
}
pub struct Label {
base: BaseWidget,
text: String,
alignment: crate::core::Alignment,
word_wrap: bool,
elide_mode: TextElideMode,
margin: u32,
#[cfg(feature = "image")]
pixmap: Option<crate::widget::Image>,
scaled_contents: bool,
fade: InteractionFade,
indent: u32,
text_format: TextFormat,
explicit_links: Vec<InlineLink>,
links: Vec<InlineLink>,
rich_runs: Vec<RichTextRun>,
open_external_links: bool,
hovered_link: Option<usize>,
text_revision: u32,
wrap_cache: Option<LineLayout>,
rich_displayed: String,
rich_width_cache: RefCell<Option<(u32, Font, u32)>>,
pub link_activated: Signal1<String>,
pub link_hovered: Signal1<String>,
}
impl Label {
pub fn new(text: String, geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Label, geometry, "Label"),
text,
alignment: crate::core::Alignment::Left,
word_wrap: false,
elide_mode: TextElideMode::None,
margin: 0,
#[cfg(feature = "image")]
pixmap: None,
scaled_contents: false,
fade: InteractionFade::new(),
indent: 0,
text_format: TextFormat::PlainText,
explicit_links: Vec::new(),
links: Vec::new(),
rich_runs: Vec::new(),
open_external_links: false,
hovered_link: None,
text_revision: 0,
wrap_cache: None,
rich_displayed: String::new(),
rich_width_cache: RefCell::new(None),
link_activated: Signal1::new(),
link_hovered: Signal1::new(),
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: impl Into<String>) {
let text = text.into();
if text == self.text {
return;
}
self.text = text;
self.rebuild_text_state();
self.base.request_redraw();
}
pub fn clear(&mut self) {
self.explicit_links.clear();
self.text.clear();
#[cfg(feature = "image")]
{
self.pixmap = None;
}
self.rebuild_text_state();
self.base.request_redraw();
}
pub fn set_num<T: crate::compat::fmt::Display>(&mut self, num: T) {
self.set_text(format!("{num}"));
}
pub fn links(&self) -> &[InlineLink] {
&self.links
}
pub fn add_link(&mut self, start: usize, end: usize, href: impl Into<String>) -> bool {
let valid = {
let displayed = self.displayed_text();
start < end
&& end <= displayed.len()
&& displayed.is_char_boundary(start)
&& displayed.is_char_boundary(end)
};
if !valid {
return false;
}
self.explicit_links.push(InlineLink { start, end, href: href.into() });
self.rebuild_links();
self.base.request_redraw();
true
}
pub fn clear_links(&mut self) {
if self.explicit_links.is_empty() {
return;
}
self.explicit_links.clear();
self.rebuild_links();
self.base.request_redraw();
}
pub fn is_open_external_links(&self) -> bool {
self.open_external_links
}
pub fn set_open_external_links(&mut self, open: bool) {
if self.open_external_links == open {
return;
}
self.open_external_links = open;
self.base.request_redraw();
}
pub fn indent(&self) -> u32 {
self.indent
}
pub fn set_indent(&mut self, indent: u32) {
if self.indent == indent {
return;
}
self.indent = indent;
self.base.request_redraw();
}
pub fn text_format(&self) -> TextFormat {
self.text_format
}
pub fn set_text_format(&mut self, format: TextFormat) {
if self.text_format == format {
return;
}
self.text_format = format;
self.rebuild_text_state();
self.base.request_redraw();
}
fn displayed_text(&self) -> &str {
if self.text_format == TextFormat::RichText {
&self.rich_displayed
} else {
self.text.as_str()
}
}
fn rebuild_text_state(&mut self) {
self.rich_runs = if self.text_format == TextFormat::RichText {
parse_rich_runs(&self.text)
} else {
Vec::new()
};
self.text_revision = self.text_revision.wrapping_add(1);
self.wrap_cache = None;
self.rich_width_cache = RefCell::new(None);
self.rich_displayed.clear();
if self.text_format == TextFormat::RichText {
for run in &self.rich_runs {
self.rich_displayed.push_str(&run.text);
}
}
let displayed_len = self.displayed_text().len();
self.explicit_links.retain(|link| link.end <= displayed_len);
self.rebuild_links();
}
fn rebuild_links(&mut self) {
self.links.clear();
for link in &self.explicit_links {
self.links.push(link.clone());
}
if self.text_format == TextFormat::RichText {
let mut offset = 0usize;
for run in &self.rich_runs {
if let Some(href) = run.link.as_ref() {
if !run.text.is_empty() {
self.links.push(InlineLink {
start: offset,
end: offset + run.text.len(),
href: href.clone(),
});
}
}
offset += run.text.len();
}
}
self.links.sort_by_key(|link| link.start);
self.hovered_link = None;
}
pub fn alignment(&self) -> crate::core::Alignment {
self.alignment
}
pub fn set_alignment(&mut self, alignment: crate::core::Alignment) {
if self.alignment == alignment {
return;
}
self.alignment = alignment;
self.base.request_redraw();
}
pub fn word_wrap(&self) -> bool {
self.word_wrap
}
pub fn set_word_wrap(&mut self, wrap: bool) {
if self.word_wrap == wrap {
return;
}
self.word_wrap = wrap;
self.base.request_redraw();
}
pub fn elide_mode(&self) -> TextElideMode {
self.elide_mode
}
pub fn set_elide_mode(&mut self, mode: TextElideMode) {
if self.elide_mode == mode {
return;
}
self.elide_mode = mode;
self.base.request_redraw();
}
pub fn margin(&self) -> u32 {
self.margin
}
pub fn set_margin(&mut self, margin: u32) {
if self.margin == margin {
return;
}
self.margin = margin;
self.base.request_redraw();
}
#[cfg(feature = "image")]
pub fn pixmap(&self) -> Option<&crate::widget::Image> {
self.pixmap.as_ref()
}
#[cfg(feature = "image")]
pub fn set_pixmap(&mut self, pixmap: Option<crate::widget::Image>) {
if self.pixmap.is_none() && pixmap.is_none() {
return;
}
self.pixmap = pixmap;
self.base.request_redraw();
}
pub fn scaled_contents(&self) -> bool {
self.scaled_contents
}
pub fn set_scaled_contents(&mut self, scaled: bool) {
if self.scaled_contents == scaled {
return;
}
self.scaled_contents = scaled;
self.base.request_redraw();
}
pub fn content_rect(&self) -> Rect {
let content = ControlMetrics::content_box(self.geometry(), EdgeOffsets::all(self.margin));
let shift = self.indent.min(content.width);
Rect::new(content.x + shift as i32, content.y, content.width - shift, content.height)
}
pub fn implicit_size(&self) -> Size {
let font = effective_font(self.style());
#[cfg(feature = "image")]
if let Some(pixmap) = self.pixmap.as_ref() {
let floor = Size::new(LABEL_MIN_WIDTH, pixmap.height());
return ControlMetrics::implicit_size(
Size::new(pixmap.width().saturating_add(self.indent), pixmap.height()),
EdgeOffsets::all(self.margin).grow(LABEL_PADDING),
floor,
);
}
let text_width = if self.text_format == TextFormat::RichText {
self.rich_width(font)
} else {
crate::widget::base_widgets::measure_cache::estimate_text_width(&self.text, font)
};
let floor = Size::new(LABEL_MIN_WIDTH, estimate_line_height(font, 1.0));
ControlMetrics::implicit_size(
Size::new(text_width.saturating_add(self.indent), 0),
EdgeOffsets::all(self.margin).grow(LABEL_PADDING),
floor,
)
}
fn rich_width(&self, font: &Font) -> u32 {
if let Some((revision, cached_font, width)) = self.rich_width_cache.borrow().as_ref() {
if *revision == self.text_revision && *cached_font == *font {
return *width;
}
}
let mut width = 0u32;
for run in &self.rich_runs {
let run_font = font.with_bold(run.bold).with_italic(run.italic);
width = width.saturating_add(
crate::widget::base_widgets::measure_cache::estimate_text_width(
&run.text, &run_font,
),
);
}
*self.rich_width_cache.borrow_mut() = Some((self.text_revision, font.clone(), width));
width
}
fn has_pixmap(&self) -> bool {
#[cfg(feature = "image")]
{
self.pixmap.is_some()
}
#[cfg(not(feature = "image"))]
{
false
}
}
fn sync_fade(&mut self) {
let enabled = self.base.is_enabled();
self.fade.sync(enabled && self.base.is_hovered(), enabled && self.base.is_pressed());
}
fn interactive_ink(&self, color: Color) -> Color {
let intensity = self.fade.intensity();
if intensity <= 0.0 {
return color;
}
color.blend(&color.contrast_color(), LABEL_INTERACTION_TINT * intensity)
}
fn draw_wrapped(
&mut self,
context: &mut RenderContext,
content: Rect,
font: &Font,
text_color: Color,
) {
let matches = self.wrap_cache.as_ref().is_some_and(|cache| {
cache.text_revision == self.text_revision
&& cache.width == content.width
&& cache.font == *font
});
if !matches {
let line_height = context.measure_text("M", font).height.max(1);
let lines = {
let mut measure = |candidate: &str| context.measure_text(candidate, font).width;
wrap_text_ranges(&self.text, content.width, &mut measure)
};
self.wrap_cache = Some(LineLayout {
text_revision: self.text_revision,
width: content.width,
font: font.clone(),
line_height,
lines,
});
}
let Some(cache) = self.wrap_cache.as_ref() else {
return;
};
let line_height = cache.line_height.max(1);
let mut y = content.y;
let bottom = content.y + content.height as i32;
for line in &cache.lines {
if y + line_height as i32 > bottom {
break;
}
let band = Rect::new(content.x, y, content.width, line_height);
draw_elided_line(
context,
band,
&line.text,
font,
text_color,
HorizontalAlignment::Left,
self.elide_mode,
);
y += line_height as i32;
}
}
fn draw_rich(
&self,
context: &mut RenderContext,
content: Rect,
font: &Font,
text_color: Color,
link_ink: Color,
) {
let total = self.rich_width(font);
let mut x = match self.alignment {
crate::core::Alignment::Center => {
content.x + (content.width.saturating_sub(total) / 2) as i32
}
crate::core::Alignment::Right => content.x + content.width as i32 - total as i32,
_ => content.x,
};
let line = context.text_line(content, font);
let right = content.x + content.width as i32;
let hovered = self.hovered_link.and_then(|index| self.links.get(index));
for run in &self.rich_runs {
if x >= right {
break;
}
let run_font = font.with_bold(run.bold).with_italic(run.italic);
let run_color = if run.link.is_some() {
let hovered_here = match (run.link.as_ref(), hovered) {
(Some(href), Some(link)) => link.href == *href,
_ => false,
};
if hovered_here {
link_ink.blend(&link_ink.contrast_color(), LABEL_LINK_TINT)
} else {
link_ink
}
} else {
text_color
};
let available = (right - x) as u32;
let advance = crate::widget::base_widgets::measure_cache::measure_text(
context, &run.text, &run_font,
)
.width;
if advance <= available {
context.draw_text(
Point::new(x, line.y),
&run.text,
&run_font,
run_color,
HorizontalAlignment::Left,
);
x += advance as i32;
} else {
draw_elided_line(
context,
Rect::new(x, line.y, available, line.height),
&run.text,
&run_font,
run_color,
HorizontalAlignment::Left,
self.elide_mode,
);
break;
}
}
}
fn link_at(&self, pos: Point) -> Option<usize> {
if self.links.is_empty() {
return None;
}
let content = self.content_rect();
if !content.contains_point(pos) {
return None;
}
let font = effective_font(self.style());
let offset = if self.text_format == TextFormat::RichText {
self.rich_offset(pos, content, font)?
} else if self.word_wrap {
self.wrapped_offset(pos, content, font)?
} else {
self.single_line_offset(pos, content, font)?
};
self.links.iter().position(|link| offset >= link.start && offset < link.end)
}
fn single_line_offset(&self, pos: Point, content: Rect, font: &Font) -> Option<usize> {
let text_width =
crate::widget::base_widgets::measure_cache::estimate_text_width(&self.text, font);
let origin = match self.alignment {
crate::core::Alignment::Center => {
content.x + (content.width.saturating_sub(text_width) / 2) as i32
}
crate::core::Alignment::Right => content.x + content.width as i32 - text_width as i32,
_ => content.x,
};
offset_in_text(&self.text, pos.x - origin, font)
}
fn wrapped_offset(&self, pos: Point, content: Rect, font: &Font) -> Option<usize> {
let cache = self.wrap_cache.as_ref()?;
if cache.text_revision != self.text_revision
|| cache.width != content.width
|| cache.font != *font
{
return None;
}
let line_height = cache.line_height.max(1);
if pos.y < content.y {
return None;
}
let index = ((pos.y - content.y) as u32) / line_height;
let line = cache.lines.get(index as usize)?;
let within = offset_in_text(&line.text, pos.x - content.x, font)?;
Some(map_line_offset(line, within, &self.text))
}
fn rich_offset(&self, pos: Point, content: Rect, font: &Font) -> Option<usize> {
let start_x = {
let total = self.rich_width(font);
match self.alignment {
crate::core::Alignment::Center => {
content.x + (content.width.saturating_sub(total) / 2) as i32
}
crate::core::Alignment::Right => content.x + content.width as i32 - total as i32,
_ => content.x,
}
};
let mut x = start_x;
let mut offset = 0usize;
for run in &self.rich_runs {
let run_font = font.with_bold(run.bold).with_italic(run.italic);
let width = estimate_text_width(&run.text, &run_font, 1.0) as i32;
if pos.x < x + width {
return offset_in_text(&run.text, pos.x - x, &run_font).map(|at| offset + at);
}
x += width;
offset += run.text.len();
}
None
}
fn update_hovered_link(&mut self, mut pos: Option<Point>) {
if !self.base.is_enabled() {
pos = None;
}
let next = pos.and_then(|pos| self.link_at(pos));
if next == self.hovered_link {
return;
}
self.hovered_link = next;
let href = next
.and_then(|index| self.links.get(index))
.map(|link| link.href.clone())
.unwrap_or_default();
self.link_hovered.emit(href);
self.base.request_redraw();
}
fn activate_link(&mut self, index: usize) {
let Some(link) = self.links.get(index) else {
return;
};
let href = link.href.clone();
if self.open_external_links && !href.is_empty() {
let platform = crate::platform::platform_facts();
if let Err(error) = crate::platform::Platform::open_external(platform, &href) {
log::warn!("label link could not be opened externally: {error:?}");
}
}
self.link_activated.emit(href);
}
}
impl Widget for Label {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
debug_assert!(
estimate_line_height(&Font::default(), 1.0) > 0,
"a size hint must be measured through ControlMetrics"
);
self.implicit_size()
}
fn tick(&mut self, delta_ms: u32) -> bool {
self.fade.tick(delta_ms)
}
fn is_animating(&self) -> bool {
self.fade.is_animating()
}
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"link_activated" => Some(EventSignalRef::mapped(
"link_activated",
&self.link_activated,
|href: &String| CapabilityValue::String(href.clone()),
)),
"link_hovered" => {
Some(EventSignalRef::mapped("link_hovered", &self.link_hovered, |href: &String| {
CapabilityValue::String(href.clone())
}))
}
_ => None,
}
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for Label {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"text" => Ok(CapabilityValue::String(self.text().to_string())),
"alignment" => {
Ok(CapabilityValue::String(alignment_to_str(self.alignment()).to_string()))
}
"word_wrap" => Ok(CapabilityValue::Bool(self.word_wrap())),
"elide" => Ok(CapabilityValue::String(self.elide_mode().as_str().to_string())),
"margin" => Ok(CapabilityValue::UInt(self.margin() as u64)),
"scaled_contents" => Ok(CapabilityValue::Bool(self.scaled_contents())),
"indent" => Ok(CapabilityValue::UInt(self.indent() as u64)),
"text_format" => Ok(CapabilityValue::String(self.text_format().as_str().to_string())),
"open_external_links" => Ok(CapabilityValue::Bool(self.is_open_external_links())),
"link_count" => Ok(CapabilityValue::UInt(self.links().len() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"text" => {
self.set_text(expect_string(value)?);
Ok(())
}
"alignment" => {
self.set_alignment(expect_alignment(value)?);
Ok(())
}
"word_wrap" => {
self.set_word_wrap(expect_bool(value)?);
Ok(())
}
"elide" => {
let token = expect_string(value)?;
let Some(mode) = TextElideMode::from_name(&token) else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.set_elide_mode(mode);
Ok(())
}
"margin" => {
self.set_margin(expect_u32(value)?);
Ok(())
}
"scaled_contents" => {
self.set_scaled_contents(expect_bool(value)?);
Ok(())
}
"indent" => {
self.set_indent(expect_u32(value)?);
Ok(())
}
"text_format" => {
let token = expect_string(value)?;
let Some(format) = TextFormat::from_name(&token) else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.set_text_format(format);
Ok(())
}
"open_external_links" => {
self.set_open_external_links(expect_bool(value)?);
Ok(())
}
"link_count" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"text",
"alignment",
"word_wrap",
"elide",
"margin",
"scaled_contents",
"indent",
"text_format",
"open_external_links",
"link_count",
BASE_PROPERTY_NAMES
]
}
}
impl EventHandler for Label {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
match event {
Event::MouseEnter { .. }
| Event::MouseLeave { .. }
| Event::MousePress { .. }
| Event::MouseRelease { .. } => self.sync_fade(),
_ => { }
}
match event {
Event::MouseMove { pos } => self.update_hovered_link(Some(*pos)),
Event::MouseLeave { .. } => self.update_hovered_link(None),
Event::MouseRelease { pos, button }
if *button == crate::event::mouse_button::PRIMARY =>
{
if let Some(index) = self.link_at(*pos) {
self.activate_link(index);
}
}
_ => { }
}
}
}
impl Draw for Label {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let disabled = !self.base.is_enabled();
if let Some(bg_color) = self.style().background_color {
context.face_with_gradient(
rect,
bg_color,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
}
let (theme_ink, theme_disabled, theme_surface, theme_accent) = {
let manager = crate::style::theme_manager();
match manager.current_theme() {
Some(active) => (
Some(active.colors.foreground),
Some(active.colors.disabled),
Some(active.colors.background),
Some(active.colors.accent),
),
None => (None, None, None, None),
}
};
#[cfg(feature = "image")]
if let Some(pixmap) = self.pixmap.as_ref() {
let content = self.content_rect();
let (width, height) = if self.scaled_contents {
(content.width, content.height)
} else {
(pixmap.width().min(content.width), pixmap.height().min(content.height))
};
if width > 0 && height > 0 {
let x = match self.alignment {
crate::core::Alignment::Center => {
content.x + ((content.width.saturating_sub(width)) / 2) as i32
}
crate::core::Alignment::Right => {
content.x + content.width as i32 - width as i32
}
_ => content.x,
};
let y = match self.alignment {
crate::core::Alignment::Top => content.y,
crate::core::Alignment::Bottom => {
content.y + content.height as i32 - height as i32
}
_ => content.y + ((content.height.saturating_sub(height)) / 2) as i32,
};
if let Some(pixels) = pixmap.rgba8_data() {
context.draw_image(x, y, width, height, pixels);
}
}
}
if !self.text.is_empty() && !self.has_pixmap() {
let fallback = if disabled { theme_disabled.or(theme_ink) } else { theme_ink };
let text_color = self.style().text_color.or(fallback).unwrap_or(if disabled {
Color::rgb(150, 150, 150)
} else {
Color::rgb(0, 0, 0)
});
let text_color = self.interactive_ink(text_color);
let font = effective_font(self.style()).clone();
let content = self.content_rect();
if self.text_format == TextFormat::RichText {
let link_ink = theme_accent.unwrap_or(text_color);
self.draw_rich(context, content, &font, text_color, link_ink);
} else if self.word_wrap {
self.draw_wrapped(context, content, &font, text_color);
} else {
let text_width = crate::widget::base_widgets::measure_cache::estimate_text_width(
&self.text, &font,
);
let text_x = match self.alignment {
crate::core::Alignment::Center => {
content.x + (content.width.saturating_sub(text_width) / 2) as i32
}
crate::core::Alignment::Right => {
content.x + content.width as i32 - text_width as i32
}
_ => content.x,
};
let line = context.text_line(content, &font);
let horizontal = match self.alignment {
crate::core::Alignment::Center => HorizontalAlignment::Center,
crate::core::Alignment::Right => HorizontalAlignment::Right,
_ => HorizontalAlignment::Left,
};
if self.elide_mode == TextElideMode::None {
context.draw_text(
Point::new(text_x, line.y),
&self.text,
&font,
text_color,
HorizontalAlignment::Left,
);
} else {
draw_elided_line(
context,
Rect::new(content.x, line.y, content.width, line.height),
&self.text,
&font,
text_color,
horizontal,
self.elide_mode,
);
}
}
}
if let Some(border_color) = self.style().border_color {
context.draw_rect(rect, border_color);
}
if disabled {
let surface = self.style().background_color.or(theme_surface);
match surface {
Some(surface) => {
context.fill_rect(rect, surface.with_alpha(dimensions::DISABLED_VEIL_ALPHA))
}
None => context.fill_rect(rect, Color::rgba(128, 128, 128, 60)),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compat::{Box, Vec};
use crate::core::{Alignment, Color, Font, ObjectId, Point, Rect, Size};
use crate::style::WidgetStyle;
#[cfg(device_profile)]
use crate::theme::AppearanceMode;
fn wrap_text(text: &str, width: u32, measure: &mut dyn FnMut(&str) -> u32) -> Vec<String> {
wrap_text_ranges(text, width, measure).into_iter().map(|line| line.text).collect()
}
#[test]
fn label_creation_sets_text_and_geometry() {
let rect = Rect::new(10, 20, 200, 30);
let label = Label::new("Hello, World!".to_string(), rect);
assert_eq!(label.text(), "Hello, World!");
assert_eq!(label.geometry(), rect);
}
#[test]
fn label_creation_default_alignment_is_left() {
let label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
assert_eq!(label.alignment(), Alignment::Left);
}
#[test]
fn label_creation_with_empty_text() {
let label = Label::new(String::new(), Rect::new(0, 0, 50, 16));
assert!(label.text().is_empty());
}
#[test]
fn label_creation_with_zero_geometry() {
let label = Label::new("Zero".to_string(), Rect::new(0, 0, 0, 0));
assert_eq!(label.geometry(), Rect::new(0, 0, 0, 0));
}
#[test]
fn label_set_text_updates_stored_text() {
let mut label = Label::new("Initial".to_string(), Rect::new(0, 0, 100, 20));
label.set_text("Updated");
assert_eq!(label.text(), "Updated");
}
#[test]
fn label_set_text_overwrites_previous() {
let mut label = Label::new("First".to_string(), Rect::new(0, 0, 100, 20));
label.set_text("Second");
label.set_text("Third");
assert_eq!(label.text(), "Third");
}
#[test]
fn label_set_text_empty() {
let mut label = Label::new("Something".to_string(), Rect::new(0, 0, 100, 20));
label.set_text(String::new());
assert!(label.text().is_empty());
}
#[test]
fn label_set_text_long_string() {
let long = "a".repeat(10_000);
let mut label = Label::new(String::new(), Rect::new(0, 0, 100, 20));
label.set_text(long.clone());
assert_eq!(label.text(), long);
}
#[test]
fn label_alignment_default_is_left() {
let label = Label::new("Hello".to_string(), Rect::new(0, 0, 100, 20));
assert_eq!(label.alignment(), Alignment::Left);
}
#[test]
fn label_set_alignment_center() {
let mut label = Label::new("Hello".to_string(), Rect::new(0, 0, 100, 20));
label.set_alignment(Alignment::Center);
assert_eq!(label.alignment(), Alignment::Center);
}
#[test]
fn label_set_alignment_right() {
let mut label = Label::new("Hello".to_string(), Rect::new(0, 0, 100, 20));
label.set_alignment(Alignment::Right);
assert_eq!(label.alignment(), Alignment::Right);
}
#[test]
fn label_set_alignment_left_explicitly() {
let mut label = Label::new("Hello".to_string(), Rect::new(0, 0, 100, 20));
label.set_alignment(Alignment::Center);
label.set_alignment(Alignment::Left);
assert_eq!(label.alignment(), Alignment::Left);
}
#[test]
fn label_set_alignment_top_and_bottom() {
let mut label = Label::new("Hello".to_string(), Rect::new(0, 0, 100, 20));
label.set_alignment(Alignment::Top);
assert_eq!(label.alignment(), Alignment::Top);
label.set_alignment(Alignment::Bottom);
assert_eq!(label.alignment(), Alignment::Bottom);
}
#[test]
fn label_alignment_set_multiple_times_keeps_last() {
let mut label = Label::new("Hello".to_string(), Rect::new(0, 0, 100, 20));
label.set_alignment(Alignment::Left);
label.set_alignment(Alignment::Right);
label.set_alignment(Alignment::Center);
label.set_alignment(Alignment::Top);
assert_eq!(label.alignment(), Alignment::Top);
}
#[test]
fn widget_geometry_delegation() {
let mut label = Label::new("Test".to_string(), Rect::new(5, 10, 150, 25));
assert_eq!(label.geometry(), Rect::new(5, 10, 150, 25));
label.set_geometry(Rect::new(20, 30, 300, 50));
assert_eq!(label.geometry(), Rect::new(20, 30, 300, 50));
}
#[test]
fn widget_visibility_delegation() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
assert!(label.is_visible(), "Label should be visible by default");
label.hide();
assert!(!label.is_visible(), "Label should be hidden after hide()");
label.show();
assert!(label.is_visible(), "Label should be visible after show()");
}
#[test]
fn widget_enabled_delegation() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
assert!(label.is_enabled(), "Label should be enabled by default");
label.set_enabled(false);
assert!(!label.is_enabled(), "Label should be disabled");
label.set_enabled(true);
assert!(label.is_enabled(), "Label should be re-enabled");
}
#[test]
fn widget_parent_delegation() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
assert_eq!(label.parent(), None);
let parent_id: ObjectId = 42;
label.set_parent(Some(parent_id));
assert_eq!(label.parent(), Some(parent_id));
label.set_parent(None);
assert_eq!(label.parent(), None);
}
#[test]
fn widget_children_delegation() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
assert!(label.children().is_empty());
let child_a: ObjectId = 100;
let child_b: ObjectId = 200;
label.add_child(child_a);
assert_eq!(label.children().len(), 1);
assert_eq!(label.children()[0], child_a);
label.add_child(child_b);
assert_eq!(label.children().len(), 2);
label.remove_child(child_a);
assert_eq!(label.children().len(), 1);
assert_eq!(label.children()[0], child_b);
label.remove_child(child_b);
assert!(label.children().is_empty());
}
#[test]
fn widget_min_max_size_delegation() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
assert_eq!(label.min_size(), None);
assert_eq!(label.max_size(), None);
let min = Size::new(80, 16);
label.set_min_size(Some(min));
assert_eq!(label.min_size(), Some(min));
let max = Size::new(400, 100);
label.set_max_size(Some(max));
assert_eq!(label.max_size(), Some(max));
label.set_min_size(None);
assert_eq!(label.min_size(), None);
}
#[test]
fn widget_tooltip_delegation() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
assert!(label.tooltip().is_empty());
label.set_tooltip("Helpful tip".to_string());
assert_eq!(label.tooltip(), "Helpful tip");
label.set_tooltip(String::new());
assert!(label.tooltip().is_empty());
}
#[test]
fn widget_style_delegation() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
assert_eq!(*label.style(), WidgetStyle::default());
let custom_style = WidgetStyle::default().with_background(Color::rgb(240, 240, 240));
label.set_style(custom_style.clone());
assert_eq!(*label.style(), custom_style);
}
#[test]
fn widget_id_is_unique_and_kind_is_label() {
let label_a = Label::new("A".to_string(), Rect::new(0, 0, 100, 20));
let label_b = Label::new("B".to_string(), Rect::new(0, 0, 100, 20));
assert_ne!(label_a.id(), label_b.id());
assert_eq!(label_a.kind(), WidgetKind::Label);
assert_eq!(label_b.kind(), WidgetKind::Label);
}
#[test]
fn label_style_default_values() {
let label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
let style = label.style();
assert_eq!(style.background_color, None);
assert_eq!(style.text_color, None);
assert_eq!(style.font, None);
assert_eq!(style.border_color, None);
assert_eq!(style.border_width, None);
assert_eq!(style.border_radius, None);
}
#[test]
fn label_style_background_color() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
let bg = Color::rgb(200, 210, 220);
let style = WidgetStyle::default().with_background(bg);
label.set_style(style);
assert_eq!(label.style().background_color, Some(bg));
}
#[test]
fn label_style_text_color() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
let tc = Color::rgb(50, 80, 200);
let style = WidgetStyle::default().with_text_color(tc);
label.set_style(style);
assert_eq!(label.style().text_color, Some(tc));
}
#[test]
fn label_style_font() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
let font = Font::simple("Helvetica", 16.0);
let style = WidgetStyle::default().with_font(font.clone());
label.set_style(style);
assert_eq!(label.style().font, Some(font));
}
#[test]
fn label_style_border_color() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
let border = Color::rgb(255, 0, 0);
let style = WidgetStyle::default().with_border(border, 2, 4);
label.set_style(style);
assert_eq!(label.style().border_color, Some(border));
}
#[test]
fn label_style_border_width() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
let style = WidgetStyle::default().with_border(Color::rgb(0, 0, 0), 5, 0);
label.set_style(style);
assert_eq!(label.style().border_width, Some(5));
}
#[test]
fn label_style_border_radius() {
let mut label = Label::new("Test".to_string(), Rect::new(0, 0, 100, 20));
let style = WidgetStyle::default().with_border(Color::rgb(0, 0, 0), 1, 8);
label.set_style(style);
assert_eq!(label.style().border_radius, Some(8));
}
#[test]
fn label_style_combined_properties() {
let mut label = Label::new("Styled".to_string(), Rect::new(0, 0, 200, 40));
let bg = Color::rgb(240, 248, 255);
let tc = Color::rgb(0, 51, 102);
let font = Font::simple("Georgia", 18.0);
let bc = Color::rgb(0, 102, 204);
let style = WidgetStyle::default()
.with_background(bg)
.with_text_color(tc)
.with_font(font.clone())
.with_border(bc, 2, 6);
label.set_style(style);
let s = label.style();
assert_eq!(s.background_color, Some(bg));
assert_eq!(s.text_color, Some(tc));
assert_eq!(s.font, Some(font));
assert_eq!(s.border_color, Some(bc));
assert_eq!(s.border_width, Some(2));
assert_eq!(s.border_radius, Some(6));
}
#[cfg(not(alloc_frugal))]
#[test]
fn the_text_is_vertically_centred_in_the_label() {
let rect = Rect::new(0, 0, 240, 120);
let mut label = Label::new("Sample".to_string(), rect);
let svg = crate::widget::svg::render_to_svg(&mut label);
let (_, y, _, _) = crate::widget::svg::text_ink_box(&svg)
.unwrap_or_else(|| panic!("a label with text must emit a text path: {svg}"));
assert!(y > 0, "the text must not sit on the label's first row: y={y}");
assert!(
y < rect.height as i32 / 2,
"a centred line starts above the middle of the cell: y={y}"
);
assert!(
y > rect.height as i32 / 4,
"a centred 14 px line begins well below the top quarter: y={y}"
);
}
#[cfg(not(alloc_frugal))]
#[test]
fn the_vertical_fix_leaves_the_horizontal_alignment_alone() {
let _theme_guard = crate::style::theme_test_guard();
let rect = Rect::new(0, 0, 200, 40);
let ink_left_of = |alignment: Alignment| -> i32 {
let mut label = Label::new("Sample".to_string(), rect);
label.set_alignment(alignment);
let svg = crate::widget::svg::render_to_svg(&mut label);
crate::widget::svg::text_ink_box(&svg)
.unwrap_or_else(|| panic!("a text path for {alignment:?}"))
.0
};
let left = ink_left_of(Alignment::Left);
let right = ink_left_of(Alignment::Right);
let centred = ink_left_of(Alignment::Center);
assert_eq!(left, rect.x, "left-aligned ink starts at the edge");
assert!(
right > left + rect.width as i32 / 4,
"right-aligned ink is pushed most of the way across: {right} vs {left}"
);
assert!(
centred > left && centred < right,
"centred sits strictly between the two edges: {centred} in ({left}, {right})"
);
}
#[test]
#[cfg(device_profile)]
fn the_ink_is_legible_on_each_appearance() {
let _guard = crate::style::theme_test_guard();
crate::widget::census::install_preset_appearances();
for appearance in [AppearanceMode::Dark, AppearanceMode::Light] {
crate::theme::global_theme_manager().set_appearance(appearance);
let mut label = Label::new("Sample".to_string(), Rect::new(0, 0, 200, 30));
crate::theme::apply_theme_to_widget(&mut label);
let surface = crate::style::theme_manager()
.current_theme()
.map(|active| active.colors.background)
.expect("a preset is active");
let svg = crate::widget::svg::render_widget_to_svg_on(
&mut label,
Rect::new(0, 0, 200, 30),
surface,
);
let ink = ink_path_fill(&svg);
let ratio = surface.contrast_ratio(ink);
assert!(
ratio >= 4.5,
"the {appearance:?} label's ink {ink:?} is only {ratio:.2}:1 on {surface:?}; a \
literal black is the defect this pins"
);
}
}
#[test]
#[cfg(device_profile)]
fn the_disabled_veil_recedes_toward_the_surface_on_either_appearance() {
let _guard = crate::style::theme_test_guard();
crate::widget::census::install_preset_appearances();
for appearance in [AppearanceMode::Dark, AppearanceMode::Light] {
crate::theme::global_theme_manager().set_appearance(appearance);
let mut enabled = Label::new("Sample".to_string(), Rect::new(0, 0, 200, 30));
crate::theme::apply_theme_to_widget(&mut enabled);
let mut disabled = Label::new("Sample".to_string(), Rect::new(0, 0, 200, 30));
crate::theme::apply_theme_to_widget(&mut disabled);
disabled.set_enabled(false);
let surface = crate::style::theme_manager()
.current_theme()
.map(|active| active.colors.background)
.expect("a preset is active");
let rect = Rect::new(0, 0, 200, 30);
let live_svg = crate::widget::svg::render_widget_to_svg_on(&mut enabled, rect, surface);
let off_svg = crate::widget::svg::render_widget_to_svg_on(&mut disabled, rect, surface);
let veils = |svg: &str| svg.matches("fill-opacity=").count();
let _ = (veils(&live_svg), veils(&off_svg));
let veil = last_fill(&off_svg);
let channel_spread = |c: Color| c.r.abs_diff(c.g).max(c.g.abs_diff(c.b)) as u32;
assert_eq!(
(veil.r, veil.g, veil.b),
(surface.r, surface.g, surface.b),
"the {appearance:?} disabled veil {veil:?} must be the surface {surface:?}, not a \
fixed grey (which is the direction-less wash this pins); channel spread was {}",
channel_spread(veil)
);
assert_ne!(veil.a, 255, "the veil must be translucent, or it hides the label entirely");
}
}
#[cfg(device_profile)]
fn last_fill(svg: &str) -> Color {
let key = "fill=\"rgba(";
let at = svg.rfind(key).expect("a fill") + key.len();
let end = svg[at..].find(')').expect("the fill's close") + at;
let mut parts = svg[at..end].split(',');
let r = parts.next().and_then(|v| v.trim().parse().ok()).expect("r");
let g = parts.next().and_then(|v| v.trim().parse().ok()).expect("g");
let b = parts.next().and_then(|v| v.trim().parse().ok()).expect("b");
let a: f32 = parts.next().and_then(|v| v.trim().parse().ok()).expect("a");
Color::rgba(r, g, b, (a * 255.0).round() as u8)
}
#[cfg(device_profile)]
fn ink_path_fill(svg: &str) -> Color {
let at = svg.find("<path").expect("the label draws an ink path");
let path = &svg[at..];
let key = "fill=\"rgba(";
let start = path.find(key).expect("the path carries a fill") + key.len();
let end = path[start..].find(')').expect("the fill's close") + start;
let mut parts = path[start..end].split(',');
let r = parts.next().and_then(|v| v.trim().parse().ok()).expect("r");
let g = parts.next().and_then(|v| v.trim().parse().ok()).expect("g");
let b = parts.next().and_then(|v| v.trim().parse().ok()).expect("b");
Color::rgb(r, g, b)
}
#[test]
fn a_label_paints_a_declared_background_gradient() {
use crate::core::Point;
use crate::style::{Gradient, WidgetStyle};
let rect = Rect::new(0, 0, 120, 30);
let backdrop = Color::WHITE;
let render = |gradient: Option<Gradient>| {
let mut label = Label::new("Sample".to_string(), rect);
let style = WidgetStyle {
background_color: Some(Color::rgb(240, 240, 240)),
background_gradient: gradient,
border_radius: Some(6),
..WidgetStyle::default()
};
label.set_style(style);
crate::widget::svg::render_widget_to_svg_on(&mut label, rect, backdrop)
};
let solid = render(None);
let ramp = render(Some(
Gradient::linear(Point::new(0, 0), Point::new(120, 0))
.add_stop(0.0, Color::BLACK)
.add_stop(1.0, Color::WHITE),
));
assert!(!solid.contains("linearGradient"), "a solid label needs no paint server");
assert!(
ramp.contains("<linearGradient") && ramp.contains("url(#"),
"a declared gradient must emit a paint server in the label's document"
);
assert_ne!(solid, ramp, "the gradient label must differ from the solid one");
}
#[test]
fn a_custom_font_grows_the_label_hint_and_the_drawn_text() {
use crate::style::WidgetStyle;
let mut baseline = Label::new("Sample text".to_string(), Rect::new(0, 0, 200, 30));
let base_hint = baseline.implicit_size();
let base_ink = ink_width(&mut baseline);
let mut big = Label::new("Sample text".to_string(), Rect::new(0, 0, 200, 30));
big.set_style(WidgetStyle::default().with_font(Font::simple("Test", 30.0)));
let big_hint = big.implicit_size();
let big_ink = ink_width(&mut big);
assert!(
big_hint.width > base_hint.width,
"a larger font must widen the label hint: {} vs {}",
big_hint.width,
base_hint.width
);
assert!(
big_ink > base_ink,
"a larger font must widen the painted text: {big_ink} vs {base_ink}"
);
}
#[test]
#[cfg(device_profile)]
fn a_theme_text_scale_above_one_grows_the_label_hint() {
use crate::style::environment::{
install_environment, uninstall_environment, EnvironmentProvider,
};
use crate::style::MotionPreference;
struct Scaled(f32);
impl EnvironmentProvider for Scaled {
fn text_scale(&self) -> f32 {
self.0
}
fn motion_preference(&self) -> MotionPreference {
MotionPreference::NoPreference
}
}
let _guard = crate::style::theme_test_guard();
let previous = install_environment(Box::new(Scaled(1.0)));
{
let mut manager = crate::theme::global_theme_manager();
manager.register_theme(crate::theme::Theme::default());
manager.set_appearance(crate::theme::AppearanceMode::Light);
}
let mut at_one = Label::new("Scaled label".to_string(), Rect::new(0, 0, 200, 30));
crate::theme::apply_active_theme(&mut at_one);
let hint_one = at_one.implicit_size();
install_environment(Box::new(Scaled(1.5)));
let mut scaled = Label::new("Scaled label".to_string(), Rect::new(0, 0, 200, 30));
crate::theme::apply_active_theme(&mut scaled);
let hint_large = scaled.implicit_size();
let _ = uninstall_environment();
if let Some(previous) = previous {
install_environment(previous);
}
assert!(
hint_large.width > hint_one.width,
"a text scale above 1.0 must widen the label hint: {} vs {}",
hint_large.width,
hint_one.width
);
}
fn ink_width(label: &mut Label) -> i32 {
let svg = crate::widget::svg::render_to_svg(label);
crate::widget::svg::text_ink_box(&svg).map(|(x, _, right, _)| right - x).unwrap_or(0)
}
fn render_rgba(label: &mut Label, size: Size) -> Vec<u8> {
use crate::render::{PaintBackend, RenderContext, SoftwarePaintBackend};
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::rgba(0, 0, 0, 0));
{
let mut context = RenderContext::new(&mut backend);
label.draw(&mut context);
}
backend.end_frame();
backend.frame_rgba().to_vec()
}
#[test]
fn mouse_enter_makes_the_label_animating() {
let mut label = Label::new("Hover".to_string(), Rect::new(0, 0, 80, 24));
assert!(!Widget::is_animating(&label), "a new label rests and owes no frames");
label.handle_event(&Event::MouseEnter { pos: Point::new(1, 1) });
assert!(
Widget::is_animating(&label),
"MouseEnter must make the label's fade moving before any tick"
);
assert!(Widget::tick(&mut label, 16), "the first fade frame must report work");
}
#[cfg(device_profile)]
#[test]
fn a_resting_label_paints_the_untinted_ink() {
let _guard = crate::style::theme_test_guard();
let rect = Rect::new(0, 0, 80, 24);
let ink = Color::rgb(40, 60, 80);
let mut label = Label::new("MMMM".to_string(), rect);
label.set_style(WidgetStyle::default().with_text_color(ink));
let fresh = crate::widget::svg::render_to_svg(&mut label);
assert_eq!(
ink_path_fill(&fresh),
ink,
"at rest the label must paint the caller's ink, not a tinted one"
);
label.handle_event(&Event::MouseEnter { pos: Point::new(1, 1) });
while Widget::tick(&mut label, 16) {}
let hovered = crate::widget::svg::render_to_svg(&mut label);
assert_ne!(ink_path_fill(&hovered), ink, "a settled hover must actually tint the ink");
label.handle_event(&Event::MouseLeave { pos: Point::new(1, 1) });
while Widget::tick(&mut label, 16) {}
assert_eq!(label.fade.hover.value(), 0.0, "the fade returns to rest exactly");
assert_eq!(
crate::widget::svg::render_to_svg(&mut label),
fresh,
"a label back at rest must repaint the same bytes it did before the hover"
);
}
#[test]
fn a_settled_hover_matches_the_manually_set_terminal_state() {
let _guard = crate::style::theme_test_guard();
let rect = Rect::new(0, 0, 80, 24);
let size = Size::new(80, 24);
let ink = Color::rgb(40, 60, 80);
let make = || {
let mut label = Label::new("MMMM".to_string(), rect);
label.set_style(WidgetStyle::default().with_text_color(ink));
label
};
let mut animated = make();
animated.handle_event(&Event::MouseEnter { pos: Point::new(1, 1) });
let mut frames = 0;
while Widget::tick(&mut animated, 16) {
frames += 1;
assert!(frames < 500, "the fade must terminate");
}
assert_eq!(animated.fade.hover.value(), 1.0, "the hover settles at its target");
let mut manual = make();
manual.fade.hover.jump_to(1.0);
manual.fade.press.jump_to(0.0);
assert_eq!(
render_rgba(&mut animated, size),
render_rgba(&mut manual, size),
"the animated end state must be the manually set terminal state"
);
}
#[test]
fn reduced_motion_reaches_the_label_target_in_one_frame() {
use crate::style::environment::{
install_environment, uninstall_environment, EnvironmentProvider,
};
use crate::style::MotionPreference;
struct Reduced;
impl EnvironmentProvider for Reduced {
fn motion_preference(&self) -> MotionPreference {
MotionPreference::ReduceMotion
}
}
let _guard = crate::style::theme_test_guard();
let previous = install_environment(Box::new(Reduced));
let mut label = Label::new("Hover".to_string(), Rect::new(0, 0, 80, 24));
label.handle_event(&Event::MouseEnter { pos: Point::new(1, 1) });
let moving = Widget::tick(&mut label, 16);
assert!(!moving, "a zero-duration fade settles on the first tick");
assert_eq!(label.fade.hover.value(), 1.0, "and reaches the target, not half way");
assert!(!Widget::is_animating(&label));
let _ = uninstall_environment();
if let Some(previous) = previous {
install_environment(previous);
}
}
#[test]
fn word_wrap_is_off_by_default_and_can_be_toggled() {
let mut label = Label::new("a b c".to_string(), Rect::new(0, 0, 40, 24));
assert!(!label.word_wrap());
label.set_word_wrap(true);
assert!(label.word_wrap());
label.set_word_wrap(true);
assert!(label.word_wrap(), "the same value is a no-op, not a toggle");
label.set_word_wrap(false);
assert!(!label.word_wrap());
}
#[test]
fn margin_insets_the_content_rect_and_the_hint() {
let mut label = Label::new("Hi".to_string(), Rect::new(0, 0, 80, 24));
let plain = label.implicit_size();
assert_eq!(label.content_rect(), Rect::new(0, 0, 80, 24));
label.set_margin(8);
assert_eq!(label.content_rect(), Rect::new(8, 8, 64, 8));
let padded = label.implicit_size();
assert_eq!(padded.width, plain.width + 16, "two sides of margin are added");
assert!(padded.height >= plain.height, "the hint never shrinks with a margin");
}
#[test]
fn a_margin_large_enough_grows_the_hint_height() {
let mut label = Label::new("Hi".to_string(), Rect::new(0, 0, 80, 24));
let plain = label.implicit_size();
label.set_margin(20);
assert!(
label.implicit_size().height > plain.height,
"a vertical margin larger than the padding grows the hint"
);
}
#[test]
fn elide_mode_round_trips_through_the_property_contract() {
let mut label = Label::new("a long caption".to_string(), Rect::new(0, 0, 40, 20));
assert_eq!(label.elide_mode(), TextElideMode::None, "the historical mode is the default");
label.set("elide", CapabilityValue::String("middle".into())).expect("elide is writable");
assert_eq!(label.elide_mode(), TextElideMode::ElideMiddle);
assert_eq!(label.get("elide"), Ok(CapabilityValue::String("middle".into())));
assert_eq!(
label.set("elide", CapabilityValue::String("sideways".into())),
Err(CapabilityAccessError::TypeMismatch)
);
}
#[test]
fn elide_left_and_middle_keep_the_end_they_promise() {
let font = Font::default();
let mut measure = |text: &str| estimate_text_width(text, &font, 1.0);
let full = measure("abcdefghij");
let width = full / 2;
let right = elide_in_mode("abcdefghij", width, TextElideMode::ElideRight, &mut measure);
assert!(right.starts_with('a'), "elide-right keeps the head: {right:?}");
assert!(right.ends_with('\u{2026}'));
assert!(right.chars().count() < 10, "something must have been removed");
let left = elide_in_mode("abcdefghij", width, TextElideMode::ElideLeft, &mut measure);
assert!(left.starts_with('\u{2026}'), "elide-left keeps the tail: {left:?}");
assert!(left.ends_with('j'));
assert!(left.chars().count() < 10);
let middle = elide_in_mode("abcdefghij", width, TextElideMode::ElideMiddle, &mut measure);
assert!(middle.contains('\u{2026}'));
assert!(middle.starts_with('a'));
assert!(middle.ends_with('j'));
assert!(middle.chars().count() < 10);
}
#[test]
fn wrapping_breaks_on_words_and_hard_splits_a_long_one() {
let font = Font::default();
let mut measure = |text: &str| estimate_text_width(text, &font, 1.0);
let line_width = measure("one two");
let lines = wrap_text("one two three", line_width, &mut measure);
assert_eq!(lines, vec!["one two".to_string(), "three".to_string()]);
let hard_width = measure("abc");
let lines = wrap_text("abcdefgh", hard_width, &mut measure);
assert_eq!(lines, vec!["abc".to_string(), "def".to_string(), "gh".to_string()]);
}
#[test]
fn wrapping_honours_explicit_line_breaks_and_blank_paragraphs() {
let font = Font::default();
let mut measure = |text: &str| estimate_text_width(text, &font, 1.0);
let lines = wrap_text("a\n\nb", 40, &mut measure);
assert_eq!(lines, vec!["a".to_string(), String::new(), "b".to_string()]);
}
#[test]
fn wrapping_keeps_the_labels_whitespace_policy() {
let font = Font::default();
let mut measure = |text: &str| estimate_text_width(text, &font, 1.0);
let lines = wrap_text("one two\tthree ", 400, &mut measure);
assert_eq!(lines, vec!["one two three".to_string()]);
let lines = wrap_text("a \n\n b", 400, &mut measure);
assert_eq!(lines, vec!["a".to_string(), String::new(), "b".to_string()]);
}
#[cfg(feature = "image")]
#[test]
fn a_pixmap_becomes_the_labels_content() {
let image = crate::widget::Image::try_from_rgba(vec![0u8; 12 * 8 * 4], 12, 8)
.expect("the buffer matches its dimensions");
let mut label = Label::new("fallback text".to_string(), Rect::new(0, 0, 80, 24));
assert!(label.pixmap().is_none());
let text_hint = label.implicit_size();
label.set_pixmap(Some(image));
assert!(label.pixmap().is_some());
assert_eq!(label.text(), "fallback text", "the text stays for accessibility");
let pixmap_hint = label.implicit_size();
assert!(
pixmap_hint.width < text_hint.width,
"the hint follows the image's own extent, not the text it replaced"
);
assert!(!label.scaled_contents(), "native size is the default");
label.set_scaled_contents(true);
assert!(label.scaled_contents());
}
#[test]
fn every_published_property_is_readable() {
let label = Label::new("x".to_string(), Rect::new(0, 0, 20, 20));
for name in label.property_names() {
assert!(label.get(name).is_ok(), "{name:?} is published but not readable");
}
}
}