use std::cell::RefCell;
use std::rc::Rc;
use teksilo_canvas::text_backend::TextLayout;
use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::color_prop::{ColorProp, TextStyleProp};
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
use teksilo_core::widget_id::WidgetId;
use super::mnemonic::{ParsedMnemonic, parse_mnemonic};
pub(crate) struct MenuLabel {
source: Prop<String>,
alt_down: Signal<bool>,
color: ColorProp,
style: TextStyleProp,
last_layout: RefCell<Option<TextLayout>>,
last_cache_gen: RefCell<u64>,
last_parsed: RefCell<Option<(String, ParsedMnemonic)>>,
}
impl std::fmt::Debug for MenuLabel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MenuLabel").finish()
}
}
impl MenuLabel {
pub(crate) fn new(
source: impl Into<Prop<String>>,
alt_down: Signal<bool>,
color: impl Into<ColorProp>,
style: impl Into<TextStyleProp>,
) -> Self {
Self {
source: source.into(),
alt_down,
color: color.into(),
style: style.into(),
last_layout: RefCell::new(None),
last_cache_gen: RefCell::new(0),
last_parsed: RefCell::new(None),
}
}
fn resolve_parsed(&self) -> ParsedMnemonic {
let raw = self.source.get();
let mut cache = self.last_parsed.borrow_mut();
if let Some((cached_raw, parsed)) = cache.as_ref() {
if cached_raw == &raw {
return parsed.clone();
}
}
let parsed = parse_mnemonic(&raw);
*cache = Some((raw, parsed.clone()));
parsed
}
fn prefix_width(
backend: &Rc<RefCell<dyn teksilo_canvas::TextBackend>>,
stripped: &str,
byte_end: usize,
style: &teksilo_tokens::TextStyle,
) -> f32 {
if byte_end == 0 {
return 0.0;
}
let mut b = backend.borrow_mut();
let layout = b.layout_single_line(&stripped[..byte_end], style, None);
layout.width
}
}
impl Widget for MenuLabel {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
if let Prop::Bound(sig) = &self.source {
sig.bind_to(
ctx.self_id(),
ctx.binding_registry(),
BindingLevel::Relayout,
);
}
self.alt_down.bind_to(
ctx.self_id(),
ctx.binding_registry(),
BindingLevel::RepaintOnly,
);
Vec::new()
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let parsed = self.resolve_parsed();
let style = self.style.resolve(&ctx.theme.typography);
let Some(backend) = ctx.text_backend else {
let width = parsed.stripped.len() as f32 * 8.0;
let height = 16.0;
let w = match proposal.width {
Some(max) => width.min(max),
None => width,
};
*self.last_layout.borrow_mut() = None;
return Size::new(w, height).into();
};
let mut backend = backend.borrow_mut();
let max_width = proposal.width.map(|w| w + 0.5);
let layout = backend.layout_single_line(&parsed.stripped, &style, max_width);
let size = Size::new(layout.width, layout.height);
*self.last_cache_gen.borrow_mut() = backend.layout_cache_generation();
*self.last_layout.borrow_mut() = Some(layout);
size.into()
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let parsed = self.resolve_parsed();
let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
let style = self.style.resolve(&ctx.theme.typography);
let cur_gen = canvas
.text_backend()
.map(|b| b.borrow().layout_cache_generation());
let gen_ok = cur_gen.is_none_or(|c| c == *self.last_cache_gen.borrow());
let mut layout = if gen_ok {
self.last_layout.borrow().clone()
} else {
None
};
let drew = match layout.as_ref() {
Some(l) => canvas.draw_text_layout(l, Point::new(bounds.x, bounds.y), color),
None => false,
};
if !drew {
match canvas.text_backend().cloned() {
Some(backend_rc) => {
let (fresh, generation) = {
let mut b = backend_rc.borrow_mut();
let f = b.layout_single_line(&parsed.stripped, &style, None);
let g = b.layout_cache_generation();
(f, g)
};
if !canvas.draw_text_layout(&fresh, Point::new(bounds.x, bounds.y), color) {
canvas.draw_text(&parsed.stripped, bounds, &style, color);
}
*self.last_layout.borrow_mut() = Some(fresh.clone());
*self.last_cache_gen.borrow_mut() = generation;
layout = Some(fresh);
}
None => {
canvas.draw_text(&parsed.stripped, bounds, &style, color);
layout = None;
}
}
}
let alt_held = self.alt_down.get() && !cfg!(target_os = "macos");
if !alt_held || !parsed.has_mnemonic() {
return;
}
let Some(byte_index) = parsed.byte_index else {
return;
};
let char_byte_len = parsed.stripped[byte_index..]
.chars()
.next()
.map(char::len_utf8)
.unwrap_or(0);
if char_byte_len == 0 {
return;
}
let Some(backend_rc) = canvas.text_backend() else {
return;
};
let Some(layout) = layout else {
return;
};
let x0 = Self::prefix_width(backend_rc, &parsed.stripped, byte_index, &style);
let x1 = Self::prefix_width(
backend_rc,
&parsed.stripped,
byte_index + char_byte_len,
&style,
);
let underline_width = (x1 - x0).max(0.0);
if underline_width <= 0.0 {
return;
}
let thickness = layout.underline_thickness.max(1.0);
let y = bounds.y + layout.height - thickness;
canvas.fill_rect(
Rect::new(bounds.x + x0, y, underline_width, thickness),
color,
);
}
fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_canvas::SizeProposal;
use teksilo_core::signal::Signal;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_tokens::{TextRole, TextStyleRole};
fn tree() -> WidgetTree {
WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
}
#[test]
fn no_underline_when_alt_up() {
let mut t = tree();
let alt = Signal::new(false);
let label = MenuLabel::new(
Prop::from("&Save".to_string()),
alt.clone(),
ColorProp::TextRole(TextRole::Primary),
TextStyleProp::Role(TextStyleRole::Body),
);
let id = t.add(label);
t.layout(SizeProposal::exact(200.0, 40.0));
let frame = t.render();
let _ = id;
let _ = frame;
}
}