use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use crate::sync::{new_channel, Sender, WakerShared};
use tiny_skia::Pixmap;
use crate::core::{DamageReq, NodeId, Tree};
use crate::event::{
CursorShape, Key, MenuAction, MenuItem, MouseButton, PointerEvent, PointerKind, ToastRequest,
WindowOp,
};
use crate::geometry::{Color, Point, Rect, Size};
use crate::platform::{self, AppHandler, DialogRequest, WindowConfig};
use crate::render::{Paint, SkiaCanvas};
use crate::signal::Signal;
use crate::text::{PlatformTextEngine, TextEngine};
use crate::theme::Theme;
thread_local! {
static MODAL_SIGNALS: RefCell<Vec<Signal<bool>>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn register_modal(show: Signal<bool>) {
MODAL_SIGNALS.with(|s| s.borrow_mut().push(show));
}
fn close_topmost_modal() -> bool {
MODAL_SIGNALS.with(|s| {
for sig in s.borrow().iter().rev() {
if sig.get() {
sig.set(false);
return true;
}
}
false
})
}
use crate::ui::Element;
const MENU_ITEM_H: i32 = 30;
const MENU_SEP_H: i32 = 9;
const MENU_PAD_X: i32 = 12;
const MENU_VPAD: i32 = 6;
const MENU_MIN_W: i32 = 140;
const MENU_MAX_H: i32 = 320;
const MENU_FONT: f32 = 13.5;
const MENU_ICON_W: i32 = 18;
const MENU_GAP: i32 = 8;
const MENU_TRAIL_GAP: i32 = 18;
const MENU_EDGE_MARGIN: i32 = 10;
const TOOLTIP_DELAY_MS: u64 = 500;
const TOOLTIP_FONT: f32 = 13.0;
const TOOLTIP_PAD_X: i32 = 8;
const TOOLTIP_PAD_Y: i32 = 4;
const TOOLTIP_CURSOR_DX: i32 = 12;
const TOOLTIP_CURSOR_DY: i32 = 20;
const TOAST_FONT: f32 = 14.0;
const TOAST_ICON_FONT: f32 = 18.0;
const TOAST_PAD_X: i32 = 16;
const TOAST_PAD_Y: i32 = 11;
const TOAST_ICON_GAP: i32 = 12;
const TOAST_MIN_W: i32 = 132;
const TOAST_FADE_IN_MS: u64 = 140;
const TOAST_FADE_OUT_MS: u64 = 280;
const TOAST_MAX: usize = 4;
const TOAST_TOP_MARGIN: i32 = 16;
const TOAST_GAP: i32 = 10;
const TOAST_CLOSE_W: i32 = 22;
const TOAST_TEXT_MIN_W: i32 = 60;
struct ToastState {
req: ToastRequest,
shown_at_ms: u64,
paused_at_ms: Option<u64>,
paused_total_ms: u64,
}
impl ToastState {
fn active_elapsed(&self, now_ms: u64) -> u64 {
let raw = now_ms.saturating_sub(self.shown_at_ms);
let cur_pause = self
.paused_at_ms
.map(|p| now_ms.saturating_sub(p))
.unwrap_or(0);
raw.saturating_sub(self.paused_total_ms)
.saturating_sub(cur_pause)
}
fn set_hover(&mut self, now_ms: u64, hovered: bool) {
match (hovered, self.paused_at_ms) {
(true, None) => self.paused_at_ms = Some(now_ms),
(false, Some(p)) => {
self.paused_total_ms += now_ms.saturating_sub(p);
self.paused_at_ms = None;
}
_ => {}
}
}
fn expired(&self, now_ms: u64) -> bool {
self.active_elapsed(now_ms) >= self.req.duration_ms
}
fn alpha(&self, now_ms: u64) -> f32 {
let e = self.active_elapsed(now_ms);
let d = self.req.duration_ms;
if e < TOAST_FADE_IN_MS {
return e as f32 / TOAST_FADE_IN_MS as f32;
}
let fade_out_start = d.saturating_sub(TOAST_FADE_OUT_MS);
if e >= fade_out_start && d > fade_out_start {
return ((d - e) as f32 / TOAST_FADE_OUT_MS as f32).clamp(0.0, 1.0);
}
1.0
}
}
struct MenuLevel {
items: Vec<MenuItem>,
rect: Rect,
hover: Option<usize>,
has_icons: bool,
spawn: Option<usize>,
content_h: i32,
scroll: i32,
}
impl MenuLevel {
fn item_rows(&self) -> Vec<(i32, i32)> {
let mut y = self.rect.y + MENU_VPAD - self.scroll;
let mut rows = Vec::with_capacity(self.items.len());
for it in &self.items {
let h = if it.separator {
MENU_SEP_H
} else {
MENU_ITEM_H
};
rows.push((y, h));
y += h;
}
rows
}
fn max_scroll(&self) -> i32 {
(self.content_h - self.rect.h).max(0)
}
fn item_at(&self, p: Point) -> Option<usize> {
if !self.rect.contains(p) {
return None;
}
for (i, (top, h)) in self.item_rows().into_iter().enumerate() {
if p.y >= top && p.y < top + h {
return if self.items[i].separator {
None
} else {
Some(i)
};
}
}
None
}
}
struct ContextMenu {
levels: Vec<MenuLevel>,
target: NodeId,
}
impl ContextMenu {
fn level_at(&self, p: Point) -> Option<usize> {
self.levels.iter().rposition(|l| l.rect.contains(p))
}
}
type RenderClosure = Box<dyn FnMut(&mut dyn crate::render::RenderTarget, Size)>;
#[derive(Clone)]
pub struct ThemeHandle {
inner: Rc<RefCell<Rc<Theme>>>,
}
impl ThemeHandle {
fn new(t: Rc<Theme>) -> Self {
Self {
inner: Rc::new(RefCell::new(t)),
}
}
pub fn set(&self, t: Theme) {
*self.inner.borrow_mut() = Rc::new(t);
crate::anim::request_repaint();
}
pub fn current(&self) -> Rc<Theme> {
self.inner.borrow().clone()
}
}
pub struct App {
cfg: WindowConfig,
render: Option<RenderClosure>,
content: Option<Element>,
theme: Option<Theme>,
theme_src: Option<ThemeHandle>,
pumps: Vec<Box<dyn FnMut()>>,
intervals: Vec<(Duration, Box<dyn FnMut()>)>,
waker_shared: Option<Arc<WakerShared>>,
single: Option<crate::single_instance::SingleInstance>,
close_handler: Option<Box<dyn FnMut() -> bool>>,
}
impl App {
pub fn new(title: impl Into<String>, width: i32, height: i32) -> Self {
Self {
cfg: WindowConfig {
title: title.into(),
width,
height,
bg: Color::hex(0xF3F3F3),
centered: false,
resizable: true,
screenshot: None,
screenshot_scale: 1.0,
screenshot_rclick: None,
screenshot_click: None,
screenshot_hover: None,
tray: None,
frameless: false,
animations: None,
accelerated: false,
min_width: 0,
min_height: 0,
},
render: None,
content: None,
theme: None,
theme_src: None,
pumps: Vec::new(),
intervals: Vec::new(),
waker_shared: None,
single: None,
close_handler: None,
}
}
pub fn bg(mut self, c: Color) -> Self {
self.cfg.bg = c;
self
}
pub fn resizable(mut self, v: bool) -> Self {
self.cfg.resizable = v;
self
}
pub fn min_size(mut self, w: i32, h: i32) -> Self {
self.cfg.min_width = w;
self.cfg.min_height = h;
self
}
pub fn animations(mut self, on: bool) -> Self {
self.cfg.animations = Some(on);
self
}
pub fn centered(mut self) -> Self {
self.cfg.centered = true;
self
}
pub fn accelerated(mut self, on: bool) -> Self {
self.cfg.accelerated = on;
self
}
pub fn theme(mut self, t: Theme) -> Self {
self.cfg.bg = t.palette.bg;
if let Some(h) = &self.theme_src {
*h.inner.borrow_mut() = Rc::new(t.clone());
}
self.theme = Some(t);
self
}
pub fn theme_handle(&mut self) -> ThemeHandle {
let init = Rc::new(self.theme.clone().unwrap_or_default());
self.theme_src
.get_or_insert_with(|| ThemeHandle::new(init))
.clone()
}
pub fn screenshot(mut self, path: impl Into<PathBuf>) -> Self {
self.cfg.screenshot = Some(path.into());
self
}
pub fn screenshot_from_args(mut self) -> Self {
let args: Vec<String> = std::env::args().collect();
if let Some(i) = args.iter().position(|a| a == "--screenshot") {
if let Some(p) = args.get(i + 1) {
self.cfg.screenshot = Some(PathBuf::from(p));
}
}
if let Some(i) = args.iter().position(|a| a == "--scale") {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<f32>().ok()) {
self.cfg.screenshot_scale = v;
}
}
if let Some(i) = args.iter().position(|a| a == "--rclick") {
if let (Some(x), Some(y)) = (
args.get(i + 1).and_then(|s| s.parse::<i32>().ok()),
args.get(i + 2).and_then(|s| s.parse::<i32>().ok()),
) {
self.cfg.screenshot_rclick = Some((x, y));
}
}
if let Some(i) = args.iter().position(|a| a == "--click") {
if let (Some(x), Some(y)) = (
args.get(i + 1).and_then(|s| s.parse::<i32>().ok()),
args.get(i + 2).and_then(|s| s.parse::<i32>().ok()),
) {
self.cfg.screenshot_click = Some((x, y));
}
}
if let Some(i) = args.iter().position(|a| a == "--hover") {
if let (Some(x), Some(y)) = (
args.get(i + 1).and_then(|s| s.parse::<i32>().ok()),
args.get(i + 2).and_then(|s| s.parse::<i32>().ok()),
) {
self.cfg.screenshot_hover = Some((x, y));
}
}
if args.iter().any(|a| a == "--accelerated") {
self.cfg.accelerated = true;
}
self
}
pub fn on_render(
mut self,
f: impl FnMut(&mut dyn crate::render::RenderTarget, Size) + 'static,
) -> Self {
self.render = Some(Box::new(f));
self
}
pub fn content(mut self, root: Element) -> Self {
self.content = Some(root);
self
}
pub fn tray(mut self, tray: platform::Tray) -> Self {
self.cfg.tray = Some(tray);
self
}
pub fn frameless(mut self) -> Self {
self.cfg.frameless = true;
self
}
pub fn single_instance(
mut self,
app_id: impl Into<String>,
on_second_instance: impl FnMut(Vec<String>) + 'static,
) -> Self {
self.single = Some(crate::single_instance::SingleInstance {
app_id: app_id.into(),
on_second: Box::new(on_second_instance),
});
self
}
pub fn on_close_request(mut self, f: impl FnMut() -> bool + 'static) -> Self {
self.close_handler = Some(Box::new(f));
self
}
pub fn run(mut self) {
let single = self.single.take();
let theme_src = match self.theme_src {
Some(h) => h,
None => ThemeHandle::new(Rc::new(self.theme.unwrap_or_default())),
};
let waker = self.waker_shared.clone();
let cfg = self.cfg;
let handler: Box<dyn AppHandler> = if let Some(f) = self.render {
Box::new(ClosureHandler { f })
} else if let Some(root) = self.content {
Box::new(UiHost::new(
root,
theme_src,
cfg.bg,
self.pumps,
self.intervals,
self.close_handler,
))
} else {
Box::new(ClosureHandler {
f: Box::new(|_, _| {}),
})
};
platform::run(cfg, handler, waker, single);
}
#[cfg(test)]
fn into_handler_for_test(self) -> UiHost {
let theme_src = match self.theme_src {
Some(h) => h,
None => ThemeHandle::new(Rc::new(self.theme.unwrap_or_default())),
};
UiHost::new(
self.content.unwrap(),
theme_src,
self.cfg.bg,
self.pumps,
self.intervals,
self.close_handler,
)
}
fn shared_waker(&mut self) -> crate::sync::Waker {
self.waker_shared
.get_or_insert_with(WakerShared::new)
.waker()
}
pub fn channel<Msg: Send + 'static>(
&mut self,
on_message: impl FnMut(Msg) + 'static,
) -> Sender<Msg> {
let waker = self.shared_waker();
let (tx, pump) = new_channel(waker, on_message);
self.pumps.push(pump);
tx
}
pub fn on_interval(mut self, every: Duration, cb: impl FnMut() + 'static) -> Self {
self.intervals.push((every, Box::new(cb)));
self
}
}
struct ClosureHandler {
f: RenderClosure,
}
impl AppHandler for ClosureHandler {
fn render(&mut self, target: &mut dyn crate::render::RenderTarget, size: Size) {
(self.f)(target, size);
}
}
const FLING_FRICTION: f32 = 0.996;
const FLING_TRIGGER: f32 = 0.25;
const FLING_STOP: f32 = 0.02;
const FLING_STALL_MS: u64 = 100;
const BOUNCE_GAIN: f32 = 22.0;
const MAX_BOUNCE: f32 = 26.0;
const BOUNCE_DECAY: f32 = 0.98;
#[derive(Clone, Copy, PartialEq)]
enum FlingPhase {
Glide,
Bounce,
}
struct Fling {
node: NodeId,
phase: FlingPhase,
vel: f32,
over: f32,
residual: f32,
last_ms: Option<u64>,
}
struct MenuScrollbarDrag {
level: usize,
start_y: i32,
start_scroll: i32,
track_h: f32,
thumb_h: f32,
}
struct UiHost {
tree: Tree,
engine: PlatformTextEngine,
hover: Option<NodeId>,
capture: Option<NodeId>,
focus: Option<NodeId>,
focus_order: Vec<NodeId>,
close: bool,
scale: f32,
focus_visible: bool,
menu: Option<ContextMenu>,
logical_size: Size,
theme: Rc<Theme>,
theme_src: ThemeHandle,
start: std::time::Instant,
pan_residual: f32,
fling: Option<Fling>,
pending_window_op: Option<WindowOp>,
pending_dialog: Option<DialogRequest>,
hover_pos: Point,
hover_since_ms: u64,
tooltip_suppressed: bool,
toasts: Vec<ToastState>,
toast_rects: Vec<(Rect, Rect)>,
bg: Color,
back: Option<Pixmap>,
pending_damage: Option<Rect>,
event_damage: Option<Rect>,
needs_relayout: bool,
last_layout_sig: u64,
sig_valid: bool,
needs_full: bool,
#[cfg(test)]
last_frame_full: bool,
swallow_up: bool,
menu_scrollbar_drag: Option<MenuScrollbarDrag>,
pumps: Vec<Box<dyn FnMut()>>,
interval_cbs: Vec<Box<dyn FnMut()>>,
interval_durs: Vec<std::time::Duration>,
show_fps: bool,
close_handler: Option<Box<dyn FnMut() -> bool>>,
}
const DAMAGE_MARGIN: i32 = 2;
impl UiHost {
fn new(
root: Element,
theme_src: ThemeHandle,
bg: Color,
pumps: Vec<Box<dyn FnMut()>>,
intervals: Vec<(std::time::Duration, Box<dyn FnMut()>)>,
close_handler: Option<Box<dyn FnMut() -> bool>>,
) -> Self {
let theme = theme_src.current();
crate::theme::set_current(theme.clone());
let mut tree = Tree::new();
tree.root = Some(root.build(&mut tree));
tree.clipboard = Some(Box::new(crate::platform::Clipboard));
let (interval_durs, interval_cbs): (Vec<_>, Vec<_>) = intervals.into_iter().unzip();
Self {
tree,
engine: PlatformTextEngine::new(),
hover: None,
capture: None,
focus: None,
focus_order: Vec::new(),
close: false,
scale: 1.0,
focus_visible: false,
menu: None,
logical_size: Size::new(0, 0),
theme,
theme_src,
start: std::time::Instant::now(),
pan_residual: 0.0,
fling: None,
pending_window_op: None,
pending_dialog: None,
hover_pos: Point::new(0, 0),
hover_since_ms: 0,
tooltip_suppressed: false,
toasts: Vec::new(),
toast_rects: Vec::new(),
bg,
back: None,
pending_damage: None,
event_damage: None,
needs_relayout: false,
last_layout_sig: 0,
sig_valid: false,
needs_full: true,
#[cfg(test)]
last_frame_full: false,
swallow_up: false,
menu_scrollbar_drag: None,
pumps,
interval_cbs,
interval_durs,
show_fps: std::env::var("WINDUI_FPS").is_ok_and(|v| v != "0" && !v.is_empty()),
close_handler,
}
}
fn apply_damage(&mut self, d: DamageReq) {
match d {
DamageReq::Rect(r) => {
self.event_damage = Some(match self.event_damage {
Some(e) => e.union(&r),
None => r,
});
}
DamageReq::Layout(_) | DamageReq::Full => self.needs_full = true,
DamageReq::None => {}
}
}
fn clear_fling(&mut self) -> bool {
match self.fling.take() {
Some(f) => {
self.tree.set_over_scroll(f.node, 0);
true
}
None => false,
}
}
fn step_fling(&mut self, now_ms: u64) {
let Some(f) = self.fling.as_ref() else { return };
let (node, phase, last) = (f.node, f.phase, f.last_ms);
let dt = match last {
None => 16,
Some(prev) => {
let raw = now_ms.saturating_sub(prev);
if raw > FLING_STALL_MS {
self.tree.set_over_scroll(node, 0);
self.fling = None;
return;
}
raw.min(64)
}
} as f32;
match phase {
FlingPhase::Glide => {
let f = self.fling.as_mut().unwrap();
f.last_ms = Some(now_ms);
f.vel *= FLING_FRICTION.powf(dt);
let advance = f.vel * dt + f.residual;
let delta = advance.trunc() as i32;
f.residual = advance - delta as f32;
let vel = f.vel;
let hit = match self.tree.scroll_range(node) {
Some((cur, max)) => {
let next = cur + delta;
let clamped = next.clamp(0, max);
self.tree.set_scroll_y(node, clamped);
clamped != next
}
None => {
self.fling = None; return;
}
};
if hit {
let impulse = (-vel * BOUNCE_GAIN).clamp(-MAX_BOUNCE, MAX_BOUNCE);
if impulse.abs() < 1.0 {
self.tree.set_over_scroll(node, 0);
self.fling = None;
} else {
self.tree.set_over_scroll(node, impulse.round() as i32);
let f = self.fling.as_mut().unwrap();
f.phase = FlingPhase::Bounce;
f.over = impulse;
crate::anim::request_repaint();
}
} else if vel.abs() < FLING_STOP {
self.fling = None;
} else {
crate::anim::request_repaint();
}
}
FlingPhase::Bounce => {
let f = self.fling.as_mut().unwrap();
f.last_ms = Some(now_ms);
f.over *= BOUNCE_DECAY.powf(dt);
let over = f.over;
if over.abs() < 0.5 {
self.tree.set_over_scroll(node, 0);
self.fling = None;
} else {
self.tree.set_over_scroll(node, over.round() as i32);
crate::anim::request_repaint();
}
}
}
}
fn level_width(&mut self, items: &[MenuItem], min_width: i32) -> (i32, bool) {
let has_icons = items.iter().any(|it| it.icon.is_some());
let mut max_label = 0;
let mut max_trail = 0;
for it in items {
if it.separator {
continue;
}
max_label = max_label.max(self.engine.measure(&it.label, None, MENU_FONT, None).w);
let tw = if !it.submenu.is_empty() {
10
} else if let Some(s) = &it.shortcut {
self.engine.measure(s, None, MENU_FONT - 2.0, None).w
} else if it.checked {
12
} else {
0
};
max_trail = max_trail.max(tw);
}
let icon_w = if has_icons { MENU_ICON_W + MENU_GAP } else { 0 };
let trail_w = if max_trail > 0 {
MENU_TRAIL_GAP + max_trail
} else {
0
};
let w = (MENU_PAD_X + icon_w + max_label + trail_w + MENU_PAD_X)
.max(MENU_MIN_W)
.max(min_width);
(w, has_icons)
}
fn build_level(
&mut self,
items: Vec<MenuItem>,
ax: i32,
ay: i32,
min_width: i32,
flip_right: Option<i32>,
anchor_top: Option<i32>,
) -> MenuLevel {
let (w, has_icons) = self.level_width(&items, min_width);
let body: i32 = items
.iter()
.map(|it| {
if it.separator {
MENU_SEP_H
} else {
MENU_ITEM_H
}
})
.sum();
let content_h = body + 2 * MENU_VPAD;
let ws = self.logical_size;
let max_h = MENU_MAX_H.min(if ws.h > 0 { ws.h * 3 / 4 } else { MENU_MAX_H });
let h = content_h.min(max_h);
let mut x = ax;
let mut y = ay;
let em = if ws.w > 0 { MENU_EDGE_MARGIN } else { 0 };
if ws.w > 0 && x + w > ws.w - em {
x = match flip_right {
Some(parent_left) => (parent_left - w).max(em),
None => (ws.w - w - em).max(em),
};
}
x = x.max(em);
if ws.h > 0 && y + h > ws.h - em {
if let Some(top) = anchor_top {
let y_above = top - h;
if y_above >= em {
y = y_above;
} else {
let space_below = ws.h - ay;
let space_above = top;
if space_above >= space_below {
y = em; } else {
y = (ws.h - h - em).max(em); }
}
} else {
y = (ws.h - h - em).max(em);
}
}
y = y.max(em);
let initial_scroll = if content_h > h {
let mut offset = MENU_VPAD;
let mut result = 0i32;
for it in &items {
let ih = if it.separator {
MENU_SEP_H
} else {
MENU_ITEM_H
};
if it.checked {
result = offset + ih / 2 - h / 2;
break;
}
offset += ih;
}
result.clamp(0, (content_h - h).max(0))
} else {
0
};
MenuLevel {
items,
rect: Rect::new(x, y, w, h),
hover: None,
has_icons,
spawn: None,
content_h,
scroll: initial_scroll,
}
}
fn open_menu(&mut self, req: crate::event::MenuRequest, target: NodeId) {
let level = self.build_level(
req.items,
req.pos.x,
req.pos.y,
req.min_width,
None,
req.anchor_top,
);
self.menu = Some(ContextMenu {
levels: vec![level],
target,
});
}
fn resync_hover_after_relayout(&mut self) {
if self.menu.is_some() {
return;
}
let mut hover = self.hover;
let mut capture = self.capture;
let _ = self.tree.dispatch_pointer(
PointerEvent::single(PointerKind::Move, self.hover_pos, MouseButton::Left),
&mut hover,
&mut capture,
);
self.hover = hover;
self.capture = capture;
}
fn show_toast(&mut self, req: ToastRequest) {
self.push_toast(req);
self.needs_full = true;
}
fn flush_pending_toasts(&mut self) {
for req in self.tree.take_pending_toasts() {
self.show_toast(req);
}
}
fn push_toast(&mut self, req: ToastRequest) {
let now_ms = self.start.elapsed().as_millis() as u64;
if self.toasts.len() >= TOAST_MAX {
self.toasts.remove(0);
}
self.toasts.push(ToastState {
req,
shown_at_ms: now_ms,
paused_at_ms: None,
paused_total_ms: 0,
});
}
fn retain_live_toasts(&mut self, now_ms: u64) {
self.toasts.retain(|t| !t.expired(now_ms));
}
fn menu_hover_update(&mut self, pos: Point) -> bool {
let Some(k) = self.menu.as_ref().and_then(|m| m.level_at(pos)) else {
return false;
};
let item_idx = self.menu.as_ref().unwrap().levels[k].item_at(pos);
let mut changed = false;
{
let m = self.menu.as_mut().unwrap();
if m.levels[k].hover != item_idx {
m.levels[k].hover = item_idx;
changed = true;
}
}
let (submenu, anchor) = {
let lvl = &self.menu.as_ref().unwrap().levels[k];
match item_idx {
Some(i) if !lvl.items[i].submenu.is_empty() && lvl.items[i].enabled => {
let (top, _) = lvl.item_rows()[i];
(
Some(lvl.items[i].submenu.clone()),
Some((lvl.rect.right(), top - MENU_VPAD, lvl.rect.x, i)),
)
}
_ => (None, None),
}
};
let existing_spawn = self
.menu
.as_ref()
.and_then(|m| m.levels.get(k + 1).map(|l| l.spawn));
match (submenu, anchor) {
(Some(items), Some((ax, ay, parent_left, i))) => {
if existing_spawn == Some(Some(i)) {
let m = self.menu.as_mut().unwrap();
if m.levels.len() > k + 2 {
m.levels.truncate(k + 2);
changed = true;
}
} else {
if let Some(m) = self.menu.as_mut() {
m.levels.truncate(k + 1);
}
let mut child =
self.build_level(items, ax - 2, ay, 0, Some(parent_left + 2), None);
child.spawn = Some(i);
self.menu.as_mut().unwrap().levels.push(child);
changed = true;
}
}
_ => {
let m = self.menu.as_mut().unwrap();
if m.levels.len() > k + 1 {
m.levels.truncate(k + 1);
changed = true;
}
}
}
changed
}
fn handle_menu_pointer(&mut self, ev: PointerEvent) -> bool {
match ev.kind {
PointerKind::Move => {
if let Some(drag) = &self.menu_scrollbar_drag {
let dy = ev.pos.y - drag.start_y;
let travel = (drag.track_h - drag.thumb_h).max(1.0);
let level_idx = drag.level;
let start_scroll = drag.start_scroll;
if let Some(m) = self.menu.as_mut() {
if let Some(level) = m.levels.get_mut(level_idx) {
let max_sc = level.max_scroll();
let new_scroll =
start_scroll + (dy as f32 * max_sc as f32 / travel).round() as i32;
level.scroll = new_scroll.clamp(0, max_sc);
}
}
return true;
}
self.menu_hover_update(ev.pos)
}
PointerKind::Down => {
if let Some(k) = self.menu.as_ref().and_then(|m| m.level_at(ev.pos)) {
let level = &self.menu.as_ref().unwrap().levels[k];
let r = level.rect;
if level.content_h > r.h && ev.pos.x >= r.right() - 16 {
let track_h = (r.h - 8) as f32;
let ratio = r.h as f32 / level.content_h as f32;
let thumb_h = (track_h * ratio).max(20.0);
self.menu_scrollbar_drag = Some(MenuScrollbarDrag {
level: k,
start_y: ev.pos.y,
start_scroll: level.scroll,
track_h,
thumb_h,
});
self.swallow_up = true;
return true;
}
}
self.swallow_up = true;
let Some(k) = self.menu.as_ref().and_then(|m| m.level_at(ev.pos)) else {
self.menu = None; return true;
};
self.menu_hover_update(ev.pos);
let hit = self.menu.as_ref().and_then(|m| {
let lvl = &m.levels[k];
lvl.item_at(ev.pos).map(|i| lvl.items[i].clone())
});
if let Some(item) = hit {
if item.is_actionable() {
let target = self.menu.as_ref().unwrap().target;
self.menu = None;
match item.action {
MenuAction::SendKey(key) => {
let res = self.tree.dispatch_key(key, Some(target));
if res.close {
self.close = true;
}
}
MenuAction::Run(f) => f(),
}
}
}
true
}
PointerKind::Up => {
self.menu_scrollbar_drag = None;
true
}
PointerKind::Wheel(delta) => {
if let Some(k) = self.menu.as_ref().and_then(|m| m.level_at(ev.pos)) {
let level = &mut self.menu.as_mut().unwrap().levels[k];
let step = (delta.abs() / 3).max(MENU_ITEM_H);
let dir = if delta > 0 { -step } else { step };
level.scroll = (level.scroll + dir).clamp(0, level.max_scroll());
}
true
}
_ => true, }
}
fn move_focus(&mut self, forward: bool) -> bool {
if self.focus_order.is_empty() {
return false;
}
let n = self.focus_order.len();
let cur = self
.focus
.and_then(|f| self.focus_order.iter().position(|&x| x == f));
let next = match cur {
Some(i) if forward => (i + 1) % n,
Some(i) => (i + n - 1) % n,
None if forward => 0,
None => n - 1,
};
let nf = Some(self.focus_order[next]);
let old = self.focus;
self.tree.set_focused(nf, old);
self.focus = nf;
true
}
fn toast_hit(&self, p: Point) -> Option<usize> {
self.toast_rects
.iter()
.position(|(panel, _)| panel.contains(p))
}
fn toast_close_hit(&self, p: Point) -> Option<usize> {
self.toast_rects
.iter()
.position(|(_, close)| close.contains(p))
}
fn handle_toast_pointer(&mut self, ev: crate::event::PointerEvent) -> bool {
use crate::event::{MenuAction, MenuItem, MouseButton, PointerKind};
let now_ms = self.start.elapsed().as_millis() as u64;
let hit = self.toast_hit(ev.pos);
for (i, t) in self.toasts.iter_mut().enumerate() {
t.set_hover(now_ms, Some(i) == hit);
}
if hit.is_some() {
self.needs_full = true; }
if ev.kind == PointerKind::Down && ev.button == MouseButton::Left {
if let Some(i) = self.toast_close_hit(ev.pos) {
self.toasts.remove(i);
self.needs_full = true;
self.swallow_up = true; return true;
}
}
if ev.kind == PointerKind::Down && ev.button == MouseButton::Right {
if let Some(i) = hit {
let text = self.toasts[i].req.text.clone();
let item = MenuItem {
label: "复制内容".to_string(),
action: MenuAction::Run(std::rc::Rc::new(move || {
use crate::core::ClipboardProvider;
crate::platform::Clipboard.set_text(&text);
})),
enabled: true,
checked: false,
icon: None,
shortcut: None,
separator: false,
submenu: Vec::new(),
};
if let Some(target) = self.focus.or(self.tree.root) {
self.open_menu(
crate::event::MenuRequest {
pos: ev.pos,
items: vec![item],
min_width: 0,
anchor_top: None,
},
target,
);
}
return true;
}
}
hit.is_some()
}
}
impl AppHandler for UiHost {
fn render(&mut self, target: &mut dyn crate::render::RenderTarget, size: Size) {
let frame_t0 = std::time::Instant::now();
for pump in self.pumps.iter_mut() {
pump();
}
self.theme = self.theme_src.current();
crate::theme::set_current(self.theme.clone());
crate::anim::reset_request();
let now_ms = self.start.elapsed().as_millis() as u64;
crate::anim::set_clock_ms(now_ms);
self.step_fling(now_ms);
let s = self.scale;
let logical = Size::new(
(size.w as f32 / s).round().max(1.0) as i32,
(size.h as f32 / s).round().max(1.0) as i32,
);
self.logical_size = logical;
let mut laid_out = false;
if self.needs_relayout {
self.tree.layout_root(logical, &mut self.engine);
laid_out = true;
let sig = self.tree.layout_signature();
if self.sig_valid && sig != self.last_layout_sig {
self.needs_full = true;
self.tree.reset_hidden_interactions();
self.resync_hover_after_relayout();
}
self.last_layout_sig = sig;
self.sig_valid = true;
self.needs_relayout = false;
}
self.flush_pending_toasts();
let back_ok = self
.back
.as_ref()
.map(|b| b.width() == size.w as u32 && b.height() == size.h as u32)
.unwrap_or(false);
let overlay = self.menu.is_some()
|| !self.toasts.is_empty()
|| (!self.tooltip_suppressed
&& self.hover.and_then(|h| self.tree.node_tooltip(h)).is_some());
let damage = match (self.pending_damage.take(), self.event_damage.take()) {
(Some(a), Some(b)) => Some(a.union(&b)),
(a, b) => a.or(b),
};
let scale_ok = {
let q = s * 4.0;
(q - q.round()).abs() < 1e-3
};
let damage_small = damage
.map(|d| {
let win = self.logical_size.w as i64 * self.logical_size.h as i64;
win > 0 && (d.w as i64 * d.h as i64) * 2 <= win
})
.unwrap_or(false);
let do_full = self.needs_full
|| !back_ok
|| overlay
|| !scale_ok
|| !damage_small
|| target.as_pixmap().is_none();
self.needs_full = false;
#[cfg(test)]
{
self.last_frame_full = do_full;
}
if !do_full {
let pixmap = target.as_pixmap().expect("软目标必有 pixmap");
self.render_partial(pixmap, size, s, damage.unwrap());
self.pending_damage = next_damage(&mut self.needs_full);
if crate::render::prof::enabled() {
eprintln!(
"[prof] partial {:.2}ms {}",
frame_t0.elapsed().as_secs_f64() * 1000.0,
crate::render::prof::take_summary()
);
}
return;
}
if !laid_out {
self.tree.layout_root(logical, &mut self.engine);
self.last_layout_sig = self.tree.layout_signature();
self.sig_valid = true;
}
self.flush_pending_toasts();
self.focus_order = self.tree.focusable_order();
if let Some(f) = self.focus {
if !self.focus_order.contains(&f) {
self.tree.set_focused(None, Some(f));
self.focus = None;
}
}
self.tree.focus_ring_visible = self.focus_visible;
self.retain_live_toasts(now_ms);
let mut canvas = target.make_canvas(&mut self.engine, s);
self.tree.paint(&mut *canvas);
if self.menu.is_none() && !self.tooltip_suppressed {
if let Some(text) = self.hover.and_then(|h| self.tree.node_tooltip(h)) {
if now_ms.saturating_sub(self.hover_since_ms) < TOOLTIP_DELAY_MS {
crate::anim::request_repaint();
} else {
let (pal, tt) = (&self.theme.palette, &self.theme.tooltip);
let ts = canvas.measure_text_wrapped(&text, None, TOOLTIP_FONT, tt.max_width());
let (w, h) = (ts.w + 2 * TOOLTIP_PAD_X, ts.h + 2 * TOOLTIP_PAD_Y);
let ws = self.logical_size;
let mut x = self.hover_pos.x + TOOLTIP_CURSOR_DX;
let mut y = self.hover_pos.y + TOOLTIP_CURSOR_DY;
if ws.w > 0 && x + w > ws.w {
x = (ws.w - w).max(0);
}
if ws.h > 0 && y + h > ws.h {
y = (self.hover_pos.y - h - 4).max(0); }
let corner = tt.corner(&self.theme.metrics);
canvas.fill_round_rect(
x as f32,
y as f32,
w as f32,
h as f32,
corner,
&Paint::fill(tt.bg(pal)),
);
let tr = Rect::new(x + TOOLTIP_PAD_X, y, w - 2 * TOOLTIP_PAD_X, h);
canvas.draw_text(
&text,
tr,
tt.text(pal),
crate::spec::Align::Start,
None,
TOOLTIP_FONT,
);
}
}
}
self.toast_rects.clear();
let ws = self.logical_size;
let mut y = TOAST_TOP_MARGIN;
for toast in &self.toasts {
let alpha = toast.alpha(now_ms);
let pal = &self.theme.palette;
let tt = &self.theme.toast;
let glyph = toast.req.kind.glyph();
let icon_color = match toast.req.kind {
crate::event::ToastKind::Info => tt.info(pal),
crate::event::ToastKind::Success => tt.success(pal),
crate::event::ToastKind::Error => tt.error(pal),
};
let icon_sz = canvas.measure_text(glyph, None, TOAST_ICON_FONT);
let panel_max_w = (ws.w - 2 * TOAST_TOP_MARGIN).max(TOAST_MIN_W);
let text_max_w = (panel_max_w
- TOAST_PAD_X
- icon_sz.w
- TOAST_ICON_GAP
- TOAST_ICON_GAP
- TOAST_CLOSE_W
- TOAST_PAD_X)
.max(TOAST_TEXT_MIN_W);
let ts =
canvas.measure_text_wrapped(&toast.req.text, None, TOAST_FONT, text_max_w as f32);
let panel_w = (TOAST_PAD_X
+ icon_sz.w
+ TOAST_ICON_GAP
+ ts.w
+ TOAST_ICON_GAP
+ TOAST_CLOSE_W
+ TOAST_PAD_X)
.max(TOAST_MIN_W)
.min(panel_max_w);
let panel_h = TOAST_PAD_Y + ts.h.max(icon_sz.h) + TOAST_PAD_Y;
let x = ((ws.w - panel_w) / 2).max(0);
let corner = tt.corner(&self.theme.metrics);
canvas.draw_shadow(
x as f32,
(y + 6) as f32,
panel_w as f32,
panel_h as f32,
corner,
22.0,
Color::rgba(0, 0, 0, 90).scale_alpha(alpha),
);
canvas.fill_round_rect(
x as f32,
y as f32,
panel_w as f32,
panel_h as f32,
corner,
&Paint::fill(tt.bg(pal).scale_alpha(alpha)),
);
let icon_x = x + TOAST_PAD_X;
let icon_rect = Rect::new(icon_x, y, icon_sz.w, panel_h);
canvas.draw_text(
glyph,
icon_rect,
icon_color.scale_alpha(alpha),
crate::spec::Align::Center,
None,
TOAST_ICON_FONT,
);
let text_x = icon_x + icon_sz.w + TOAST_ICON_GAP;
let text_rect = Rect::new(text_x, y, text_max_w, panel_h);
canvas.draw_text(
&toast.req.text,
text_rect,
tt.text(pal).scale_alpha(alpha),
crate::spec::Align::Start,
None,
TOAST_FONT,
);
let close = Rect::new(
x + panel_w - TOAST_CLOSE_W - TOAST_PAD_X / 2,
y,
TOAST_CLOSE_W,
panel_h,
);
canvas.draw_text(
"\u{2715}",
close,
pal.text_muted.scale_alpha(alpha),
crate::spec::Align::Center,
None,
TOAST_FONT,
);
let panel = Rect::new(x, y, panel_w, panel_h);
self.toast_rects.push((panel, close));
y += panel_h + TOAST_GAP;
crate::anim::request_repaint();
}
if let Some(menu) = self.menu.as_ref() {
let (pal, mt) = (&self.theme.palette, &self.theme.menu);
for (li, level) in menu.levels.iter().enumerate() {
let r = level.rect;
canvas.draw_shadow(
r.x as f32,
(r.y + 6) as f32,
r.w as f32,
r.h as f32,
10.0,
18.0,
Color::rgba(0, 0, 0, 110),
);
canvas.fill_round_rect(
r.x as f32,
r.y as f32,
r.w as f32,
r.h as f32,
10.0,
&Paint::fill(mt.bg(pal)),
);
canvas.stroke_round_rect(
r.x as f32,
r.y as f32,
r.w as f32,
r.h as f32,
10.0,
1.0,
&Paint::fill(mt.border(pal)),
);
let child_spawn = menu.levels.get(li + 1).and_then(|l| l.spawn);
let label_x = r.x
+ MENU_PAD_X
+ if level.has_icons {
MENU_ICON_W + MENU_GAP
} else {
0
};
canvas.save();
canvas.clip_rect(Rect::new(
r.x,
r.y + MENU_VPAD,
r.w,
(r.h - 2 * MENU_VPAD).max(0),
));
for (i, (top, h)) in level.item_rows().into_iter().enumerate() {
let it = &level.items[i];
if it.separator {
canvas.fill_rect(
(r.x + 8) as f32,
(top + h / 2) as f32,
(r.w - 16) as f32,
1.0,
&Paint::fill(mt.border(pal)),
);
continue;
}
let active = (level.hover == Some(i) || child_spawn == Some(i)) && it.enabled;
if active {
canvas.fill_round_rect(
(r.x + 4) as f32,
(top + 1) as f32,
(r.w - 8) as f32,
(h - 2) as f32,
6.0,
&Paint::fill(mt.hover(pal)),
);
}
let color = if !it.enabled {
mt.text_disabled(pal)
} else if active || it.checked {
mt.accent(pal)
} else {
mt.text(pal)
};
if let Some(icon) = &it.icon {
let ir = Rect::new(r.x + MENU_PAD_X, top, MENU_ICON_W, h);
canvas.draw_text(
icon,
ir,
color,
crate::spec::Align::Center,
None,
MENU_FONT,
);
}
let lr = Rect::new(label_x, top, (r.right() - MENU_PAD_X - label_x).max(0), h);
canvas.draw_text(
&it.label,
lr,
color,
crate::spec::Align::Start,
None,
MENU_FONT,
);
let tr = Rect::new(r.x, top, r.w - MENU_PAD_X, h);
if !it.submenu.is_empty() {
canvas.draw_text(
"\u{203A}",
tr,
color,
crate::spec::Align::End,
None,
MENU_FONT + 1.0,
);
} else if let Some(s) = &it.shortcut {
canvas.draw_text(
s,
tr,
mt.text_disabled(pal),
crate::spec::Align::End,
None,
MENU_FONT - 2.0,
);
} else if it.checked {
canvas.draw_text(
"\u{2713}",
tr,
mt.accent(pal),
crate::spec::Align::End,
None,
MENU_FONT,
);
}
}
canvas.restore();
if level.content_h > r.h {
let track_h = (r.h - 8) as f32;
let ratio = r.h as f32 / level.content_h as f32;
let thumb_h = (track_h * ratio).max(20.0);
let max_sc = level.max_scroll().max(1) as f32;
let thumb_y =
(r.y + 4) as f32 + (track_h - thumb_h) * (level.scroll as f32 / max_sc);
canvas.fill_round_rect(
(r.right() - 8) as f32,
thumb_y,
5.0,
thumb_h,
2.5,
&Paint::fill(mt.border(pal)),
);
}
}
}
if self.show_fps {
let ms = frame_t0.elapsed().as_secs_f32() * 1000.0;
let fps = if ms > 0.01 { 1000.0 / ms } else { 999.0 };
let txt = format!("{ms:.1} ms ~{fps:.0} fps");
canvas.fill_round_rect(
4.0,
4.0,
132.0,
22.0,
4.0,
&Paint::fill(Color::rgba(0, 0, 0, 180)),
);
canvas.draw_text(
&txt,
Rect::new(10, 4, 126, 22),
Color::rgba(0, 255, 120, 255),
crate::spec::Align::Start,
None,
12.0,
);
}
drop(canvas);
if let Some(pixmap) = target.as_pixmap() {
self.seed_back(pixmap, size);
}
self.pending_damage = next_damage(&mut self.needs_full);
if crate::render::prof::enabled() {
eprintln!(
"[prof] full {:.2}ms {}",
frame_t0.elapsed().as_secs_f64() * 1000.0,
crate::render::prof::take_summary()
);
}
}
fn on_pointer(&mut self, mut ev: crate::event::PointerEvent) -> bool {
let s = self.scale;
ev.pos = Point::new(
(ev.pos.x as f32 / s).round() as i32,
(ev.pos.y as f32 / s).round() as i32,
);
if ev.kind == PointerKind::Down {
self.clear_fling();
}
if self.menu.is_some() {
return self.handle_menu_pointer(ev);
}
if !self.toasts.is_empty() && self.handle_toast_pointer(ev) {
return true;
}
match ev.kind {
PointerKind::Up if self.swallow_up => {
self.swallow_up = false;
return false;
}
PointerKind::Down => self.swallow_up = false,
_ => {}
}
let old_hover = self.hover;
let mut hover = self.hover;
let mut capture = self.capture;
let mut res = self.tree.dispatch_pointer(ev, &mut hover, &mut capture);
self.hover = hover;
self.capture = capture;
self.hover_pos = ev.pos;
let now_ms = self.start.elapsed().as_millis() as u64;
if hover != old_hover {
self.hover_since_ms = now_ms;
self.tooltip_suppressed = false;
let node_has_tooltip = |id: Option<NodeId>| {
id.is_some_and(|h| self.tree.get(h).is_some_and(|n| n.tooltip.is_some()))
};
if node_has_tooltip(old_hover) || node_has_tooltip(hover) {
res.repaint = true;
}
}
match ev.kind {
PointerKind::Down => self.tooltip_suppressed = true,
PointerKind::Move if self.tooltip_suppressed => {
self.tooltip_suppressed = false;
self.hover_since_ms = now_ms;
}
_ => {}
}
if let Some(f) = res.focus {
let old = self.focus;
self.tree.set_focused(Some(f), old);
self.focus = Some(f);
self.focus_visible = false;
}
if res.close {
self.close = true;
}
if let Some(req) = res.menu {
if let Some(target) = self.focus.or(self.tree.root) {
self.open_menu(req, target);
}
}
if let Some(url) = res.open_url {
platform::open_url(&url);
}
if res.window_op.is_some() {
self.pending_window_op = res.window_op;
}
if res.dialog.is_some() {
self.pending_dialog = res.dialog;
}
if let Some(req) = res.toast {
self.show_toast(req);
}
self.apply_damage(res.damage);
if !matches!(ev.kind, PointerKind::Move) {
self.needs_relayout = true;
}
res.repaint
}
fn on_key(&mut self, ev: crate::event::KeyEvent) -> bool {
if self.menu.is_some() {
if ev.key == Key::Escape {
self.menu = None;
}
return true;
}
if ev.key == Key::Tab {
self.focus_visible = true;
let moved = self.move_focus(!ev.shift);
if moved {
self.needs_full = true;
}
return moved;
}
let res = self.tree.dispatch_key(ev, self.focus);
if res.close {
self.close = true;
}
if let Some(url) = res.open_url {
platform::open_url(&url);
}
if res.window_op.is_some() {
self.pending_window_op = res.window_op;
}
if res.dialog.is_some() {
self.pending_dialog = res.dialog;
}
if let Some(req) = res.toast {
self.show_toast(req);
}
if !res.consumed && ev.key == Key::Escape {
if !close_topmost_modal() {
let allowed = self.close_handler.as_mut().map(|h| h()).unwrap_or(true);
if allowed {
self.close = true;
}
} else {
self.needs_full = true;
}
}
if res.repaint {
self.apply_damage(res.damage);
self.needs_relayout = true;
}
res.repaint
}
fn wants_close(&self) -> bool {
self.close
}
fn on_close_request(&mut self) -> bool {
if close_topmost_modal() {
self.needs_full = true;
return false;
}
self.close_handler.as_mut().map(|h| h()).unwrap_or(true)
}
fn capture_active(&self) -> bool {
self.capture.is_some()
}
fn set_scale(&mut self, scale: f32) {
self.needs_full = true;
self.scale = scale;
self.engine.set_scale(scale);
}
fn wants_animation(&self) -> bool {
crate::anim::animation_requested()
}
fn intervals(&self) -> Vec<std::time::Duration> {
self.interval_durs.clone()
}
fn on_interval_fired(&mut self, idx: usize) -> bool {
if let Some(cb) = self.interval_cbs.get_mut(idx) {
cb();
true
} else {
false
}
}
fn on_drop_files(&mut self, pos: Point, paths: Vec<std::path::PathBuf>) -> bool {
self.needs_full = true;
let s = self.scale;
let p = Point::new(
(pos.x as f32 / s).round() as i32,
(pos.y as f32 / s).round() as i32,
);
let res = self.tree.dispatch_files(p, paths);
if res.close {
self.close = true;
}
if let Some(url) = res.open_url {
platform::open_url(&url);
}
if let Some(req) = res.toast {
self.show_toast(req);
}
if res.dialog.is_some() {
self.pending_dialog = res.dialog;
}
res.repaint
}
fn window_drag_at(&self, pos: Point) -> bool {
if self.menu.is_some() {
return false;
}
let s = self.scale;
let p = Point::new(
(pos.x as f32 / s).round() as i32,
(pos.y as f32 / s).round() as i32,
);
self.tree.drag_hit_at(p)
}
fn interactive_at(&self, pos: Point) -> bool {
let s = self.scale;
let p = Point::new(
(pos.x as f32 / s).round() as i32,
(pos.y as f32 / s).round() as i32,
);
if let Some(menu) = &self.menu {
if menu.levels.iter().any(|l| l.rect.contains(p)) {
return true;
}
}
if self.toast_rects.iter().any(|(panel, _)| panel.contains(p)) {
return true;
}
self.tree.interactive_hit_at(p)
}
fn take_window_op(&mut self) -> Option<WindowOp> {
self.pending_window_op.take()
}
fn take_dialog_request(&mut self) -> Option<DialogRequest> {
self.pending_dialog.take()
}
fn cursor(&self) -> CursorShape {
if self.menu.is_some() {
return CursorShape::Arrow;
}
match self.hover {
Some(h) if self.tree.node_enabled(h) => self.tree.cursor_at(h),
_ => CursorShape::Arrow,
}
}
fn on_pan(&mut self, pos: Point, dy: i32) -> bool {
self.needs_full = true; if self.menu.is_some() {
self.pan_residual = 0.0;
return false;
}
let s = self.scale;
let p = Point::new(
(pos.x as f32 / s).round() as i32,
(pos.y as f32 / s).round() as i32,
);
let total = dy as f32 / s + self.pan_residual;
let dyl = total.trunc() as i32;
self.pan_residual = total - dyl as f32;
if dyl == 0 {
return false;
}
self.clear_fling();
self.tree.pan_scroll(p, dyl)
}
fn start_fling(&mut self, pos: Point, vy: f32) -> bool {
self.clear_fling();
if self.menu.is_some() {
return false;
}
if vy.abs() < FLING_TRIGGER {
return false;
}
let s = self.scale;
let p = Point::new(
(pos.x as f32 / s).round() as i32,
(pos.y as f32 / s).round() as i32,
);
let vel = -vy / s;
let Some(node) = self.tree.scroll_target(p, vel > 0.0) else {
return false;
};
self.fling = Some(Fling {
node,
phase: FlingPhase::Glide,
vel,
over: 0.0,
residual: 0.0,
last_ms: None,
});
crate::anim::request_repaint();
true
}
fn cancel_fling(&mut self) -> bool {
self.clear_fling()
}
fn ime_caret(&self) -> Option<(i32, i32, i32)> {
let focus = self.focus?;
let (p, h) = self.tree.caret_of(focus)?;
let s = self.scale;
Some((
(p.x as f32 * s).round() as i32,
(p.y as f32 * s).round() as i32,
((h as f32 * s).round() as i32).max(1),
))
}
fn set_ime_composing(&mut self, composing: bool) -> bool {
let Some(focus) = self.focus else {
return false;
};
self.tree.set_composing(focus, composing)
}
fn on_capture_lost(&mut self) -> bool {
self.needs_full = true;
if self.capture.is_none() {
return false;
}
let ev = PointerEvent::single(
PointerKind::Up,
Point::new(-1_000_000, -1_000_000),
MouseButton::Left,
);
let mut hover = self.hover;
let mut capture = self.capture;
let res = self.tree.dispatch_pointer(ev, &mut hover, &mut capture);
self.hover = hover;
self.capture = capture;
res.repaint
}
}
impl UiHost {
fn render_partial(&mut self, pixmap: &mut Pixmap, size: Size, s: f32, damage: Rect) {
let raw = damage
.inflate(DAMAGE_MARGIN)
.intersect(&Rect::from_size(self.logical_size));
const GRID: i32 = 4;
let x0 = raw.x - raw.x.rem_euclid(GRID);
let y0 = raw.y - raw.y.rem_euclid(GRID);
let x1 = raw.right() + (GRID - raw.right().rem_euclid(GRID)) % GRID;
let y1 = raw.bottom() + (GRID - raw.bottom().rem_euclid(GRID)) % GRID;
let dmg =
Rect::new(x0, y0, x1 - x0, y1 - y0).intersect(&Rect::from_size(self.logical_size));
let pdmg = dmg.scaled(s).intersect(&Rect::new(0, 0, size.w, size.h));
if pdmg.is_empty() {
self.blit_back_to(pixmap);
return;
}
let Some(mut sub) = Pixmap::new(pdmg.w as u32, pdmg.h as u32) else {
self.blit_back_to(pixmap);
return;
};
sub.fill(tiny_skia::Color::from_rgba8(
self.bg.r, self.bg.g, self.bg.b, self.bg.a,
));
{
let mut canvas = SkiaCanvas::with_text_offset(
&mut sub,
&mut self.engine,
s,
Point::new(dmg.x, dmg.y),
);
self.tree.paint(&mut canvas);
}
if let Some(back) = self.back.as_mut() {
blit(&sub, back, pdmg.x, pdmg.y);
}
self.blit_back_to(pixmap);
}
fn blit_back_to(&self, pixmap: &mut Pixmap) {
if let Some(back) = self.back.as_ref() {
if back.width() == pixmap.width() && back.height() == pixmap.height() {
pixmap.data_mut().copy_from_slice(back.data());
}
}
}
fn seed_back(&mut self, pixmap: &Pixmap, size: Size) {
let need_new = self
.back
.as_ref()
.map(|b| b.width() != size.w as u32 || b.height() != size.h as u32)
.unwrap_or(true);
if need_new {
self.back = Pixmap::new(size.w as u32, size.h as u32);
}
if let Some(back) = self.back.as_mut() {
back.data_mut().copy_from_slice(pixmap.data());
}
}
}
fn next_damage(needs_full: &mut bool) -> Option<Rect> {
match crate::anim::take_damage() {
crate::anim::Damage::Rect(r) => Some(r),
crate::anim::Damage::Full => {
*needs_full = true;
None
}
crate::anim::Damage::None => None,
}
}
fn blit(src: &Pixmap, dst: &mut Pixmap, x: i32, y: i32) {
let (sw, sh) = (src.width() as usize, src.height() as usize);
let (dw, dh) = (dst.width() as usize, dst.height() as usize);
let (x, y) = (x.max(0) as usize, y.max(0) as usize);
debug_assert!(
x + sw <= dw && y + sh <= dh,
"blit 越界:({x},{y})+{sw}x{sh} 超出 {dw}x{dh}"
);
let sd = src.data();
let dd = dst.data_mut();
for row in 0..sh {
let s0 = row * sw * 4;
let d0 = ((y + row) * dw + x) * 4;
dd[d0..d0 + sw * 4].copy_from_slice(&sd[s0..s0 + sw * 4]);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_returns_sendable_sender() {
let mut app = App::new("t", 100, 100);
let tx = app.channel::<u32>(|_| {});
let h = std::thread::spawn(move || tx.send(5));
assert!(h.join().unwrap().is_ok());
assert_eq!(app.pumps.len(), 1);
}
#[test]
fn on_interval_registers() {
let app = App::new("t", 100, 100).on_interval(std::time::Duration::from_millis(100), || {});
assert_eq!(app.intervals.len(), 1);
}
#[test]
fn toast_stack_caps_at_max_and_drops_oldest() {
let app = App::new("t", 100, 100).content(Element::col());
let mut app = app.into_handler_for_test();
for i in 0..(TOAST_MAX + 2) {
app.push_toast(ToastRequest {
text: format!("t{i}"),
kind: crate::event::ToastKind::Info,
duration_ms: 3000,
});
}
assert_eq!(app.toasts.len(), TOAST_MAX, "不超过上限");
assert_eq!(app.toasts.first().unwrap().req.text, "t2", "最旧两条被丢弃");
assert_eq!(
app.toasts.last().unwrap().req.text,
format!("t{}", TOAST_MAX + 1)
);
}
#[test]
fn toast_fade_curve_and_expiry() {
let t = ToastState {
req: ToastRequest {
text: "hi".into(),
kind: crate::event::ToastKind::Success,
duration_ms: 1000,
},
shown_at_ms: 100,
paused_at_ms: None,
paused_total_ms: 0,
};
assert_eq!(t.alpha(100), 0.0, "起点不可见");
let mid_in = t.alpha(100 + TOAST_FADE_IN_MS / 2);
assert!((0.4..=0.6).contains(&mid_in));
assert_eq!(t.alpha(100 + 500), 1.0);
assert!(!t.expired(100 + 999));
assert!(t.expired(100 + 1000));
}
#[test]
fn toast_hover_freezes_countdown() {
let mut t = ToastState {
req: ToastRequest {
text: "hi".into(),
kind: crate::event::ToastKind::Info,
duration_ms: 1000,
},
shown_at_ms: 0,
paused_at_ms: None,
paused_total_ms: 0,
};
t.set_hover(200, true);
assert!(!t.expired(5000), "悬停期间不过期");
assert_eq!(t.active_elapsed(5000), 200, "有效流逝冻结在 200");
t.set_hover(5000, false);
assert!(!t.expired(5000 + 799));
assert!(t.expired(5000 + 800));
}
#[test]
fn toast_hit_and_close_hit() {
use crate::geometry::Point;
let app = App::new("t", 400, 300).content(Element::col());
let mut app = app.into_handler_for_test();
app.toast_rects = vec![
(Rect::new(100, 16, 200, 44), Rect::new(280, 16, 22, 44)),
(Rect::new(100, 70, 200, 44), Rect::new(280, 70, 22, 44)),
];
assert_eq!(app.toast_hit(Point::new(150, 30)), Some(0));
assert_eq!(app.toast_hit(Point::new(150, 84)), Some(1));
assert_eq!(app.toast_hit(Point::new(10, 10)), None);
assert_eq!(app.toast_close_hit(Point::new(285, 30)), Some(0));
assert_eq!(
app.toast_close_hit(Point::new(150, 30)),
None,
"面板内非✕区不算关闭"
);
}
#[test]
fn modal_open_clears_stale_hover() {
use crate::event::{MouseButton, PointerEvent, PointerKind};
use crate::geometry::Point;
use crate::platform::AppHandler;
use crate::render::PixmapTarget;
use tiny_skia::Pixmap;
let show = crate::signal::signal(false);
let s2 = show;
let ui = Element::stack()
.fill()
.child(
Element::row()
.clickable()
.on_click(move |_| s2.set(true))
.width_match()
.height(60),
)
.child(Element::dialog(show, Element::leaf().width(40).height(40)));
let app = App::new("t", 100, 100).content(ui);
let mut handler = app.into_handler_for_test();
handler.set_scale(1.0);
let mut pm = Pixmap::new(100, 100).unwrap();
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(100, 100));
handler.on_pointer(PointerEvent::single(
PointerKind::Move,
Point::new(30, 30),
MouseButton::Left,
));
let row_hover = handler.hover;
assert!(row_hover.is_some(), "应 hover 到可点击行");
handler.on_pointer(PointerEvent::single(
PointerKind::Down,
Point::new(30, 30),
MouseButton::Left,
));
handler.on_pointer(PointerEvent::single(
PointerKind::Up,
Point::new(30, 30),
MouseButton::Left,
));
assert!(show.get(), "点击应打开模态");
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(100, 100));
assert_ne!(
handler.hover, row_hover,
"模态弹出后旧 hover 应被清掉,避免高亮残留"
);
}
#[test]
fn nested_modal_over_cell_clears_hover() {
use crate::event::{MouseButton, PointerEvent, PointerKind};
use crate::geometry::Point;
use crate::platform::AppHandler;
use crate::render::PixmapTarget;
use tiny_skia::Pixmap;
let show_a = crate::signal::signal(true);
let show_b = crate::signal::signal(false);
let sb = show_b;
let cell = Element::stack()
.clickable()
.on_click(move |_| sb.set(true))
.width(100)
.height(40);
let dialog_a =
Element::dialog(show_a, Element::scroll().width(200).height(200).child(cell));
let dialog_b = Element::dialog(show_b, Element::leaf().width(80).height(60));
let ui = Element::stack()
.fill()
.child(Element::col().fill())
.child(dialog_a)
.child(dialog_b);
let app = App::new("t", 300, 300).content(ui);
let mut handler = app.into_handler_for_test();
handler.set_scale(1.0);
let mut pm = Pixmap::new(300, 300).unwrap();
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(300, 300));
handler.on_pointer(PointerEvent::single(
PointerKind::Move,
Point::new(100, 70),
MouseButton::Left,
));
let cell_hover = handler.hover;
assert!(
cell_hover.is_some(),
"应 hover 到单元格,实得 {cell_hover:?}"
);
handler.on_pointer(PointerEvent::single(
PointerKind::Down,
Point::new(100, 70),
MouseButton::Left,
));
handler.on_pointer(PointerEvent::single(
PointerKind::Up,
Point::new(100, 70),
MouseButton::Left,
));
assert!(show_b.get(), "点单元格应打开对话框B");
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(300, 300));
assert_ne!(
handler.hover, cell_hover,
"对话框B弹出后,被遮住的单元格 hover 应被清掉"
);
}
#[test]
fn hiding_node_resets_its_interaction_state() {
use crate::core::Widget;
use crate::platform::AppHandler;
use crate::render::PixmapTarget;
use std::cell::Cell as StdCell;
use std::rc::Rc;
use tiny_skia::Pixmap;
struct ResetProbe(Rc<StdCell<u32>>);
impl Widget for ResetProbe {
fn reset_interaction(&mut self) {
self.0.set(self.0.get() + 1);
}
}
let hits = Rc::new(StdCell::new(0u32));
let show = crate::signal::signal(true);
let probe = hits.clone();
let ui = Element::stack().fill().child(Element::dialog(
show,
Element::leaf()
.width(20)
.height(20)
.widget(ResetProbe(probe)),
));
let app = App::new("t", 40, 40).content(ui);
let mut handler = app.into_handler_for_test();
handler.set_scale(1.0);
let mut pm = Pixmap::new(40, 40).unwrap();
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(40, 40));
show.set(false);
handler.needs_relayout = true;
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(40, 40));
assert!(
hits.get() >= 1,
"节点隐藏时应调用 reset_interaction 重置交互态"
);
}
#[test]
fn theme_handle_hot_swaps_into_host() {
use crate::platform::AppHandler;
use crate::render::PixmapTarget;
use tiny_skia::Pixmap;
let mut app = App::new("t", 60, 60).theme(crate::theme::Theme::default());
let handle = app.theme_handle();
app = app.content(Element::col());
let mut handler = app.into_handler_for_test();
handler.set_scale(1.0);
let mut pm = Pixmap::new(60, 60).unwrap();
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(60, 60));
let lum = |c: Color| c.r as u32 + c.g as u32 + c.b as u32;
assert!(lum(handler.theme.palette.bg) > 500, "初始亮色背景");
handle.set(crate::theme::Theme::dark());
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(60, 60));
assert!(
lum(handler.theme.palette.bg) < 300,
"热切换后 host 应共享句柄的暗色主题"
);
}
#[test]
fn interaction_takes_partial_path() {
use crate::platform::AppHandler;
use crate::render::PixmapTarget;
use tiny_skia::Pixmap;
let app = App::new("t", 60, 60).content(Element::col().width(60).height(60));
let mut handler = app.into_handler_for_test();
handler.set_scale(1.0);
let mut pm = Pixmap::new(60, 60).unwrap();
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(60, 60));
assert!(handler.last_frame_full, "首帧应为全窗");
handler.event_damage = Some(Rect::new(10, 10, 12, 12));
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(60, 60));
assert!(!handler.last_frame_full, "带小脏区的交互帧应走局部重绘");
}
#[test]
fn structural_click_repaints_full() {
use crate::event::{MouseButton, PointerEvent, PointerKind};
use crate::platform::AppHandler;
use crate::render::PixmapTarget;
use tiny_skia::Pixmap;
let flag = std::rc::Rc::new(std::cell::Cell::new(false));
let f2 = flag.clone();
let app = App::new("t", 80, 80).content(
Element::col()
.width(80)
.height(80)
.child(Element::button("X").on_click(move |_| f2.set(true)))
.child(
Element::col()
.width(80)
.height(30)
.visible_when(move || flag.get()),
),
);
let mut handler = app.into_handler_for_test();
handler.set_scale(1.0);
let mut pm = Pixmap::new(80, 80).unwrap();
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(80, 80)); let at = Point::new(15, 12);
handler.on_pointer(PointerEvent::single(
PointerKind::Down,
at,
MouseButton::Left,
));
handler.on_pointer(PointerEvent::single(PointerKind::Up, at, MouseButton::Left));
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(80, 80));
assert!(handler.last_frame_full, "切换 visible_when 面板应整窗刷新");
}
#[test]
fn local_click_stays_partial() {
use crate::event::{MouseButton, PointerEvent, PointerKind};
use crate::platform::AppHandler;
use crate::render::PixmapTarget;
use tiny_skia::Pixmap;
let app = App::new("t", 120, 120).content(
Element::col()
.width(120)
.height(120)
.child(Element::button("X")),
);
let mut handler = app.into_handler_for_test();
handler.set_scale(1.0);
let mut pm = Pixmap::new(120, 120).unwrap();
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(120, 120)); handler.on_pointer(PointerEvent::single(
PointerKind::Down,
Point::new(15, 12),
MouseButton::Left,
));
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(120, 120));
assert!(!handler.last_frame_full, "无结构变化的点击应走局部重绘");
}
#[test]
fn render_drains_pending_messages() {
use crate::platform::AppHandler;
use crate::render::PixmapTarget;
use tiny_skia::Pixmap;
let got = std::rc::Rc::new(std::cell::Cell::new(0u32));
let g2 = got.clone();
let mut app = App::new("t", 50, 50);
let tx = app.channel::<u32>(move |m| g2.set(m));
app = app.content(Element::col());
tx.send(7).unwrap();
let mut handler = app.into_handler_for_test();
handler.set_scale(1.0);
let mut pm = Pixmap::new(50, 50).unwrap();
handler.render(&mut PixmapTarget { pixmap: &mut pm }, Size::new(50, 50));
assert_eq!(got.get(), 7, "render 前排空 pump,消息写入状态");
}
}