use std::{
any::Any,
f32::consts::{PI, TAU},
fmt,
sync::{
Arc,
atomic::{AtomicU32, Ordering},
},
time::Duration,
};
use omp_core::Str;
use smallvec::SmallVec;
use xutf::Text as _;
use crate::{
anim::{self, Easing, Lerp, Tween},
components::Markdown,
context::UiContext,
frame::{Color, Frame, Gradient, Rect, Style},
input::{Key, Mouse, UiEvent},
markup::{Align, Dim},
props::{Prop, PropValue, Props},
};
pub type Slot = u32;
static NEXT_SLOT: AtomicU32 = AtomicU32::new(1);
pub fn next_slot() -> Slot {
NEXT_SLOT.fetch_add(1, Ordering::Relaxed)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Flow {
Skip,
Consumed,
Event(UiEvent),
}
pub struct ResizeTail<'a> {
pub children: &'a mut [Cached],
pub gap: u16,
}
pub trait Component: Any {
fn props(&self) -> &Props;
fn props_mut(&mut self) -> &mut Props;
fn slot(&self) -> Slot;
fn kind(&self) -> &'static str {
std::any::type_name::<Self>()
}
fn children(&self) -> &[Cached] {
&[]
}
fn children_mut(&mut self) -> &mut [Cached] {
&mut []
}
fn measure(&mut self, ctx: &UiContext) -> (u16, u16);
fn height(&mut self, ctx: &UiContext, width: u16) -> u16;
fn place(&mut self, ctx: &UiContext, content: Rect) {
let _ = (ctx, content);
}
fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect);
fn paints_border(&self) -> bool {
true
}
fn paints_background(&self) -> bool {
true
}
fn gradient_bounds(&self, content: Rect) -> Option<Rect> {
let _ = content;
None
}
fn resize_tail(&mut self) -> Option<ResizeTail<'_>> {
None
}
fn validation_error(&self) -> Option<String> {
None
}
fn stretch_in_row(&self) -> bool {
false
}
fn focusable(&self) -> bool {
self.props().flag(Prop::Focus)
}
fn enter(&mut self, forward: bool) {
let _ = forward;
}
fn ring(&self, out: &mut Vec<Slot>) {
if self.focusable() {
out.push(self.slot());
}
for child in self.children().iter().filter(|child| child.visible) {
child.comp.ring(out);
}
}
fn key(&mut self, ec: &mut EventCtx<'_>, key: Key) -> Flow {
let _ = (ec, key);
Flow::Skip
}
fn mouse(
&mut self,
ec: &mut EventCtx<'_>,
tag: HitTag,
at: (u16, u16),
rect: Rect,
mouse: Mouse,
) -> Flow {
let _ = (ec, tag, at, rect, mouse);
Flow::Skip
}
fn paste(&mut self, ec: &mut EventCtx<'_>, text: &str) -> Flow {
let _ = (ec, text);
Flow::Skip
}
fn paste_raw(&mut self, ec: &mut EventCtx<'_>, text: &str) -> Flow {
self.paste(ec, text)
}
fn value(&self, out: &mut serde_json::Map<String, serde_json::Value>) {
let _ = out;
}
fn set_text(&mut self, ctx: &UiContext, text: Str) -> bool {
let _ = (ctx, text);
false
}
}
impl dyn Component {
pub(crate) fn is<T: Component>(&self) -> bool {
(self as &dyn Any).is::<T>()
}
#[cfg(test)]
pub(crate) fn downcast_ref<T: Component>(&self) -> Option<&T> {
(self as &dyn Any).downcast_ref()
}
pub(crate) fn downcast_mut<T: Component>(&mut self) -> Option<&mut T> {
(self as &mut dyn Any).downcast_mut()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct MemoKey {
version: u64,
width_epoch: u64,
revision: u64,
}
impl MemoKey {
pub(crate) fn new(version: u64, ctx: &UiContext) -> Self {
Self { version, width_epoch: crate::rich::width_config_epoch(), revision: ctx.revision }
}
}
pub struct Cached {
comp: Box<dyn Component>,
pub rect: Rect,
pub visible: bool,
version: u64,
measured: Option<(MemoKey, (u16, u16))>,
laid: Option<(MemoKey, u16, u16)>,
anim: Option<Box<AnimState>>,
}
impl Cached {
pub fn new(comp: Box<dyn Component>) -> Self {
Self {
comp,
rect: Rect::new(0, 0, 0, 0),
visible: true,
version: 0,
measured: None,
laid: None,
anim: None,
}
}
pub fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
let key = MemoKey::new(self.version, ctx);
if let Some((cached, measured)) = self.measured
&& cached == key
{
return measured;
}
let (mut min, mut nat) = self.comp.measure(ctx);
let extra = horizontal_inset(self.comp.props(), self.comp.paints_border()).saturating_mul(2);
min = min.saturating_add(extra);
nat = nat.saturating_add(extra).max(min);
let measured = (min, nat);
self.measured = Some((key, measured));
measured
}
pub fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
let key = MemoKey::new(self.version, ctx);
if let Some((cached, laid_width, height)) = self.laid
&& cached == key
&& laid_width == width
{
return height;
}
let fixed = self.sampled_h(ctx);
let paints_border = self.comp.paints_border();
let x_inset = horizontal_inset(self.comp.props(), paints_border);
let y_inset = vertical_inset(self.comp.props(), paints_border);
let height = if let Some(fixed) = fixed {
fixed
} else {
let content_width = width.saturating_sub(x_inset.saturating_mul(2));
let minimum = self
.measure(ctx)
.0
.saturating_sub(x_inset.saturating_mul(2));
self
.comp
.height(ctx, content_width.max(minimum).max(1))
.saturating_add(y_inset.saturating_mul(2))
};
let height = height.saturating_add(self.comp.props().lift());
if self.size_settled(ctx.now) {
self.laid = Some((key, width, height));
}
height
}
pub fn place(&mut self, ctx: &UiContext, rect: Rect) {
self.rect = rect;
let props = self.comp.props();
let chrome = lifted_rect(rect, props.lift(), 0);
let content = content_rect(chrome, props, self.comp.paints_border());
self.comp.place(ctx, content);
}
pub fn paint(&mut self, pc: &mut PaintCtx<'_>) {
let own = self.comp.slot();
let decorated = self.comp.props().hover_decorated();
let pointer_hovered = decorated && pc.hover.is_some_and(|(slot, _)| self.contains_slot(slot));
let hovered = pointer_hovered || (decorated && pc.keyboard && pc.focus == Some(own));
let mut glow = if pointer_hovered {
self.border_glow(pc)
} else if hovered {
self.focus_glow(pc)
} else {
None
};
let hover_swap = if hovered && glow.is_none() {
self.swap_hover_chrome()
} else {
None
};
let anim = self.begin_paint(pc.ctx, pc.now);
let chrome_anim = anim
.as_ref()
.map_or_else(ChromeAnim::default, |paint| paint.chrome);
let rect = self.rect;
if decorated {
pc.hits.push(Hit { rect, slot: own, tag: HitTag::Zone });
}
let lift = self.comp.props().lift();
let (risen, rise) = if lift == 0 {
(0, f32::from(u8::from(hovered)))
} else {
self.lift_rise(pc, hovered, lift)
};
let chrome = lifted_rect(rect, lift, risen);
if let Some(glow) = glow.as_mut()
&& glow.focus
{
glow.strength = self.focus_bloom(pc);
glow.pointer =
(chrome.x.saturating_add(chrome.width / 2), chrome.y.saturating_add(chrome.height / 2));
} else {
if let Some(glow) = glow.as_mut() {
glow.strength = rise;
}
if let Some(state) = self.anim.as_deref_mut() {
state.bloom = None;
}
}
let paints_border = self.comp.paints_border();
if lift > 0 {
self
.comp
.place(pc.ctx, content_rect(chrome, self.comp.props(), paints_border));
}
let props = self.comp.props();
if props.border().is_some() && paints_border {
paint_border(pc, chrome, props, chrome_anim, glow);
}
let content = content_rect(chrome, props, paints_border);
let outer_clip = pc.clip;
if props.h().is_some() {
pc.clip = pc.clip.min(content.y.saturating_add(content.height));
}
self.comp.paint(pc, content);
pc.clip = outer_clip;
paint_gradients(
pc,
chrome,
self.comp.gradient_bounds(content),
self.comp.props(),
paints_border,
self.comp.paints_background(),
chrome_anim,
);
if risen > 0 {
paint_lift_shadow(pc, chrome, rect);
}
if glow.is_some() && pointer_hovered {
pc.wake(own, pc.now.saturating_add(anim::FRAME));
}
if let Some(anim) = anim {
self.end_paint(pc, anim);
}
if let Some((prop, displaced)) = hover_swap {
match displaced {
Some(value) => self.comp.props_mut().set(prop, value),
None => self.comp.props_mut().unset(prop),
}
}
}
pub const fn invalidate(&mut self) {
self.version = self.version.wrapping_add(1);
self.measured = None;
self.laid = None;
}
pub fn update<R>(&mut self, slot: Slot, f: impl FnOnce(&mut Self) -> (R, bool)) -> Option<R> {
let mut f = Some(f);
self
.update_where(&|cached| cached.comp.slot() == slot, &mut f)
.map(|(value, _)| value)
}
pub fn update_id<R>(&mut self, id: &str, f: impl FnOnce(&mut Self) -> (R, bool)) -> Option<R> {
let mut f = Some(f);
self
.update_where(
&|cached| {
cached
.comp
.props()
.id()
.is_some_and(|candidate| candidate == id)
},
&mut f,
)
.map(|(value, _)| value)
}
fn update_where<R, P, F>(&mut self, predicate: &P, f: &mut Option<F>) -> Option<(R, bool)>
where
P: Fn(&Self) -> bool,
F: FnOnce(&mut Self) -> (R, bool),
{
if predicate(self) {
let (value, dirty) = f.take().expect("update closure reused")(self);
if dirty {
self.invalidate();
}
return Some((value, dirty));
}
let result = self
.comp
.children_mut()
.iter_mut()
.find_map(|child| child.update_where(predicate, f));
if result.as_ref().is_some_and(|(_, dirty)| *dirty) {
self.invalidate();
}
result
}
pub fn find_slot(&mut self, slot: Slot) -> Option<&mut Self> {
if self.comp.slot() == slot {
return Some(self);
}
for child in self.comp.children_mut() {
if let Some(found) = child.find_slot(slot) {
return Some(found);
}
}
None
}
pub fn comp(&self) -> &dyn Component {
self.comp.as_ref()
}
pub fn comp_mut(&mut self) -> &mut dyn Component {
self.comp.as_mut()
}
pub(crate) fn into_comp(self) -> Box<dyn Component> {
self.comp
}
pub(crate) fn fill_style(&mut self, ctx: &UiContext, now: Duration) -> Style {
let paint = self.begin_paint(ctx, now);
let style = self.comp.props().style(&ctx.theme);
if let Some(paint) = paint {
self.restore_props(paint.saved);
}
style
}
pub(crate) fn w(&mut self, ctx: &UiContext) -> Option<Dim> {
let target = self.comp.props().w();
let Some((duration, easing)) = self.anim_spec() else {
return target;
};
let state = self.anim.get_or_insert_default();
let Some(target) = target else {
state.w = None;
return None;
};
let (pct, goal) = match target {
Dim::Pct(percent) => (true, u16::from(percent)),
Dim::Cells(cells) => (false, cells),
};
let tween = match &mut state.w {
Some((unit, tween)) if *unit == pct => tween,
slot => &mut slot.insert((pct, Tween::settled(goal))).1,
};
tween.retarget(ctx.now, goal, duration, easing);
let sampled = tween.sample(ctx.now);
Some(if pct {
Dim::Pct(sampled.min(100) as u8)
} else {
Dim::Cells(sampled)
})
}
fn sampled_h(&mut self, ctx: &UiContext) -> Option<u16> {
let target = self.comp.props().h();
let Some((duration, easing)) = self.anim_spec() else {
return target;
};
let state = self.anim.get_or_insert_default();
let Some(target) = target else {
state.h = None;
return None;
};
let tween = state.h.get_or_insert_with(|| Tween::settled(target));
tween.retarget(ctx.now, target, duration, easing);
Some(tween.sample(ctx.now))
}
fn size_settled(&self, now: Duration) -> bool {
self.anim.as_deref().is_none_or(|state| {
state.h.is_none_or(|tween| tween.is_settled(now))
&& state.w.is_none_or(|(_, tween)| tween.is_settled(now))
})
}
fn anim_spec(&self) -> Option<(Duration, Easing)> {
let props = self.comp.props();
Some((props.anim()?, props.ease()))
}
fn begin_paint(&mut self, ctx: &UiContext, now: Duration) -> Option<PaintAnim> {
let props = self.comp.props();
let spec = props.anim().map(|duration| (duration, props.ease()));
let spin = props.spin();
if spec.is_none() && spin.is_none() && self.anim.is_none() {
return None;
}
if spec.is_some() {
let _ = self.sampled_h(ctx);
let _ = self.w(ctx);
}
let props = self.comp.props();
let mut paint = PaintAnim::default();
if let Some(period) = spin
&& (props.gradient_of(Prop::Fg).is_some()
|| props.gradient_of(Prop::Bg).is_some()
|| props.gradient_of(Prop::On).is_some()
|| props.gradient_of(bc_slot(props)).is_some())
{
let nanos = period.as_nanos().max(1);
paint.chrome.angle = ((now.as_nanos() % nanos) * 360 / nanos) as u16;
let step = Duration::from_nanos((nanos / 360) as u64).max(anim::FRAME);
paint.merge_wake(now.saturating_add(step));
}
if let Some((duration, easing)) = spec {
let bg_prop = if props.get(Prop::Bg).is_some() {
Prop::Bg
} else {
Prop::On
};
let bc_prop = bc_slot(props);
let fg_target = color_target(ctx, props, Prop::Fg);
let bg_target = color_target(ctx, props, bg_prop);
let bc_target = color_target(ctx, props, bc_prop);
let state = self.anim.get_or_insert_default();
state.fg.retarget(now, fg_target, duration, easing);
state.bg.retarget(now, bg_target, duration, easing);
state.bc.retarget(now, bc_target, duration, easing);
paint.chrome.fg = paint.apply(self.comp.as_mut(), &state.fg, Prop::Fg, now);
paint.chrome.bg = paint.apply(self.comp.as_mut(), &state.bg, bg_prop, now);
paint.chrome.bc = paint.apply(self.comp.as_mut(), &state.bc, bc_prop, now);
for settles in
[state.h.map(|tween| tween.settles_at()), state.w.map(|(_, tween)| tween.settles_at())]
.into_iter()
.flatten()
.filter(|&settles| settles > now)
{
paint.relayout = true;
paint.merge_wake(settles.min(now.saturating_add(anim::FRAME)));
}
} else {
self.anim = None;
}
if paint.wake.is_none() {
None
} else {
Some(paint)
}
}
fn end_paint(&mut self, pc: &mut PaintCtx<'_>, paint: PaintAnim) {
let PaintAnim { saved, wake, relayout, .. } = paint;
self.restore_props(saved);
if let Some(at) = wake {
let slot = self.comp.slot();
if relayout {
pc.wake_layout(slot, at);
} else {
pc.wake(slot, at);
}
}
}
fn restore_props(&mut self, saved: SmallVec<(Prop, PropValue), 3>) {
for (prop, value) in saved {
self.comp.props_mut().set(prop, value);
}
}
pub(crate) fn contains_slot(&self, slot: Slot) -> bool {
self.comp.slot() == slot
|| self
.comp
.children()
.iter()
.any(|child| child.contains_slot(slot))
}
fn swap_hover_chrome(&mut self) -> Option<(Prop, Option<PropValue>)> {
let props = self.comp.props();
let hover = props.get(Prop::Hover).cloned()?;
let slot = bc_slot(props);
let displaced = props.get(slot).cloned();
self.comp.props_mut().set(slot, hover);
Some((slot, displaced))
}
fn border_glow(&self, pc: &PaintCtx<'_>) -> Option<BorderGlow> {
let pointer = pc.pointer?;
let (start, end) = self.hover_ramp(pc)?;
Some(BorderGlow { pointer, start, end, strength: 1.0, focus: false })
}
fn focus_glow(&self, pc: &PaintCtx<'_>) -> Option<BorderGlow> {
let (start, end) = self.hover_ramp(pc)?;
Some(BorderGlow { pointer: (0, 0), start, end, strength: 1.0, focus: true })
}
fn hover_ramp(&self, pc: &PaintCtx<'_>) -> Option<(Color, Color)> {
let value = self.comp.props().gradient_of(Prop::Hover)?;
let (start, end) = value.split_once("..")?;
let resolve = |color: &str| pc.ctx.theme.token(color).or_else(|| Color::parse(color));
Some((resolve(start)?, resolve(end)?))
}
fn lift_rise(&mut self, pc: &mut PaintCtx<'_>, hovered: bool, lift: u16) -> (u16, f32) {
let target = if hovered { f32::from(lift) } else { 0.0 };
let Some((duration, easing)) = self.anim_spec() else {
return if hovered { (lift, 1.0) } else { (0, 0.0) };
};
let (duration, easing) = if pc.keyboard {
((duration / 2).min(KEY_SNAP), Easing::EaseOut)
} else {
(duration, easing)
};
let state = self.anim.get_or_insert_default();
let tween = state.lift.get_or_insert_with(|| Tween::settled(0.0));
tween.retarget(pc.now, target, duration, easing);
let sample = tween.sample(pc.now).clamp(0.0, f32::from(lift));
if !tween.is_settled(pc.now) {
let at = tween.settles_at().min(pc.now.saturating_add(anim::FRAME));
pc.wake(self.comp.slot(), at);
}
(sample.round() as u16, sample / f32::from(lift))
}
fn focus_bloom(&mut self, pc: &mut PaintCtx<'_>) -> f32 {
let Some((duration, easing)) = self.anim_spec() else {
return 1.0;
};
let state = self.anim.get_or_insert_default();
let tween = state.bloom.get_or_insert_with(|| Tween::settled(0.0));
tween.retarget(pc.now, 1.0, duration, easing);
let sample = tween.sample(pc.now);
if !tween.is_settled(pc.now) {
let at = tween.settles_at().min(pc.now.saturating_add(anim::FRAME));
pc.wake(self.comp.slot(), at);
}
sample
}
}
#[derive(Default)]
struct AnimState {
fg: Channel,
bg: Channel,
bc: Channel,
w: Option<(bool, Tween<u16>)>,
h: Option<Tween<u16>>,
lift: Option<Tween<f32>>,
bloom: Option<Tween<f32>>,
}
#[derive(Clone, Copy, Default)]
enum Channel {
#[default]
Empty,
Solid(Tween<Color>),
Ramp(Tween<(Color, Color)>),
}
impl Channel {
fn retarget(
&mut self,
now: Duration,
target: ChannelTarget,
duration: Duration,
easing: Easing,
) {
match (self, target) {
(Self::Solid(tween), ChannelTarget::Solid(color)) => {
tween.retarget(now, color, duration, easing);
},
(Self::Ramp(tween), ChannelTarget::Ramp(start, end)) => {
tween.retarget(now, (start, end), duration, easing);
},
(slot, ChannelTarget::None) => *slot = Self::Empty,
(slot, ChannelTarget::Solid(color)) => *slot = Self::Solid(Tween::settled(color)),
(slot, ChannelTarget::Ramp(start, end)) => {
*slot = Self::Ramp(Tween::settled((start, end)));
},
}
}
}
#[derive(Clone, Copy)]
enum ChannelTarget {
None,
Solid(Color),
Ramp(Color, Color),
}
fn color_target(ctx: &UiContext, props: &Props, prop: Prop) -> ChannelTarget {
match props.get(prop) {
Some(PropValue::Color(color)) => ChannelTarget::Solid(*color),
Some(PropValue::Token(token)) => ctx
.theme
.token(token)
.map_or(ChannelTarget::None, ChannelTarget::Solid),
Some(PropValue::Gradient(value)) => {
let resolve = |color: &str| ctx.theme.token(color).or_else(|| Color::parse(color));
value
.split_once("..")
.and_then(|(start, end)| Some((resolve(start)?, resolve(end)?)))
.map_or(ChannelTarget::None, |(start, end)| ChannelTarget::Ramp(start, end))
},
_ => ChannelTarget::None,
}
}
#[derive(Clone, Copy, Default)]
pub struct ChromeAnim {
fg: Option<(Color, Color)>,
bg: Option<(Color, Color)>,
bc: Option<(Color, Color)>,
angle: u16,
}
#[derive(Default)]
struct PaintAnim {
saved: SmallVec<(Prop, PropValue), 3>,
chrome: ChromeAnim,
wake: Option<Duration>,
relayout: bool,
}
impl PaintAnim {
fn apply(
&mut self,
comp: &mut dyn Component,
channel: &Channel,
prop: Prop,
now: Duration,
) -> Option<(Color, Color)> {
match channel {
Channel::Empty => None,
Channel::Solid(tween) => {
if !tween.is_settled(now)
&& let Some(saved) = comp.props().get(prop).cloned()
{
self.merge_wake(tween.settles_at().min(now.saturating_add(anim::FRAME)));
comp.props_mut().set(prop, tween.sample(now));
self.saved.push((prop, saved));
}
None
},
Channel::Ramp(tween) => {
if tween.is_settled(now) {
return None;
}
self.merge_wake(tween.settles_at().min(now.saturating_add(anim::FRAME)));
Some(tween.sample(now))
},
}
}
fn merge_wake(&mut self, at: Duration) {
self.wake = Some(self.wake.map_or(at, |wake| wake.min(at)));
}
}
pub fn horizontal_inset(props: &Props, paints_border: bool) -> u16 {
let (_, pad_x) = props.pad();
pad_x.saturating_add(u16::from(paints_border && props.border().is_some()))
}
pub fn vertical_inset(props: &Props, paints_border: bool) -> u16 {
let (pad_y, _) = props.pad();
pad_y.saturating_add(u16::from(paints_border && props.border().is_some()))
}
fn content_rect(rect: Rect, props: &Props, paints_border: bool) -> Rect {
let x_inset = horizontal_inset(props, paints_border);
let y_inset = vertical_inset(props, paints_border);
Rect::new(
rect.x.saturating_add(x_inset),
rect.y.saturating_add(y_inset),
rect.width.saturating_sub(x_inset.saturating_mul(2)),
rect.height.saturating_sub(y_inset.saturating_mul(2)),
)
}
fn bc_slot(props: &Props) -> Prop {
if props.get(Prop::Bc).is_some() {
Prop::Bc
} else {
Prop::Edge
}
}
fn lifted_rect(rect: Rect, lift: u16, risen: u16) -> Rect {
let lift = lift.min(rect.height.saturating_sub(1));
Rect::new(rect.x, rect.y.saturating_add(lift - risen.min(lift)), rect.width, rect.height - lift)
}
fn paint_lift_shadow(pc: &mut PaintCtx<'_>, chrome: Rect, rect: Rect) {
let y = chrome.y.saturating_add(chrome.height);
let Some(glyph) = pc.ctx.charset.shadow() else {
return;
};
if y >= rect.y.saturating_add(rect.height) || y >= pc.clip || rect.width < 3 {
return;
}
let style = Style::new().fg(pc.ctx.theme.shadow);
for x in rect.x.saturating_add(1)..rect.x.saturating_add(rect.width - 1) {
pc.frame.put(x, y, glyph, style);
}
}
const KEY_SNAP: Duration = Duration::from_millis(120);
#[derive(Clone, Copy)]
struct BorderGlow {
pointer: (u16, u16),
start: Color,
end: Color,
strength: f32,
focus: bool,
}
impl BorderGlow {
fn color_at(self, x: u16, y: u16, rect: Rect, base: Color, phase: f32) -> Option<Color> {
let dx = (f32::from(x) - f32::from(self.pointer.0)) * 0.5;
let dy = f32::from(y) - f32::from(self.pointer.1);
let radius = if self.focus {
let corner_x = f32::from(rect.width) * 0.25;
let corner_y = f32::from(rect.height) * 0.5;
corner_x.hypot(corner_y) * self.strength.mul_add(1.6, 0.4)
} else {
(f32::from(rect.width).mul_add(0.5, f32::from(rect.height)) * 0.3).clamp(2.5, 6.0)
* self.strength.mul_add(0.65, 0.35)
};
let amount = self.strength * (-dx.mul_add(dx, dy * dy) / (radius * radius)).exp();
if amount < 0.02 {
return None;
}
let center_x = f32::from(rect.x) + f32::from(rect.width) / 2.0;
let center_y = f32::from(rect.y) + f32::from(rect.height) / 2.0;
let cell = (f32::from(y) - center_y).atan2((f32::from(x) - center_x) * 0.5);
let cursor =
(f32::from(self.pointer.1) - center_y).atan2((f32::from(self.pointer.0) - center_x) * 0.5);
let mut delta = (cell - cursor).rem_euclid(TAU);
if delta > PI {
delta = TAU - delta;
}
let wave = phase.mul_add(0.15, delta / PI);
let wheel = 1.0 - (1.0 - wave.rem_euclid(2.0)).abs();
Some(base.lerp(self.start.lerp(self.end, wheel), amount.min(1.0)))
}
}
fn glow_cell(frame: &mut Frame, x: u16, y: u16, rect: Rect, glow: BorderGlow, phase: f32) {
frame.recolor_fg(x, y, |base| glow.color_at(x, y, rect, base, phase).unwrap_or(base));
}
pub fn paint_gradients(
pc: &mut PaintCtx<'_>,
bounds: Rect,
projection: Option<Rect>,
props: &Props,
paints_border: bool,
paints_background: bool,
chrome: ChromeAnim,
) {
let angle = (props.angle() + chrome.angle) % 360;
let bottom = bounds.y.saturating_add(bounds.height).min(pc.clip);
let painted = Rect::new(bounds.x, bounds.y, bounds.width, bottom.saturating_sub(bounds.y));
if paints_background {
let background_bounds = if paints_border && props.border().is_some() && !props.bleed() {
Rect::new(
bounds.x.saturating_add(1),
bounds.y.saturating_add(1),
bounds.width.saturating_sub(2),
bounds.height.saturating_sub(2),
)
} else {
bounds
};
let background_bottom = background_bounds
.y
.saturating_add(background_bounds.height)
.min(pc.clip);
let background = Rect::new(
background_bounds.x,
background_bounds.y,
background_bounds.width,
background_bottom.saturating_sub(background_bounds.y),
);
let bg_prop = if props.get(Prop::Bg).is_some() {
Prop::Bg
} else {
Prop::On
};
let gradient = chrome
.bg
.map(|(start, end)| Gradient::new(start, end, angle))
.or_else(|| resolve_gradient(pc.ctx, props, bg_prop, angle));
if let Some(gradient) = gradient {
pc.frame
.underlay_gradient(background, gradient, projection.unwrap_or(background_bounds));
} else {
let bg = props.style(&pc.ctx.theme).background_color();
if bg != Color::Default {
pc.frame.underlay(background, bg);
}
}
}
let gradient = chrome
.fg
.map(|(start, end)| Gradient::new(start, end, angle))
.or_else(|| resolve_gradient(pc.ctx, props, Prop::Fg, angle));
if let Some(gradient) = gradient {
pc.frame
.gradient_foreground(painted, gradient, projection.unwrap_or(bounds));
}
}
fn resolve_gradient(ctx: &UiContext, props: &Props, prop: Prop, angle: u16) -> Option<Gradient> {
let value = props.gradient_of(prop)?;
let (start, end) = value.split_once("..")?;
let resolve = |color: &str| ctx.theme.token(color).or_else(|| Color::parse(color));
Some(Gradient::new(resolve(start)?, resolve(end)?, angle))
}
fn assemble_border_line(
line: &mut SmallVec<u8, 256>,
left: char,
horizontal: char,
right: char,
inner: usize,
) {
line.clear();
let mut left_bytes = [0; 4];
line.extend_from_slice(left.encode_utf8(&mut left_bytes).as_bytes());
let mut horizontal_bytes = [0; 4];
let horizontal = horizontal.encode_utf8(&mut horizontal_bytes).as_bytes();
for _ in 0..inner {
line.extend_from_slice(horizontal);
}
let mut right_bytes = [0; 4];
line.extend_from_slice(right.encode_utf8(&mut right_bytes).as_bytes());
}
fn paint_border(
pc: &mut PaintCtx<'_>,
rect: Rect,
props: &Props,
chrome: ChromeAnim,
glow: Option<BorderGlow>,
) {
if rect.width < 2 || rect.height < 2 {
return;
}
let border = props.border().unwrap_or_default();
let (tl, tr, bl, br, horizontal, vertical) = pc.ctx.charset.border(border);
let style = props.style(&pc.ctx.theme);
let base = if props.bleed() {
style
} else {
style.bg(Color::Default)
};
let angle = (props.angle() + chrome.angle) % 360;
let ramp = chrome
.bc
.map(|(start, end)| Gradient::new(start, end, angle))
.or_else(|| resolve_gradient(pc.ctx, props, bc_slot(props), angle));
let edge = if ramp.is_some() {
base.fg(Color::Default)
} else if let Some(color) = props.edge(&pc.ctx.theme) {
base.fg(color)
} else if props.get(Prop::Fg).is_some() {
base.dim()
} else {
base.fg(pc.ctx.theme.border)
};
let inner = usize::from(rect.width) - 2;
assemble_border_line(&mut pc.border_scratch, tl, horizontal, tr, inner);
if rect.y < pc.clip {
let top = std::str::from_utf8(&pc.border_scratch)
.expect("border glyph assembly only appends valid UTF-8");
pc.frame.put(rect.x, rect.y, top, edge);
}
let bottom_y = rect.y.saturating_add(rect.height - 1);
if bottom_y < pc.clip {
assemble_border_line(&mut pc.border_scratch, bl, horizontal, br, inner);
let bottom = std::str::from_utf8(&pc.border_scratch)
.expect("border glyph assembly only appends valid UTF-8");
pc.frame.put(rect.x, bottom_y, bottom, edge);
}
let mut vertical_bytes = [0; 4];
let vertical = vertical.encode_utf8(&mut vertical_bytes);
for y in rect.y.saturating_add(1)..bottom_y.min(pc.clip) {
pc.frame.put(rect.x, y, &*vertical, edge);
pc.frame
.put(rect.x.saturating_add(rect.width - 1), y, &*vertical, edge);
}
if let Some(gradient) = ramp {
let side_top = rect.y.saturating_add(1);
let side_rows = bottom_y.min(pc.clip).saturating_sub(side_top);
let strips = [
(rect.y < pc.clip).then(|| Rect::new(rect.x, rect.y, rect.width, 1)),
(bottom_y < pc.clip).then(|| Rect::new(rect.x, bottom_y, rect.width, 1)),
(side_rows > 0).then(|| Rect::new(rect.x, side_top, 1, side_rows)),
(side_rows > 0)
.then(|| Rect::new(rect.x.saturating_add(rect.width - 1), side_top, 1, side_rows)),
];
for strip in strips.into_iter().flatten() {
pc.frame.gradient_foreground(strip, gradient, rect);
}
}
if let Some(glow) = glow {
let phase = pc.now.as_secs_f32() * 0.5;
let right = rect.x.saturating_add(rect.width - 1);
if rect.y < pc.clip {
for x in rect.x..=right {
glow_cell(pc.frame, x, rect.y, rect, glow, phase);
}
}
if bottom_y < pc.clip {
for x in rect.x..=right {
glow_cell(pc.frame, x, bottom_y, rect, glow, phase);
}
}
for y in rect.y.saturating_add(1)..bottom_y.min(pc.clip) {
glow_cell(pc.frame, rect.x, y, rect, glow, phase);
glow_cell(pc.frame, right, y, rect, glow, phase);
}
}
if rect.y < pc.clip
&& let Some(title) = props.title()
{
border_label(pc, rect, rect.y, title, props.title_align(), base, true);
}
if bottom_y < pc.clip
&& let Some(footer) = props.footer()
{
border_label(pc, rect, bottom_y, footer, props.footer_align(), base, false);
}
}
fn border_label(
pc: &mut PaintCtx<'_>,
rect: Rect,
y: u16,
text: &str,
align: Align,
base: Style,
bold: bool,
) {
let fit = rect.width.saturating_sub(4);
if fit == 0 {
return;
}
let mut width: u16 = 0;
let mut end = 0usize;
for grapheme in text.graphemes() {
if grapheme == "\n" || grapheme == "\r" {
break;
}
let cells = u16::try_from(grapheme.visible_width()).unwrap_or(u16::MAX);
if width.saturating_add(cells) > fit {
break;
}
width += cells;
end += grapheme.len();
}
if width == 0 {
return;
}
let text = &text[..end];
let total = width + 2;
let x = match align {
Align::Start => rect.x.saturating_add(2),
Align::Center => rect.x.saturating_add(rect.width.saturating_sub(total) / 2),
Align::End => rect
.x
.saturating_add(rect.width.saturating_sub(2).saturating_sub(total)),
}
.clamp(
rect.x.saturating_add(1),
rect
.x
.saturating_add(rect.width.saturating_sub(1).saturating_sub(total)),
);
let end = pc.frame.put(x, y, " ", base);
let end = pc
.frame
.put(end, y, text, if bold { base.bold() } else { base });
pc.frame.put(end, y, " ", base);
}
pub struct PaintCtx<'a> {
pub frame: &'a mut Frame,
pub clip: u16,
pub ctx: &'a UiContext,
pub hits: &'a mut Vec<Hit>,
pub focus: Option<Slot>,
pub hover: Option<(Slot, HitTag)>,
pub pointer: Option<(u16, u16)>,
pub keyboard: bool,
pub now: Duration,
pub(crate) wakes: &'a mut Vec<Wake>,
border_scratch: SmallVec<u8, 256>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Wake {
pub slot: Slot,
pub at: Duration,
pub layout: bool,
}
impl<'a> PaintCtx<'a> {
pub(crate) const fn new(
frame: &'a mut Frame,
ctx: &'a UiContext,
hits: &'a mut Vec<Hit>,
wakes: &'a mut Vec<Wake>,
) -> Self {
let clip = frame.size().height;
Self {
frame,
clip,
ctx,
hits,
focus: None,
hover: None,
pointer: None,
keyboard: false,
now: Duration::ZERO,
border_scratch: SmallVec::new(),
wakes,
}
}
pub(crate) const fn nested<'b>(&'b mut self, frame: &'b mut Frame, clip: u16) -> PaintCtx<'b> {
PaintCtx {
frame,
clip,
ctx: self.ctx,
hits: self.hits,
focus: self.focus,
hover: self.hover,
pointer: self.pointer,
keyboard: self.keyboard,
now: self.now,
border_scratch: SmallVec::new(),
wakes: self.wakes,
}
}
pub fn wake(&mut self, slot: Slot, at: Duration) {
self.request(slot, at, false);
}
pub(crate) fn wake_layout(&mut self, slot: Slot, at: Duration) {
self.request(slot, at, true);
}
fn request(&mut self, slot: Slot, at: Duration, layout: bool) {
match self.wakes.iter_mut().find(|wake| wake.slot == slot) {
Some(wake) => {
wake.at = wake.at.min(at);
wake.layout |= layout;
},
None => self.wakes.push(Wake { slot, at, layout }),
}
}
}
pub struct EventCtx<'a> {
pub ctx: &'a UiContext,
pub width: u16,
pub view_rows: u16,
pub(crate) layout: bool,
}
impl<'a> EventCtx<'a> {
pub const fn new(ctx: &'a UiContext, width: u16, view_rows: u16) -> Self {
Self { ctx, width, view_rows, layout: false }
}
pub const fn request_layout(&mut self) {
self.layout = true;
}
}
pub trait IntoComponent {
fn into_component(self) -> Box<dyn Component>;
}
impl<T: Component + 'static> IntoComponent for T {
fn into_component(self) -> Box<dyn Component> {
Box::new(self)
}
}
impl IntoComponent for Box<dyn Component> {
fn into_component(self) -> Box<dyn Component> {
self
}
}
impl IntoComponent for &str {
fn into_component(self) -> Box<dyn Component> {
Box::new(Markdown::text_of(self))
}
}
impl IntoComponent for String {
fn into_component(self) -> Box<dyn Component> {
Box::new(Markdown::text_of(self))
}
}
impl IntoComponent for Str {
fn into_component(self) -> Box<dyn Component> {
Box::new(Markdown::text_of(self))
}
}
pub trait IntoChildren {
fn extend_children(self, out: &mut Vec<Cached>);
}
impl<T: IntoComponent> IntoChildren for T {
fn extend_children(self, out: &mut Vec<Cached>) {
out.push(Cached::new(self.into_component()));
}
}
impl IntoChildren for () {
fn extend_children(self, _out: &mut Vec<Cached>) {}
}
impl<T: IntoChildren> IntoChildren for Option<T> {
fn extend_children(self, out: &mut Vec<Cached>) {
if let Some(children) = self {
children.extend_children(out);
}
}
}
impl<T: IntoChildren> IntoChildren for Vec<T> {
fn extend_children(self, out: &mut Vec<Cached>) {
for children in self {
children.extend_children(out);
}
}
}
impl<T: IntoChildren, const N: usize> IntoChildren for [T; N] {
fn extend_children(self, out: &mut Vec<Cached>) {
for children in self {
children.extend_children(out);
}
}
}
impl<T: IntoChildren, const N: usize> IntoChildren for SmallVec<T, N> {
fn extend_children(self, out: &mut Vec<Cached>) {
for children in self {
children.extend_children(out);
}
}
}
impl IntoChildren for Cached {
fn extend_children(self, out: &mut Vec<Cached>) {
out.push(self);
}
}
pub trait ElementFactory: Send + Sync {
fn build(&self, name: &str, props: Props, children: Vec<Cached>) -> Box<dyn Component>;
}
impl<F> ElementFactory for F
where
F: Fn(&str, Props, Vec<Cached>) -> Box<dyn Component> + Send + Sync,
{
fn build(&self, name: &str, props: Props, children: Vec<Cached>) -> Box<dyn Component> {
self(name, props, children)
}
}
#[derive(Clone, Default)]
pub struct Elements(Arc<Vec<(Str, Box<dyn ElementFactory>)>>);
impl fmt::Debug for Elements {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Elements")
.field("len", &self.0.len())
.finish()
}
}
impl Elements {
pub fn builder() -> ElementsBuilder {
ElementsBuilder::default()
}
pub(crate) fn get(&self, name: &str) -> Option<&dyn ElementFactory> {
self
.0
.iter()
.find(|(candidate, _)| candidate == name)
.map(|(_, factory)| factory.as_ref())
}
pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
#[derive(Default)]
pub struct ElementsBuilder {
factories: Vec<(Str, Box<dyn ElementFactory>)>,
}
impl ElementsBuilder {
pub fn with(mut self, name: impl Into<Str>, factory: impl ElementFactory + 'static) -> Self {
let name = name.into();
if let Some((_, stored)) = self
.factories
.iter_mut()
.find(|(candidate, _)| candidate == &name)
{
*stored = Box::new(factory);
} else {
self.factories.push((name, Box::new(factory)));
}
self
}
pub fn build(self) -> Elements {
Elements(Arc::new(self.factories))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HitTag {
Row(u16),
Sub(u16),
Chip(u16),
Press,
Wheel,
Scrollbar,
Zone,
}
#[derive(Clone, Copy, Debug)]
pub struct Hit {
pub rect: Rect,
pub slot: Slot,
pub tag: HitTag,
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, rc::Rc};
use parking_lot::{Mutex, MutexGuard};
use super::*;
struct Probe {
props: Props,
slot: Slot,
children: Vec<Cached>,
measures: Rc<Cell<u32>>,
}
impl Probe {
fn new(measures: Rc<Cell<u32>>, children: Vec<Cached>) -> Self {
Self { props: Props::new(), slot: next_slot(), children, measures }
}
}
static WIDTH_EPOCH: Mutex<()> = Mutex::new(());
fn width_epoch_guard() -> MutexGuard<'static, ()> {
WIDTH_EPOCH.lock()
}
impl Component for Probe {
fn props(&self) -> &Props {
&self.props
}
fn props_mut(&mut self) -> &mut Props {
&mut self.props
}
fn slot(&self) -> Slot {
self.slot
}
fn children(&self) -> &[Cached] {
&self.children
}
fn children_mut(&mut self) -> &mut [Cached] {
&mut self.children
}
fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
self.measures.set(self.measures.get() + 1);
(1, 2)
}
fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
1
}
fn paint(&mut self, _pc: &mut PaintCtx<'_>, _rect: Rect) {}
}
#[test]
fn dirty_update_invalidates_only_ancestor_path() {
let _epoch = width_epoch_guard();
let target_count = Rc::new(Cell::new(0));
let sibling_count = Rc::new(Cell::new(0));
let root_count = Rc::new(Cell::new(0));
let target = Cached::new(Box::new(Probe::new(target_count.clone(), Vec::new())));
let target_slot = target.comp().slot();
let sibling = Cached::new(Box::new(Probe::new(sibling_count.clone(), Vec::new())));
let mut root = Cached::new(Box::new(Probe::new(root_count.clone(), vec![target, sibling])));
let ctx = UiContext::default();
root.measure(&ctx);
root.height(&ctx, 8);
for child in root.comp.children_mut() {
child.measure(&ctx);
child.height(&ctx, 8);
}
root.update(target_slot, |_| ((), true)).unwrap();
assert!(root.measured.is_none());
assert!(root.laid.is_none());
let children = root.comp.children();
assert!(children[0].measured.is_none());
assert!(children[0].laid.is_none());
assert!(children[1].measured.is_some());
assert!(children[1].laid.is_some());
root.measure(&ctx);
root.height(&ctx, 8);
root.comp.children_mut()[0].measure(&ctx);
root.comp.children_mut()[0].height(&ctx, 8);
root.comp.children_mut()[1].measure(&ctx);
root.comp.children_mut()[1].height(&ctx, 8);
assert_eq!(root_count.get(), 2);
assert_eq!(target_count.get(), 2);
assert_eq!(sibling_count.get(), 1);
root.update(target_slot, |_| ((), false)).unwrap();
assert!(root.measured.is_some());
assert!(root.laid.is_some());
assert!(root.comp.children()[0].measured.is_some());
assert!(root.comp.children()[0].laid.is_some());
assert!(root.comp.children()[1].measured.is_some());
assert!(root.comp.children()[1].laid.is_some());
}
#[test]
fn into_children_flattens_supported_inputs() {
let mut children = Vec::new();
().extend_children(&mut children);
Some("one").extend_children(&mut children);
vec!["two", "three"].extend_children(&mut children);
["four", "five"].extend_children(&mut children);
assert_eq!(children.len(), 5);
}
#[test]
fn elements_builder_resolves_registered_factory() {
let elements = Elements::builder()
.with("card", |_name: &str, _props: Props, _children: Vec<Cached>| {
Box::new(Markdown::text_of("made")) as Box<dyn Component>
})
.build();
let mut built = elements
.get("card")
.unwrap()
.build("card", Props::new(), Vec::new());
assert!(built.measure(&UiContext::default()).1 > 0);
assert!(elements.get("missing").is_none());
}
#[test]
fn width_epoch_invalidates_cached_measurement() {
let _epoch = width_epoch_guard();
let original = crate::rich::jamo_width();
let next = if original == crate::context::JamoWidth::Narrow {
crate::context::JamoWidth::Wide
} else {
crate::context::JamoWidth::Narrow
};
let measures = Rc::new(Cell::new(0));
let mut cached = Cached::new(Box::new(Probe::new(measures.clone(), Vec::new())));
let ctx = UiContext::default();
assert_eq!(cached.measure(&ctx), (1, 2));
assert_eq!(cached.measure(&ctx), (1, 2));
assert_eq!(measures.get(), 1);
assert!(crate::rich::set_jamo_width(next));
assert_eq!(cached.measure(&ctx), (1, 2));
assert_eq!(measures.get(), 2);
crate::rich::set_jamo_width(original);
}
}