use std::num::NonZeroUsize;
use std::path::Path;
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
use std::sync::Arc;
use web_time::{Duration, Instant};
#[cfg(target_arch = "wasm32")]
use std::cell::RefCell;
#[cfg(target_arch = "wasm32")]
use std::rc::Rc;
#[cfg(not(target_arch = "wasm32"))]
use notify::{EventKind, RecursiveMode, Watcher};
use rux_layout::{
Background, Cursor, FocusItem, FocusKind, FocusRegion, HitRegion,
Offset, Paint, PaintRect, PaintText, Rgba, ScrollRegion, SelectRegion, StateRegion, TextAlign,
TextContent, TextWrap,
};
use rux_runtime::{Document, Focus, InteractionState, Viewport};
use vello::kurbo::Affine;
use vello::peniko::Color;
use vello::util::{RenderContext, RenderSurface};
use vello::wgpu;
use vello::wgpu::CurrentSurfaceTexture;
use vello::{AaConfig, AaSupport, Renderer, RendererOptions, RenderParams, Scene};
#[cfg(not(target_arch = "wasm32"))]
use accesskit::{Node as AccessKitNode, NodeId, Role, Toggled, Tree, TreeUpdate};
#[cfg(not(target_arch = "wasm32"))]
use rux_layout::{AccessNode, AccessRole};
use winit::application::ApplicationHandler;
use winit::event::{ElementState, Ime, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{Key, NamedKey};
use winit::window::{CursorIcon, Window, WindowId};
#[derive(Debug)]
enum RuxEvent {
#[cfg(not(target_arch = "wasm32"))]
Reload,
#[cfg(target_arch = "wasm32")]
SurfaceReady,
#[cfg(target_arch = "wasm32")]
SetSource(String),
#[cfg(target_arch = "wasm32")]
Resize(f64, f64),
#[cfg(target_arch = "wasm32")]
WebText { value: String, caret: usize, anchor: usize, composing: usize },
#[cfg(target_arch = "wasm32")]
WebPaste(String),
#[cfg(target_arch = "wasm32")]
WebRoute(Option<usize>),
#[cfg(not(target_arch = "wasm32"))]
Access(accesskit_winit::Event),
}
#[cfg(not(target_arch = "wasm32"))]
impl From<accesskit_winit::Event> for RuxEvent {
fn from(event: accesskit_winit::Event) -> Self {
Self::Access(event)
}
}
const TAP_SLOP: f64 = 6.0;
const LONG_PRESS: Duration = Duration::from_millis(500);
const TOOLBAR_H: f32 = 34.0;
const TOOLBAR_PAD: f32 = 12.0;
const TOOLBAR_GAP: f32 = 6.0;
#[derive(Clone, Copy, Debug, PartialEq)]
enum TextAction {
Copy,
Cut,
Paste,
SelectAll,
}
impl TextAction {
const ALL: [TextAction; 4] =
[TextAction::Copy, TextAction::Cut, TextAction::Paste, TextAction::SelectAll];
fn label(self) -> &'static str {
match self {
TextAction::Copy => "Copy",
TextAction::Cut => "Cut",
TextAction::Paste => "Paste",
TextAction::SelectAll => "Select all",
}
}
fn width(self) -> f32 {
(self.label().chars().count() as f32 * 7.8).round() + TOOLBAR_PAD * 2.0
}
}
fn toolbar_layout(
field: (f32, f32, f32, f32),
viewport: (f32, f32),
) -> ((f32, f32, f32, f32), Vec<(TextAction, f32, f32, f32, f32)>) {
let total: f32 = TextAction::ALL.iter().map(|a| a.width()).sum();
let (fx, fy, _, fh) = field;
let x = fx.min(viewport.0 - total).max(0.0);
let above = fy - TOOLBAR_H - TOOLBAR_GAP;
let y = if above >= 0.0 { above } else { fy + fh + TOOLBAR_GAP };
let mut buttons = Vec::with_capacity(TextAction::ALL.len());
let mut bx = x;
for action in TextAction::ALL {
let w = action.width();
buttons.push((action, bx, y, w, TOOLBAR_H));
bx += w;
}
((x, y, total, TOOLBAR_H), buttons)
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum TouchText {
Pending { at: (f64, f64), deadline: Instant },
Caret,
Selecting,
}
fn touch_text_after_move(state: TouchText, distance: f64) -> TouchText {
match state {
TouchText::Pending { .. } if distance > TAP_SLOP => TouchText::Caret,
other => other,
}
}
const BLINK: Duration = Duration::from_millis(530);
const DOUBLE_CLICK: Duration = Duration::from_millis(500);
const BG: Color = Color::from_rgb8(0x11, 0x11, 0x1b);
const DROPDOWN_ROW_H: f32 = 30.0;
const DROPDOWN_GAP: f32 = 4.0;
fn dropdown_row(sel: &SelectRegion, i: usize) -> (f32, f32, f32, f32) {
(
sel.x,
sel.y + sel.height + DROPDOWN_GAP + i as f32 * DROPDOWN_ROW_H,
sel.width,
DROPDOWN_ROW_H,
)
}
const BAR_W: f32 = 8.0;
const BAR_MIN_THUMB: f32 = 24.0;
const LINE: f32 = 24.0;
#[derive(Clone, Copy, Debug, PartialEq)]
enum Axis2 {
X,
Y,
}
#[derive(Clone, Copy, Debug)]
struct BarDrag {
id: usize,
axis: Axis2,
grab: f32,
start: f32,
}
fn bar_track(r: &ScrollRegion, axis: Axis2) -> (f32, f32, f32, f32) {
let corner = if r.max.x > 0.0 && r.max.y > 0.0 { BAR_W } else { 0.0 };
match axis {
Axis2::Y => (r.x + r.width - BAR_W, r.y, BAR_W, r.height - corner),
Axis2::X => (r.x, r.y + r.height - BAR_W, r.width - corner, BAR_W),
}
}
fn bar_thumb(r: &ScrollRegion, offset: Offset, axis: Axis2) -> Option<(f32, f32, f32, f32)> {
let (max, visible, content) = match axis {
Axis2::Y => (r.max.y, r.height, r.content_height),
Axis2::X => (r.max.x, r.width, r.content_width),
};
if max <= 0.0 {
return None;
}
let (tx, ty, tw, th) = bar_track(r, axis);
let track_len = if axis == Axis2::Y { th } else { tw };
let thumb_len = (track_len * visible / content.max(1.0)).clamp(BAR_MIN_THUMB.min(track_len), track_len);
let travel = (track_len - thumb_len).max(0.0);
let pos = match axis {
Axis2::Y => offset.y,
Axis2::X => offset.x,
};
let along = travel * (pos / max).clamp(0.0, 1.0);
Some(match axis {
Axis2::Y => (tx, ty + along, tw, thumb_len),
Axis2::X => (tx + along, ty, thumb_len, th),
})
}
fn scrollbar_paints(scrolls: &[ScrollRegion], offsets: &[Offset]) -> Vec<Paint> {
let track_bg = Rgba::new(1.0, 1.0, 1.0, 0.05);
let thumb_bg = Rgba::new(0.80, 0.84, 0.96, 0.35); let mut out = Vec::new();
for r in scrolls {
let offset = offsets.get(r.id).copied().unwrap_or_default();
for axis in [Axis2::Y, Axis2::X] {
let Some((thx, thy, thw, thh)) = bar_thumb(r, offset, axis) else {
continue;
};
let (tx, ty, tw, th) = bar_track(r, axis);
out.push(Paint::Rect(PaintRect {
x: tx,
y: ty,
width: tw,
height: th,
background: Some(Background::Color(track_bg)),
radius: [BAR_W / 2.0; 4],
border_width: 0.0,
border_color: None,
}));
out.push(Paint::Rect(PaintRect {
x: thx,
y: thy,
width: thw,
height: thh,
background: Some(Background::Color(thumb_bg)),
radius: [BAR_W / 2.0; 4],
border_width: 0.0,
border_color: None,
}));
}
}
out
}
fn focus_ring(item: &FocusItem, within: Option<&ScrollRegion>) -> Vec<Paint> {
let ring = Paint::Rect(PaintRect {
x: item.x - 2.0,
y: item.y - 2.0,
width: item.width + 4.0,
height: item.height + 4.0,
background: None,
radius: [7.0; 4],
border_width: 2.0,
border_color: Some(Rgba::new(0.54, 0.71, 0.98, 1.0)), });
let Some(r) = within else { return vec![ring] };
if item.y + item.height < r.y || item.y > r.y + r.height {
return Vec::new();
}
vec![
Paint::PushClip { x: r.x - 2.0, y: r.y - 2.0, width: r.width + 4.0, height: r.height + 4.0, radius: [0.0; 4] },
ring,
Paint::PopClip,
]
}
fn toolbar_paints(field: (f32, f32, f32, f32), viewport: (f32, f32)) -> Vec<Paint> {
let panel_bg = Rgba::new(0.19, 0.20, 0.27, 1.0); let border = Rgba::new(0.27, 0.28, 0.35, 1.0); let ink = Rgba::new(0.80, 0.84, 0.96, 1.0); let divider = Rgba::new(0.35, 0.36, 0.44, 1.0);
let ((x, y, w, h), buttons) = toolbar_layout(field, viewport);
let mut out = Vec::with_capacity(buttons.len() * 2 + 2);
out.push(Paint::Shadow {
x,
y: y + 3.0,
width: w,
height: h,
radius: 8.0,
blur: 16.0,
color: Rgba::new(0.0, 0.0, 0.0, 0.45),
});
out.push(Paint::Rect(PaintRect {
x,
y,
width: w,
height: h,
background: Some(Background::Color(panel_bg)),
radius: [8.0; 4],
border_width: 1.0,
border_color: Some(border),
}));
for (i, (action, bx, by, bw, bh)) in buttons.iter().enumerate() {
if i > 0 {
out.push(Paint::Rect(PaintRect {
x: *bx,
y: by + 7.0,
width: 1.0,
height: bh - 14.0,
background: Some(Background::Color(divider)),
radius: [0.0; 4],
border_width: 0.0,
border_color: None,
}));
}
out.push(Paint::Text(PaintText {
x: *bx,
y: by + (bh - 17.0) / 2.0,
width: *bw,
height: 17.0,
content: TextContent {
align: TextAlign::Center,
..overlay_text(action.label().to_string(), 14.0, 500, ink)
},
}));
}
out
}
fn dropdown_paints(sel: &SelectRegion, value: &str) -> Vec<Paint> {
let panel_bg = Rgba::new(0.19, 0.20, 0.27, 1.0); let border = Rgba::new(0.27, 0.28, 0.35, 1.0); let selected = Rgba::new(0.35, 0.36, 0.44, 1.0); let ink = Rgba::new(0.80, 0.84, 0.96, 1.0);
let (px, py, pw, _) = dropdown_row(sel, 0);
let ph = sel.options.len() as f32 * DROPDOWN_ROW_H;
let mut out = Vec::with_capacity(sel.options.len() * 2 + 2);
out.push(Paint::Shadow {
x: px,
y: py + 3.0,
width: pw,
height: ph,
radius: 8.0,
blur: 16.0,
color: Rgba::new(0.0, 0.0, 0.0, 0.45),
});
out.push(Paint::Rect(PaintRect {
x: px,
y: py,
width: pw,
height: ph,
background: Some(Background::Color(panel_bg)),
radius: [8.0; 4],
border_width: 1.0,
border_color: Some(border),
}));
for (i, option) in sel.options.iter().enumerate() {
let y = py + i as f32 * DROPDOWN_ROW_H;
if option == value {
out.push(Paint::Rect(PaintRect {
x: px + 4.0,
y: y + 3.0,
width: pw - 8.0,
height: DROPDOWN_ROW_H - 6.0,
background: Some(Background::Color(selected)),
radius: [5.0; 4],
border_width: 0.0,
border_color: None,
}));
} else if i > 0 {
out.push(Paint::Rect(PaintRect {
x: px + 10.0,
y,
width: pw - 20.0,
height: 1.0,
background: Some(Background::Color(border)),
radius: [0.0; 4],
border_width: 0.0,
border_color: None,
}));
}
out.push(Paint::Text(PaintText {
x: px + 12.0,
y: y + (DROPDOWN_ROW_H - 15.0) / 2.0,
width: pw - 24.0,
height: DROPDOWN_ROW_H,
content: TextContent {
text: option.clone(),
font_size: 15.0,
weight: 400,
color: ink,
align: TextAlign::Start,
wrap: TextWrap::Normal,
font_family: None,
letter_spacing: None,
word_spacing: None,
line_height: None,
italic: false,
underline: false,
strikethrough: false,
nowrap: true,
caret: None,
selection: None,
preedit: None,
},
}));
}
out
}
#[cfg(not(target_arch = "wasm32"))]
const ACCESS_ROOT: NodeId = NodeId(0);
#[cfg(not(target_arch = "wasm32"))]
fn to_accesskit_role(role: AccessRole) -> Role {
match role {
AccessRole::Label => Role::Label,
AccessRole::Heading => Role::Heading,
AccessRole::Button => Role::Button,
AccessRole::CheckBox => Role::CheckBox,
AccessRole::RadioButton => Role::RadioButton,
AccessRole::TextInput => Role::TextInput,
AccessRole::MultilineTextInput => Role::MultilineTextInput,
AccessRole::ComboBox => Role::ComboBox,
AccessRole::Image => Role::Image,
AccessRole::Link => Role::Link,
AccessRole::ScrollView => Role::ScrollView,
AccessRole::Group | AccessRole::None => Role::Group,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn access_tree(nodes: &[AccessNode], focused_model: Option<&str>, scale: f64, title: &str) -> TreeUpdate {
let mut root = AccessKitNode::new(Role::Window);
root.set_label(title.to_string());
let mut updates = Vec::with_capacity(nodes.len() + 1);
let mut children = Vec::with_capacity(nodes.len());
let mut focus = ACCESS_ROOT;
for (i, node) in nodes.iter().enumerate() {
let id = NodeId(i as u64 + 1);
children.push(id);
let mut ak = AccessKitNode::new(to_accesskit_role(node.access.role));
if let Some(label) = node.access.name() {
if node.access.role == AccessRole::Label {
ak.set_value(label.to_string());
} else {
ak.set_label(label.to_string());
}
}
if let Some(value) = &node.access.value {
ak.set_value(value.clone());
}
if let Some(checked) = node.access.checked {
ak.set_toggled(if checked { Toggled::True } else { Toggled::False });
}
ak.set_bounds(accesskit::Rect {
x0: node.x as f64 * scale,
y0: node.y as f64 * scale,
x1: (node.x + node.width) as f64 * scale,
y1: (node.y + node.height) as f64 * scale,
});
if matches!(
node.access.role,
AccessRole::Button
| AccessRole::Link
| AccessRole::CheckBox
| AccessRole::RadioButton
| AccessRole::TextInput
| AccessRole::MultilineTextInput
| AccessRole::ComboBox
) {
ak.add_action(accesskit::Action::Focus);
ak.add_action(accesskit::Action::Click);
}
if let (Some(model), Some(focused)) = (&node.model, focused_model) {
if model == focused {
focus = id;
}
}
updates.push((id, ak));
}
root.set_children(children);
let mut tree = Tree::new(ACCESS_ROOT);
tree.toolkit_name = Some("Rux".into());
tree.toolkit_version = Some(env!("CARGO_PKG_VERSION").into());
let mut tree_update = TreeUpdate {
nodes: vec![(ACCESS_ROOT, root)],
tree: Some(tree),
tree_id: accesskit::TreeId::ROOT,
focus,
};
tree_update.nodes.extend(updates);
tree_update
}
const OVERLAY_PAD: f32 = 16.0;
const OVERLAY_LINE_H: f32 = 20.0;
const OVERLAY_TITLE_H: f32 = 26.0;
const OVERLAY_MAX_WARNINGS: usize = 6;
struct Overlay {
paints: Vec<Paint>,
rect: (f32, f32, f32, f32),
}
fn overlay_paints(diag: &rux_runtime::Diagnostics, path: &Path, width: f32) -> Option<Overlay> {
if diag.is_empty() {
return None;
}
let error_bg = Rgba::new(0.24, 0.09, 0.13, 0.97); let error_edge = Rgba::new(0.95, 0.35, 0.42, 1.0); let warn_bg = Rgba::new(0.20, 0.17, 0.10, 0.97); let warn_edge = Rgba::new(0.98, 0.70, 0.35, 1.0); let ink = Rgba::new(0.95, 0.95, 0.97, 1.0);
let muted = Rgba::new(0.78, 0.78, 0.84, 1.0);
let is_error = diag.error.is_some();
let (bg, edge) = if is_error { (error_bg, error_edge) } else { (warn_bg, warn_edge) };
let panel_w = (width - OVERLAY_PAD * 2.0).max(120.0);
let text_w = panel_w - OVERLAY_PAD * 2.0;
let mut lines: Vec<(String, Rgba)> = Vec::new();
if let Some(error) = &diag.error {
lines.extend(wrap_overlay(error, text_w).into_iter().map(|l| (l, ink)));
if diag.stale {
lines.push((
"showing the last version that loaded, fix the file and save".to_string(),
muted,
));
}
}
let shown = diag.warnings.len().min(OVERLAY_MAX_WARNINGS);
for warning in &diag.warnings[..shown] {
lines.extend(
wrap_overlay(&format!("• {warning}"), text_w)
.into_iter()
.map(|l| (l, if is_error { muted } else { ink })),
);
}
if diag.warnings.len() > shown {
lines.push((
format!("… and {} more (full list on stderr)", diag.warnings.len() - shown),
muted,
));
}
let title = match (&diag.error, diag.warnings.len()) {
(Some(_), 0) => format!("rux: {} failed to load", file_name(path)),
(Some(_), n) => format!("rux: {} failed to load · {n} warning(s)", file_name(path)),
(None, n) => format!("rux: {n} warning(s) in {}", file_name(path)),
};
lines.push(("tap this panel to dismiss it".to_string(), muted));
let panel_h = OVERLAY_TITLE_H + lines.len() as f32 * OVERLAY_LINE_H + OVERLAY_PAD * 1.5;
let x = OVERLAY_PAD;
let y = OVERLAY_PAD;
let mut out = Vec::with_capacity(lines.len() + 3);
out.push(Paint::Shadow {
x,
y: y + 3.0,
width: panel_w,
height: panel_h,
radius: 10.0,
blur: 20.0,
color: Rgba::new(0.0, 0.0, 0.0, 0.5),
});
out.push(Paint::Rect(PaintRect {
x,
y,
width: panel_w,
height: panel_h,
background: Some(Background::Color(bg)),
radius: [10.0; 4],
border_width: 2.0,
border_color: Some(edge),
}));
out.push(Paint::Text(PaintText {
x: x + OVERLAY_PAD,
y: y + OVERLAY_PAD * 0.6,
width: text_w,
height: OVERLAY_TITLE_H,
content: overlay_text(title, 15.0, 700, edge),
}));
for (i, (line, color)) in lines.into_iter().enumerate() {
out.push(Paint::Text(PaintText {
x: x + OVERLAY_PAD,
y: y + OVERLAY_TITLE_H + OVERLAY_PAD * 0.4 + i as f32 * OVERLAY_LINE_H,
width: text_w,
height: OVERLAY_LINE_H,
content: overlay_text(line, 14.0, 400, color),
}));
}
Some(Overlay { paints: out, rect: (x, y, panel_w, panel_h) })
}
fn overlay_visible(
diag: &rux_runtime::Diagnostics,
dismissed: Option<&rux_runtime::Diagnostics>,
) -> bool {
!diag.is_empty() && dismissed != Some(diag)
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
fn wrap_overlay(text: &str, width: f32) -> Vec<String> {
let max_chars = ((width / 7.3) as usize).max(20);
let mut lines = Vec::new();
for paragraph in text.split('\n') {
let mut line = String::new();
for word in paragraph.split_whitespace() {
if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > max_chars {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
lines.push(line);
}
lines
}
fn overlay_text(text: String, font_size: f32, weight: u16, color: Rgba) -> TextContent {
TextContent {
text,
font_size,
weight,
color,
align: TextAlign::Start,
wrap: TextWrap::Normal,
font_family: None,
letter_spacing: None,
word_spacing: None,
line_height: None,
italic: false,
underline: false,
strikethrough: false,
nowrap: true,
caret: None,
selection: None,
preedit: None,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn load_document(path: &PathBuf) -> Document {
match Document::load(path) {
Ok(doc) => doc,
Err(err) => {
eprintln!("rux: failed to load {}: {err}", path.display());
let mut doc = Document::from_source("<template><screen></screen></template>")
.expect("empty document");
doc.set_load_error(err);
doc.clear_stale();
doc
}
}
}
struct RenderState {
window: Arc<Window>,
surface: RenderSurface<'static>,
renderer: Renderer,
scene: Scene,
#[cfg(not(target_arch = "wasm32"))]
access: accesskit_winit::Adapter,
}
#[derive(Clone, Debug)]
struct Preedit {
at: usize,
len: usize,
replaced: String,
}
#[cfg(target_arch = "wasm32")]
type Pending = Rc<RefCell<Option<(RenderContext, RenderState)>>>;
struct App {
context: RenderContext,
state: Option<RenderState>,
#[cfg(not(target_arch = "wasm32"))]
proxy: winit::event_loop::EventLoopProxy<RuxEvent>,
#[cfg(target_arch = "wasm32")]
pending: Pending,
#[cfg(target_arch = "wasm32")]
starting: bool,
#[cfg(not(target_arch = "wasm32"))]
path: PathBuf,
document: Document,
text: rux_text::TextEngine,
images: rux_paint::ImageCache,
hits: Vec<HitRegion>,
focuses: Vec<FocusRegion>,
selects: Vec<SelectRegion>,
focusables: Vec<FocusItem>,
focus_index: Option<usize>,
shift_held: bool,
ctrl_held: bool,
alt_held: bool,
scrolls: Vec<ScrollRegion>,
states: Vec<StateRegion>,
offsets: Vec<Offset>,
bar_drag: Option<BarDrag>,
touch: Option<(f32, f32)>,
focused: Option<String>,
focused_row: Option<String>,
focused_multiline: bool,
open_select: Option<(String, Option<String>)>,
caret: usize,
anchor: usize,
overlay_dismissed: Option<rux_runtime::Diagnostics>,
overlay_rect: Option<(f32, f32, f32, f32)>,
preedit: Option<Preedit>,
text_drag: bool,
touch_text: Option<TouchText>,
text_scroll: f32,
last_click: Option<(Instant, f64, f64)>,
#[cfg(not(target_arch = "wasm32"))]
clipboard: Option<arboard::Clipboard>,
caret_visible: bool,
blink_deadline: Option<Instant>,
pointer: (f64, f64),
press: Option<(f64, f64)>,
cursor: CursorIcon,
#[cfg(target_arch = "wasm32")]
mirrored: Option<(usize, String)>,
}
impl App {
fn new(
#[cfg(not(target_arch = "wasm32"))] path: PathBuf,
#[cfg(not(target_arch = "wasm32"))] proxy: winit::event_loop::EventLoopProxy<RuxEvent>,
#[cfg(target_arch = "wasm32")] document: Document,
) -> Self {
#[cfg(not(target_arch = "wasm32"))]
let document = load_document(&path);
Self {
context: RenderContext::new(),
state: None,
#[cfg(not(target_arch = "wasm32"))]
proxy,
#[cfg(target_arch = "wasm32")]
pending: Rc::new(RefCell::new(None)),
#[cfg(target_arch = "wasm32")]
starting: false,
#[cfg(not(target_arch = "wasm32"))]
path,
document,
text: rux_text::TextEngine::new(),
images: rux_paint::ImageCache::new(),
hits: Vec::new(),
focuses: Vec::new(),
selects: Vec::new(),
focusables: Vec::new(),
focus_index: None,
shift_held: false,
ctrl_held: false,
alt_held: false,
scrolls: Vec::new(),
offsets: Vec::new(),
bar_drag: None,
touch: None,
focused: None,
focused_row: None,
focused_multiline: false,
open_select: None,
caret: 0,
anchor: 0,
overlay_dismissed: None,
overlay_rect: None,
preedit: None,
text_drag: false,
touch_text: None,
text_scroll: 0.0,
last_click: None,
#[cfg(not(target_arch = "wasm32"))]
clipboard: arboard::Clipboard::new()
.map_err(|e| eprintln!("rux: no clipboard ({e}), so copy/paste is disabled"))
.ok(),
caret_visible: true,
blink_deadline: None,
pointer: (0.0, 0.0),
press: None,
cursor: CursorIcon::Default,
states: Vec::new(),
#[cfg(target_arch = "wasm32")]
mirrored: None,
}
}
#[cfg(target_arch = "wasm32")]
fn sync_url(&mut self) {
if WEB_BASE.with(|b| b.borrow().is_none()) {
return;
}
let (index, _) = self.document.history_position();
let route = self.document.location().to_string();
let Some((was, ref was_route)) = self.mirrored else {
web_write_history(index, &route, true);
self.mirrored = Some((index, route));
return;
};
if was == index {
if *was_route != route {
web_write_history(index, &route, true);
self.mirrored = Some((index, route));
}
return;
}
if index > was {
web_write_history(index, &route, false);
} else if let Some(window) = web_sys::window() {
if let Ok(history) = window.history() {
let _ = history.go_with_delta(index as i32 - was as i32);
}
}
self.mirrored = Some((index, route));
}
#[cfg(target_arch = "wasm32")]
fn apply_web_route(&mut self, index: Option<usize>) {
let moved = match index {
Some(index) => self.document.go_to(index),
None => match web_route_now() {
Some(route) => self.document.start_at(&route),
None => false,
},
};
self.mirrored =
Some((self.document.history_position().0, self.document.location().to_string()));
if moved {
self.request_redraw();
}
}
#[cfg(not(target_arch = "wasm32"))]
fn reload(&mut self) {
match Document::load(&self.path) {
Ok(doc) => {
let was = self.document.location().to_string();
self.document.replace_with(doc);
if was != rux_runtime::ROOT_PATH {
self.document.start_at(&was);
}
eprintln!("reloaded {}", self.path.display());
}
Err(err) => {
eprintln!("rux: reload failed for {}: {err}", self.path.display());
self.document.set_load_error(err);
}
}
}
#[cfg(target_arch = "wasm32")]
fn set_source(&mut self, source: String) {
match Document::from_source(&source) {
Ok(doc) => {
self.document = doc;
self.focused = None;
self.focus_index = None;
self.open_select = None;
}
Err(err) => web_sys::console::error_1(&format!("rux: {err}").into()),
}
}
fn scale(&self) -> f64 {
self.state
.as_ref()
.map(|s| s.window.scale_factor())
.unwrap_or(1.0)
}
fn logical(&self, p: (f64, f64)) -> (f32, f32) {
let scale = self.scale();
((p.0 / scale) as f32, (p.1 / scale) as f32)
}
fn scroll_at(&mut self, pointer: (f64, f64), dx: f32, dy: f32) {
let (px, py) = self.logical(pointer);
let Some(region) = self
.scrolls
.iter()
.rev()
.find(|s| s.contains(px, py) && s.scrollable())
else {
return;
};
let (id, max) = (region.id, region.max);
self.scroll_to(
id,
Offset {
x: self.offsets[id].x + dx,
y: self.offsets[id].y + dy,
}
.clamp_to(max),
);
}
fn scroll_to(&mut self, id: usize, next: Offset) {
if self.offsets.get(id) != Some(&next) {
if let Some(slot) = self.offsets.get_mut(id) {
*slot = next;
self.request_redraw();
}
}
}
fn press_scrollbar(&mut self, pointer: (f64, f64)) -> bool {
let (px, py) = self.logical(pointer);
for r in self.scrolls.iter().rev() {
let offset = self.offsets.get(r.id).copied().unwrap_or_default();
for axis in [Axis2::Y, Axis2::X] {
let Some((tx, ty, tw, th)) = bar_thumb(r, offset, axis) else {
continue;
};
if px >= tx && px <= tx + tw && py >= ty && py <= ty + th {
self.bar_drag = Some(BarDrag {
id: r.id,
axis,
grab: if axis == Axis2::Y { py } else { px },
start: if axis == Axis2::Y { offset.y } else { offset.x },
});
return true;
}
}
}
false
}
fn drag_scrollbar(&mut self, pointer: (f64, f64)) {
let Some(drag) = self.bar_drag else { return };
let Some(r) = self.scrolls.iter().find(|s| s.id == drag.id).cloned() else {
return;
};
let Some((_, _, tw, th)) = bar_thumb(&r, self.offsets[drag.id], drag.axis) else {
return;
};
let (_, _, track_w, track_h) = bar_track(&r, drag.axis);
let (px, py) = self.logical(pointer);
let (pos, track_len, thumb_len, max) = match drag.axis {
Axis2::Y => (py, track_h, th, r.max.y),
Axis2::X => (px, track_w, tw, r.max.x),
};
let travel = (track_len - thumb_len).max(0.0);
if travel <= 0.0 {
return;
}
let moved = drag.start + (pos - drag.grab) * max / travel;
let next = match drag.axis {
Axis2::Y => Offset { x: self.offsets[drag.id].x, y: moved },
Axis2::X => Offset { x: moved, y: self.offsets[drag.id].y },
};
self.scroll_to(drag.id, next.clamp_to(r.max));
}
fn scroll_key(&mut self, key: &Key) -> bool {
let (px, py) = self.logical(self.pointer);
let Some(r) = self
.scrolls
.iter()
.rev()
.find(|s| s.contains(px, py) && s.scrollable())
.cloned()
else {
return false;
};
let page = (r.height * 0.9).max(LINE);
let here = self.offsets[r.id];
let next = match key {
Key::Named(NamedKey::ArrowDown) => Offset { y: here.y + LINE, ..here },
Key::Named(NamedKey::ArrowUp) => Offset { y: here.y - LINE, ..here },
Key::Named(NamedKey::ArrowRight) => Offset { x: here.x + LINE, ..here },
Key::Named(NamedKey::ArrowLeft) => Offset { x: here.x - LINE, ..here },
Key::Named(NamedKey::PageDown) => Offset { y: here.y + page, ..here },
Key::Named(NamedKey::PageUp) => Offset { y: here.y - page, ..here },
Key::Named(NamedKey::Home) => Offset { y: 0.0, ..here },
Key::Named(NamedKey::End) => Offset { y: r.max.y, ..here },
_ => return false,
};
self.scroll_to(r.id, next.clamp_to(r.max));
true
}
fn scroll_focus_into_view(&mut self) {
let Some(item) = self.focus_index.and_then(|i| self.focusables.get(i)).cloned() else {
return;
};
for r in self.scrolls.clone() {
if !r.scrollable() {
continue;
}
if item.x + item.width < r.x || item.x > r.x + r.width {
continue;
}
let here = self.offsets[r.id];
let mut next = here;
if item.y < r.y {
next.y = here.y - (r.y - item.y);
} else if item.y + item.height > r.y + r.height {
next.y = here.y + (item.y + item.height - (r.y + r.height));
}
if item.x < r.x {
next.x = here.x - (r.x - item.x);
} else if item.x + item.width > r.x + r.width {
next.x = here.x + (item.x + item.width - (r.x + r.width));
}
self.scroll_to(r.id, next.clamp_to(r.max));
}
}
fn index_in(&mut self, region: &FocusRegion, px: f32, py: f32) -> usize {
let value = self.document.value_in(®ion.model, region.row.as_deref());
match region.text.as_ref() {
Some(t) if !value.is_empty() => {
let (tx, ty) = self.text_point(region, t, px, py);
self.text.index_at_point(
&value,
&rux_paint::text_style(&t.content),
Some(t.width),
tx,
ty,
)
}
_ => 0,
}
}
fn text_point(
&self,
region: &FocusRegion,
t: &rux_layout::PaintText,
px: f32,
py: f32,
) -> (f32, f32) {
(px - t.x + self.text_scroll_for(region), py - t.y)
}
fn track_caret_x(
layout: &rux_layout::Layout,
focused: Option<&str>,
focused_row: Option<&str>,
caret: usize,
scroll: &mut f32,
text: &mut rux_text::TextEngine,
document: &mut rux_runtime::Document,
) -> f32 {
let Some(model) = focused else {
*scroll = 0.0;
return 0.0;
};
let Some(region) = layout
.focuses
.iter()
.find(|f| f.model == model && f.row.as_deref() == focused_row)
else {
return *scroll;
};
let (false, Some(t)) = (region.multiline, region.text.as_ref()) else {
*scroll = 0.0;
return 0.0;
};
let value = document.value_in(model, focused_row);
let style = rux_paint::text_style(&t.content);
let (cx, _, _) = text.caret_geometry(&value, &style, Some(t.width), caret.min(value.len()));
let inset = (t.x - region.x).max(0.0);
let visible = (region.width - inset * 2.0).max(1.0);
if cx < *scroll {
*scroll = cx;
} else if cx > *scroll + visible {
*scroll = cx - visible;
}
let full = text.measure(&value, &style, None).0;
*scroll = scroll.clamp(0.0, (full - visible).max(0.0));
*scroll
}
fn toolbar_field(&self) -> Option<(f32, f32, f32, f32)> {
if self.caret == self.anchor {
return None;
}
let region = self.focused_region()?;
Some((region.x, region.y, region.width, region.height))
}
fn toolbar_action_at(&self, fx: f32, fy: f32) -> Option<TextAction> {
let field = self.toolbar_field()?;
let (_, buttons) = toolbar_layout(field, self.logical_size());
buttons
.into_iter()
.find(|(_, bx, by, bw, bh)| fx >= *bx && fx <= bx + bw && fy >= *by && fy <= by + bh)
.map(|(action, ..)| action)
}
fn toolbar_covers(&self, fx: f32, fy: f32) -> bool {
let Some(field) = self.toolbar_field() else { return false };
let ((x, y, w, h), _) = toolbar_layout(field, self.logical_size());
fx >= x && fx <= x + w && fy >= y && fy <= y + h
}
fn run_text_action(&mut self, action: TextAction) {
let Some(model) = self.focused.clone() else { return };
match action {
TextAction::Copy => self.copy_selection(),
TextAction::Cut => self.cut_selection(&model),
TextAction::Paste => self.request_paste(&model),
TextAction::SelectAll => self.select_all_text(&model),
}
self.request_redraw();
}
fn logical_size(&self) -> (f32, f32) {
let Some(state) = self.state.as_ref() else { return (0.0, 0.0) };
let scale = state.window.scale_factor();
let size = state.window.inner_size();
((size.width as f64 / scale) as f32, (size.height as f64 / scale) as f32)
}
fn text_scroll_for(&self, region: &FocusRegion) -> f32 {
let focused = self.focused.as_deref() == Some(region.model.as_str())
&& self.focused_row.as_deref() == region.row.as_deref();
if focused && !region.multiline { self.text_scroll } else { 0.0 }
}
fn press_text(&mut self, pointer: (f64, f64)) -> bool {
if self.open_select.is_some() {
return false;
}
let (fx, fy) = self.logical(pointer);
if self.toolbar_covers(fx, fy) {
return false;
}
let Some(region) = self.focuses.iter().rev().find(|f| f.contains(fx, fy)).cloned() else {
return false;
};
self.focus_index = self.focusables.iter().rposition(|f| f.contains(fx, fy));
self.focused_multiline = region.multiline;
let double = self
.last_click
.is_some_and(|(at, x, y)| {
at.elapsed() < DOUBLE_CLICK && (pointer.0 - x).hypot(pointer.1 - y) <= TAP_SLOP
});
self.last_click = Some((Instant::now(), pointer.0, pointer.1));
if double && self.select_word_at(pointer) {
return true;
}
let caret = self.index_in(®ion, fx, fy);
self.text_drag = true;
self.set_focus(Some((region.model, region.row, caret)));
true
}
fn select_word_at(&mut self, pointer: (f64, f64)) -> bool {
let (fx, fy) = self.logical(pointer);
let Some(region) = self.focuses.iter().rev().find(|f| f.contains(fx, fy)).cloned() else {
return false;
};
let value = self.document.value_in(®ion.model, region.row.as_deref());
let (Some(t), false) = (®ion.text, value.is_empty()) else {
return false;
};
let (tx, ty) = self.text_point(®ion, t, fx, fy);
let (start, end) = self.text.word_at_point(
&value,
&rux_paint::text_style(&t.content),
Some(t.width),
tx,
ty,
);
self.set_focus_range(Some(Focus {
model: region.model,
row: region.row,
caret: end,
anchor: start,
preedit: None,
}));
true
}
fn press_text_touch(&mut self, pointer: (f64, f64)) -> bool {
if !self.press_text(pointer) {
return false;
}
self.text_drag = false;
self.touch_text = Some(if self.anchor == self.caret {
TouchText::Pending { at: pointer, deadline: Instant::now() + LONG_PRESS }
} else {
TouchText::Selecting
});
true
}
fn drag_caret(&mut self, pointer: (f64, f64)) {
let Some(region) = self.focused_region().cloned() else { return };
let (fx, fy) = self.logical(pointer);
let caret = self.index_in(®ion, fx, fy);
if caret != self.caret || self.anchor != caret {
self.set_focus_range(Some(Focus {
model: region.model,
row: region.row,
caret,
anchor: caret,
preedit: None,
}));
}
}
fn drag_text(&mut self, pointer: (f64, f64)) {
let Some(region) = self.focused_region().cloned() else { return };
let (fx, fy) = self.logical(pointer);
let caret = self.index_in(®ion, fx, fy);
if caret != self.caret {
let anchor = self.anchor;
self.set_focus_range(Some(Focus {
model: region.model,
row: region.row,
caret,
anchor,
preedit: None,
}));
}
}
fn update_cursor(&mut self) {
let scale = self.scale();
let (px, py) = ((self.pointer.0 / scale) as f32, (self.pointer.1 / scale) as f32);
let want = self
.hits
.iter()
.rev()
.find(|h| h.contains(px, py))
.map(|h| match h.cursor {
Cursor::Pointer => CursorIcon::Pointer,
Cursor::Default => CursorIcon::Default,
})
.unwrap_or(CursorIcon::Default);
if want != self.cursor {
self.cursor = want;
if let Some(state) = &self.state {
state.window.set_cursor(want);
}
}
}
fn update_pointer_state(&mut self) {
if self.states.is_empty() && self.document.interaction().hovered.is_none() {
return;
}
let scale = self.scale();
let (px, py) = ((self.pointer.0 / scale) as f32, (self.pointer.1 / scale) as f32);
let hovered = self
.states
.iter()
.rev()
.find(|r| r.contains(px, py))
.map(|r| r.path.clone());
let active = self.press.is_some().then(|| hovered.clone()).flatten();
let next = InteractionState {
hovered,
active,
focused_model: self.document.interaction().focused_model.clone(),
focused_row: self.document.interaction().focused_row.clone(),
};
if self.document.set_interaction(next) {
self.request_redraw();
}
}
fn update_viewport(&mut self) {
let Some(state) = self.state.as_ref() else { return };
let scale = state.window.scale_factor();
let viewport = Viewport {
width: (state.surface.config.width as f64 / scale) as f32,
height: (state.surface.config.height as f64 / scale) as f32,
};
if self.document.set_viewport(viewport) {
self.request_redraw();
}
}
fn clear_pointer_state(&mut self) {
let mut next = self.document.interaction().clone();
if next.hovered.is_none() && next.active.is_none() {
return;
}
next.hovered = None;
next.active = None;
if self.document.set_interaction(next) {
self.request_redraw();
}
}
fn update_focus_state(&mut self, model: Option<String>, row: Option<String>) {
let mut next = self.document.interaction().clone();
if next.focused_model == model && next.focused_row == row {
return;
}
next.focused_model = model;
next.focused_row = row;
if self.document.set_interaction(next) {
self.request_redraw();
}
}
fn dismiss_overlay_at(&mut self, fx: f32, fy: f32) -> bool {
if !self.overlay_covers(fx, fy) {
return false;
}
self.overlay_dismissed = Some(self.document.diagnostics().clone());
self.overlay_rect = None;
self.request_redraw();
true
}
fn overlay_covers(&self, fx: f32, fy: f32) -> bool {
self.overlay_rect
.is_some_and(|(x, y, w, h)| fx >= x && fx <= x + w && fy >= y && fy <= y + h)
}
fn overlay_covers_physical(&self, (px, py): (f64, f64)) -> bool {
let scale = self.scale();
self.overlay_covers((px / scale) as f32, (py / scale) as f32)
}
fn dispatch_tap(&mut self, px: f64, py: f64) {
let scale = self.scale();
let (px, py) = (px / scale, py / scale);
let (fx, fy) = (px as f32, py as f32);
if self.dismiss_overlay_at(fx, fy) {
return;
}
if let Some(action) = self.toolbar_action_at(fx, fy) {
self.run_text_action(action);
return;
}
if let Some((model, row)) = self.open_select.take() {
if let Some(sel) = self
.selects
.iter()
.find(|s| s.model == model && s.row == row)
.cloned()
{
for (i, option) in sel.options.iter().enumerate() {
let (rx, ry, rw, rh) = dropdown_row(&sel, i);
if fx >= rx && fx <= rx + rw && fy >= ry && fy <= ry + rh {
self.document.apply_edit_in(&model, row.as_deref(), option);
self.request_redraw();
return;
}
}
}
self.request_redraw();
return;
}
self.focus_index = self.focusables.iter().rposition(|f| f.contains(fx, fy));
if let Some(sel) = self.selects.iter().find(|s| s.contains(fx, fy)) {
self.open_select = Some((sel.model.clone(), sel.row.clone()));
self.set_focus(None);
self.request_redraw();
return;
}
self.set_focus(None);
let handler = self
.hits
.iter()
.rev()
.find(|h| h.contains(px as f32, py as f32))
.map(|h| (h.on_tap.clone(), h.instance.clone()));
if let Some((src, instance)) = handler {
if self.document.apply_handler_in(&src, instance.as_deref()) {
self.request_redraw();
}
}
}
fn edit_focused(&mut self, key: &Key) {
let Some(model) = self.focused.clone() else {
return;
};
if self.ctrl_held && self.text_shortcut(key, &model) {
return;
}
let mut value = self.focused_value();
let caret = self.caret.min(value.len());
let (sel_start, sel_end) = {
let (s, e) = self.selection();
(s.min(value.len()), e.min(value.len()))
};
let has_selection = sel_start != sel_end;
let extend = self.shift_held;
let prev = value[..caret].chars().next_back().map(char::len_utf8);
let next = value[caret..].chars().next().map(char::len_utf8);
let mut edited = false;
let mut moved = false;
let mut new_caret = caret;
let replace_selection = |value: &mut String, text: &str| {
value.replace_range(sel_start..sel_end, text);
sel_start + text.len()
};
match key {
Key::Named(NamedKey::Backspace) => {
if has_selection {
new_caret = replace_selection(&mut value, "");
edited = true;
} else if let Some(len) = prev {
value.replace_range(caret - len..caret, "");
new_caret = caret - len;
edited = true;
}
}
Key::Named(NamedKey::Delete) => {
if has_selection {
new_caret = replace_selection(&mut value, "");
edited = true;
} else if let Some(len) = next {
value.replace_range(caret..caret + len, "");
edited = true;
}
}
Key::Named(NamedKey::ArrowLeft) => {
if has_selection && !extend {
new_caret = sel_start;
moved = true;
} else if let Some(len) = prev {
new_caret = caret - len;
moved = true;
}
}
Key::Named(NamedKey::ArrowRight) => {
if has_selection && !extend {
new_caret = sel_end;
moved = true;
} else if let Some(len) = next {
new_caret = caret + len;
moved = true;
}
}
Key::Named(NamedKey::ArrowUp | NamedKey::ArrowDown) if self.focused_multiline => {
if let Some(t) = self
.focused_region()
.and_then(|f| f.text.clone())
{
let style = rux_paint::text_style(&t.content);
let (cx, cy, ch) = self.text.caret_geometry(&value, &style, Some(t.width), caret);
let dir = if matches!(key, Key::Named(NamedKey::ArrowUp)) { -1.0 } else { 1.0 };
let target_y = cy + ch / 2.0 + dir * ch;
new_caret = self.text.index_at_point(&value, &style, Some(t.width), cx, target_y);
moved = new_caret != caret;
}
}
Key::Named(NamedKey::Home) => {
new_caret = 0;
moved = true;
}
Key::Named(NamedKey::End) => {
new_caret = value.len();
moved = true;
}
Key::Named(NamedKey::Escape) => {
self.set_focus(None);
return;
}
Key::Named(NamedKey::Space) => {
new_caret = replace_selection(&mut value, " ");
edited = true;
}
Key::Named(NamedKey::Enter) if self.focused_multiline => {
new_caret = replace_selection(&mut value, "\n");
edited = true;
}
Key::Character(s) => {
let typed: String = s.chars().filter(|c| !c.is_control()).collect();
if !typed.is_empty() {
new_caret = replace_selection(&mut value, &typed);
edited = true;
}
}
_ => {}
}
if edited || moved {
let new_anchor = if moved && extend { self.anchor } else { new_caret };
self.scroll_caret_into_view(&value, new_caret);
if edited {
self.write_focused(&value);
}
self.set_focus_range(Some(Focus {
model,
row: self.focused_row.clone(),
caret: new_caret,
anchor: new_anchor,
preedit: None,
}));
}
}
fn text_shortcut(&mut self, key: &Key, model: &str) -> bool {
let Key::Character(s) = key else { return false };
match s.to_lowercase().as_str() {
"a" => self.select_all_text(model),
"c" => self.copy_selection(),
"x" => self.cut_selection(model),
"v" => self.request_paste(model),
_ => return false,
}
true
}
fn select_all_text(&mut self, model: &str) {
let value = self.focused_value();
self.set_focus_range(Some(Focus {
model: model.to_string(),
row: self.focused_row.clone(),
caret: value.len(),
anchor: 0,
preedit: None,
}));
}
fn copy_selection(&mut self) {
if let Some(text) = self.selected_text() {
self.clipboard_write(&text);
}
}
fn cut_selection(&mut self, model: &str) {
let Some(text) = self.selected_text() else { return };
self.clipboard_write(&text);
let value = self.focused_value();
let (start, end) = self.selection();
let mut value = value;
value.replace_range(start.min(value.len())..end.min(value.len()), "");
self.write_focused(&value);
self.set_focus_range(Some(Focus::at(model, start)));
}
#[cfg(not(target_arch = "wasm32"))]
fn request_paste(&mut self, model: &str) {
if let Some(pasted) = self.clipboard_read() {
self.apply_paste(model, &pasted);
}
}
#[cfg(target_arch = "wasm32")]
fn request_paste(&mut self, _model: &str) {
use wasm_bindgen_futures::JsFuture;
let Some(clipboard) = web_clipboard() else { return };
let promise = clipboard.read_text();
wasm_bindgen_futures::spawn_local(async move {
let Ok(value) = JsFuture::from(promise).await else { return };
let Some(text) = value.as_string() else { return };
WEB_PROXY.with(|p| {
if let Some(proxy) = p.borrow().as_ref() {
let _ = proxy.send_event(RuxEvent::WebPaste(text));
}
});
});
}
fn apply_paste(&mut self, model: &str, pasted: &str) {
let pasted = if self.focused_multiline {
pasted.replace("\r\n", "\n")
} else {
pasted.lines().next().unwrap_or("").to_string()
};
let value = self.focused_value();
let (start, end) = self.selection();
let mut value = value;
let (start, end) = (start.min(value.len()), end.min(value.len()));
value.replace_range(start..end, &pasted);
let caret = start + pasted.len();
self.write_focused(&value);
self.scroll_caret_into_view(&value, caret);
self.set_focus_range(Some(Focus::at(model, caret)));
}
fn scroll_caret_into_view(&mut self, value: &str, caret: usize) {
let Some(region) = self.focused_region().cloned() else {
return;
};
let (Some(sid), Some(t)) = (region.scroll_id, ®ion.text) else {
return;
};
let style = rux_paint::text_style(&t.content);
let (_, cy, ch) = self.text.caret_geometry(value, &style, Some(t.width), caret);
let visible = region.height;
let mut off = self.offsets.get(sid).copied().unwrap_or_default();
if cy < off.y {
off.y = cy;
} else if cy + ch > off.y + visible {
off.y = cy + ch - visible;
}
if let Some(slot) = self.offsets.get_mut(sid) {
slot.y = off.y.max(0.0);
}
}
fn on_key(&mut self, key: &Key) {
if self.alt_held {
let moved = match key {
Key::Named(NamedKey::ArrowLeft) => self.document.back(),
Key::Named(NamedKey::ArrowRight) => self.document.forward(),
_ => false,
};
if moved {
self.request_redraw();
return;
}
}
if let Key::Named(NamedKey::Tab) = key {
self.move_focus(self.shift_held);
return;
}
if self.focused.is_some() {
self.edit_focused(key);
return;
}
if let Some(idx) = self.focus_index {
match key {
Key::Named(NamedKey::Space | NamedKey::Enter) => {
self.activate_focused(idx);
return;
}
Key::Named(NamedKey::Escape) => {
self.focus_index = None;
self.request_redraw();
return;
}
_ => {}
}
}
self.scroll_key(key);
}
fn move_focus(&mut self, backward: bool) {
let n = self.focusables.len();
if n == 0 {
return;
}
let next = match self.focus_index {
Some(i) if backward => (i + n - 1) % n,
Some(i) => (i + 1) % n,
None if backward => n - 1,
None => 0,
};
self.set_keyboard_focus(Some(next));
}
fn set_keyboard_focus(&mut self, index: Option<usize>) {
self.focus_index = index;
match index.and_then(|i| self.focusables.get(i)).map(|f| f.kind.clone()) {
Some(FocusKind::Text { model, row, multiline, .. }) => {
let caret = self.document.value_in(&model, row.as_deref()).len();
self.focused_multiline = multiline;
self.set_focus(Some((model, row, caret)));
}
_ => self.set_focus(None),
}
self.scroll_focus_into_view();
self.request_redraw();
}
fn activate_focused(&mut self, index: usize) {
match self.focusables.get(index).map(|f| f.kind.clone()) {
Some(FocusKind::Activate { on_tap, instance }) => {
self.document.apply_handler_in(&on_tap, instance.as_deref());
self.request_redraw();
}
Some(FocusKind::Select { model, row, .. }) => {
self.open_select = Some((model, row));
self.request_redraw();
}
_ => {}
}
}
fn set_focus(&mut self, focus: Option<(String, Option<String>, usize)>) {
match focus {
Some((model, row, caret)) => self.set_focus_range(Some(Focus::at_row(model, row, caret))),
None => self.set_focus_range(None),
}
}
fn focused_value(&mut self) -> String {
let Some(model) = self.focused.clone() else { return String::new() };
let row = self.focused_row.clone();
self.document.value_in(&model, row.as_deref())
}
fn write_focused(&mut self, value: &str) {
let Some(model) = self.focused.clone() else { return };
let row = self.focused_row.clone();
self.document.apply_edit_in(&model, row.as_deref(), value);
}
fn focused_region(&self) -> Option<&FocusRegion> {
let model = self.focused.as_deref()?;
self.focuses
.iter()
.find(|f| f.model == model && f.row.as_deref() == self.focused_row.as_deref())
}
fn set_focus_range(&mut self, focus: Option<Focus>) {
if focus.as_ref().and_then(|f| f.preedit).is_none() {
self.cancel_preedit();
}
let same_field = focus
.as_ref()
.is_some_and(|f| f.is(self.focused.as_deref().unwrap_or(""), self.focused_row.as_deref()));
if !same_field {
self.text_scroll = 0.0;
}
self.focused = focus.as_ref().map(|f| f.model.clone());
self.focused_row = focus.as_ref().and_then(|f| f.row.clone());
self.caret = focus.as_ref().map(|f| f.caret).unwrap_or(0);
self.anchor = focus.as_ref().map(|f| f.anchor).unwrap_or(0);
self.document.set_focus(focus);
let model = self.focused.clone();
let row = self.focused_row.clone();
self.update_focus_state(model, row);
self.set_ime_enabled(self.focused.is_some());
self.reset_blink();
self.request_redraw();
}
fn set_ime_enabled(&mut self, on: bool) {
let Some(state) = self.state.as_ref() else { return };
state.window.set_ime_allowed(on);
if on {
self.update_ime_area();
}
#[cfg(target_arch = "wasm32")]
self.sync_web_ime();
}
#[cfg(target_arch = "wasm32")]
fn sync_web_ime(&mut self) {
if !web_is_touch() {
return;
}
let Some(el) = web_ime_element() else { return };
if self.focused.is_none() {
let _ = el.blur();
return;
}
let value = self.focused_value();
let caret16 = byte_to_utf16_index(&value, self.caret.min(value.len())) as u32;
let anchor16 = byte_to_utf16_index(&value, self.anchor.min(value.len())) as u32;
let (start, end, direction) = browser_selection(anchor16, caret16);
if el.value() != value {
el.set_value(&value);
let _ = el.set_selection_range_with_direction(start, end, direction);
} else if el.selection_start().ok().flatten() != Some(start)
|| el.selection_end().ok().flatten() != Some(end)
{
let _ = el.set_selection_range_with_direction(start, end, direction);
}
let _ = el.focus();
self.position_web_ime();
}
#[cfg(target_arch = "wasm32")]
fn position_web_ime(&mut self) {
let Some(el) = WEB_IME.with(|c| c.borrow().clone()) else { return };
let Some(canvas) = WEB_CANVAS.with(|c| c.borrow().clone()) else { return };
let Some(region) = self.focused_region() else { return };
let (ox, oy) = (canvas.offset_left() as f32, canvas.offset_top() as f32);
let style = el.style();
let _ = style.set_property("left", &format!("{}px", ox + region.x));
let _ = style.set_property("top", &format!("{}px", oy + region.y));
let _ = style.set_property("width", &format!("{}px", region.width.max(1.0)));
let _ = style.set_property("height", &format!("{}px", region.height.max(1.0)));
}
#[cfg(target_arch = "wasm32")]
fn apply_web_text(&mut self, value: String, caret: usize, anchor: usize, composing: usize) {
let Some(model) = self.focused.clone() else { return };
let value = if self.focused_multiline {
value.replace("\r\n", "\n")
} else {
value.replace(['\n', '\r'], "")
};
let caret = floor_char_boundary(&value, caret.min(value.len()));
let anchor = floor_char_boundary(&value, anchor.min(value.len()));
let preedit = (composing > 0 && composing <= caret)
.then(|| (floor_char_boundary(&value, caret - composing), caret));
self.preedit = None;
self.write_focused(&value);
self.scroll_caret_into_view(&value, caret);
let row = self.focused_row.clone();
self.set_focus_range(Some(Focus { model, row, caret, anchor, preedit }));
}
fn update_ime_area(&mut self) {
let Some(window) = self.state.as_ref().map(|s| s.window.clone()) else { return };
let scale = window.scale_factor();
if self.focused.is_none() { return; }
let Some(region) = self.focused_region().cloned() else {
return;
};
let Some(t) = region.text.as_ref() else { return };
let value = self.focused_value();
let style = rux_paint::text_style(&t.content);
let caret = self.caret.min(value.len());
let (cx, cy, ch) = self.text.caret_geometry(&value, &style, Some(t.width), caret);
window.set_ime_cursor_area(
winit::dpi::LogicalPosition::new((t.x + cx) as f64, (t.y + cy) as f64)
.to_physical::<f64>(scale),
winit::dpi::LogicalSize::new(rux_text::CARET_WIDTH as f64, ch as f64)
.to_physical::<f64>(scale),
);
}
fn on_ime(&mut self, ime: &Ime) {
match ime {
Ime::Enabled => {}
Ime::Preedit(text, cursor) => self.set_preedit(text, *cursor),
Ime::Commit(text) => self.commit_text(text),
Ime::Disabled => {
self.cancel_preedit();
self.request_redraw();
}
}
}
fn set_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) {
let Some(model) = self.focused.clone() else { return };
let mut value = self.focused_value();
let composing = match self.preedit.clone() {
Some(p) => p,
None => {
let (start, end) = self.selection();
let (start, end) = (start.min(value.len()), end.min(value.len()));
let replaced = value[start..end].to_string();
value.replace_range(start..end, "");
Preedit { at: start, len: 0, replaced }
}
};
let at = composing.at.min(value.len());
let end = (at + composing.len).min(value.len());
value.replace_range(at..end, text);
if text.is_empty() {
value.insert_str(at, &composing.replaced);
let caret = at + composing.replaced.len();
self.preedit = None;
self.write_focused(&value);
self.set_focus_range(Some(Focus::at(model, caret)));
return;
}
let caret = at + cursor.map(|(s, _)| s.min(text.len())).unwrap_or(text.len());
self.preedit = Some(Preedit { at, len: text.len(), replaced: composing.replaced });
self.write_focused(&value);
self.scroll_caret_into_view(&value, caret);
self.set_focus_range(Some(Focus {
model,
row: self.focused_row.clone(),
caret,
anchor: caret,
preedit: Some((at, at + text.len())),
}));
self.update_ime_area();
}
fn commit_text(&mut self, text: &str) {
let Some(model) = self.focused.clone() else { return };
let mut value = self.focused_value();
let (start, end) = match self.preedit.take() {
Some(p) => {
let at = p.at.min(value.len());
(at, (at + p.len).min(value.len()))
}
None => {
let (s, e) = self.selection();
(s.min(value.len()), e.min(value.len()))
}
};
let text = if self.focused_multiline {
text.replace("\r\n", "\n")
} else {
text.lines().next().unwrap_or("").to_string()
};
value.replace_range(start..end, &text);
let caret = start + text.len();
self.write_focused(&value);
self.scroll_caret_into_view(&value, caret);
self.set_focus_range(Some(Focus::at(model, caret)));
self.update_ime_area();
}
fn cancel_preedit(&mut self) {
let Some(p) = self.preedit.take() else { return };
if self.focused.is_none() { return; }
let mut value = self.focused_value();
let at = p.at.min(value.len());
let end = (at + p.len).min(value.len());
value.replace_range(at..end, &p.replaced);
self.write_focused(&value);
}
fn selection(&self) -> (usize, usize) {
(self.caret.min(self.anchor), self.caret.max(self.anchor))
}
fn selected_text(&mut self) -> Option<String> {
self.focused.as_ref()?;
let (start, end) = self.selection();
if start == end {
return None;
}
let value = self.focused_value();
value.get(start.min(value.len())..end.min(value.len())).map(str::to_string)
}
#[cfg(not(target_arch = "wasm32"))]
fn clipboard_write(&mut self, text: &str) {
if let Some(cb) = self.clipboard.as_mut() {
if let Err(e) = cb.set_text(text.to_string()) {
eprintln!("rux: clipboard copy failed: {e}");
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn clipboard_read(&mut self) -> Option<String> {
self.clipboard.as_mut()?.get_text().ok()
}
#[cfg(target_arch = "wasm32")]
fn clipboard_write(&mut self, text: &str) {
let Some(clipboard) = web_clipboard() else { return };
let _ = clipboard.write_text(text);
}
#[cfg(target_arch = "wasm32")]
fn clipboard_read(&mut self) -> Option<String> {
None
}
fn reset_blink(&mut self) {
self.caret_visible = true;
self.blink_deadline = self.focused.is_some().then(|| Instant::now() + BLINK);
}
fn request_redraw(&self) {
if let Some(state) = self.state.as_ref() {
state.window.request_redraw();
}
}
fn render(&mut self) {
self.update_viewport();
#[cfg(target_arch = "wasm32")]
self.sync_url();
let caret_visible = self.caret_visible;
let App {
context,
state,
document,
text,
images,
hits,
focuses,
selects,
focusables,
focus_index,
open_select,
scrolls,
offsets,
states,
overlay_dismissed,
overlay_rect,
caret,
anchor,
text_scroll,
focused,
focused_row,
#[cfg(not(target_arch = "wasm32"))]
path,
..
} = self;
let Some(state) = state.as_mut() else {
return;
};
let width = state.surface.config.width;
let height = state.surface.config.height;
let scale = state.window.scale_factor();
let logical = (width as f64 / scale, height as f64 / scale);
if let Some(restored) = document.take_scroll() {
*offsets = restored;
}
let mut layout = {
let mut measure = |tc: &rux_layout::TextContent, mw: Option<f32>| {
text.measure(&tc.text, &rux_paint::text_style(tc), mw)
};
rux_layout::layout_scrolled(
&document.root,
logical.0 as f32,
logical.1 as f32,
offsets,
&mut measure,
)
};
offsets.resize(layout.scrolls.len(), Offset::default());
for region in &layout.scrolls {
offsets[region.id] = offsets[region.id].clamp_to(region.max);
}
document.record_scroll(offsets);
let shift = Self::track_caret_x(
&layout,
focused.as_deref(),
focused_row.as_deref(),
*caret,
text_scroll,
text,
document,
);
if shift != 0.0 {
for paint in layout.paints.iter_mut() {
if let Paint::Text(t) = paint {
if t.content.caret.is_some() {
t.x -= shift;
}
}
}
}
let content = rux_paint::build_scene(&layout.paints, text, images, caret_visible);
state.scene.reset();
state
.scene
.append(&content, Some(Affine::scale(scale)));
let bars = scrollbar_paints(&layout.scrolls, offsets);
if !bars.is_empty() {
let scene = rux_paint::build_scene(&bars, text, images, false);
state.scene.append(&scene, Some(Affine::scale(scale)));
}
if let Some(item) = focus_index.and_then(|i| layout.focusables.get(i)) {
let within = item.scroll.and_then(|s| layout.scrolls.get(s));
let ring = rux_paint::build_scene(&focus_ring(item, within), text, images, false);
state.scene.append(&ring, Some(Affine::scale(scale)));
}
if *caret != *anchor {
if let Some(r) = focused.as_deref().and_then(|m| {
layout
.focuses
.iter()
.find(|f| f.model == m && f.row.as_deref() == focused_row.as_deref())
}) {
let strip = toolbar_paints(
(r.x, r.y, r.width, r.height),
(logical.0 as f32, logical.1 as f32),
);
let scene = rux_paint::build_scene(&strip, text, images, false);
state.scene.append(&scene, Some(Affine::scale(scale)));
}
}
if let Some((model, row)) = open_select.clone() {
if let Some(sel) = layout.selects.iter().find(|s| s.model == model && s.row == row) {
let value = document.value_in(&model, row.as_deref());
let overlay = dropdown_paints(sel, &value);
let scene = rux_paint::build_scene(&overlay, text, images, false);
state.scene.append(&scene, Some(Affine::scale(scale)));
}
}
let diagnostics = document.diagnostics();
*overlay_rect = None;
if overlay_visible(diagnostics, overlay_dismissed.as_ref()) {
#[cfg(not(target_arch = "wasm32"))]
let panel = overlay_paints(diagnostics, path, logical.0 as f32);
#[cfg(target_arch = "wasm32")]
let panel =
overlay_paints(diagnostics, Path::new("playground.rux"), logical.0 as f32);
if let Some(panel) = panel {
let scene = rux_paint::build_scene(&panel.paints, text, images, false);
state.scene.append(&scene, Some(Affine::scale(scale)));
*overlay_rect = Some(panel.rect);
}
}
#[cfg(not(target_arch = "wasm32"))]
{
let window_title = state.window.title();
state.access.update_if_active(|| {
access_tree(&layout.access, focused.as_deref(), scale, &window_title)
});
}
*hits = layout.hits;
*focuses = layout.focuses;
if let Some(model) = focused.clone() {
let still_here = focuses
.iter()
.any(|f| f.model == model && f.row.as_deref() == focused_row.as_deref());
if !still_here {
*focused = None;
*focused_row = None;
*text_scroll = 0.0;
#[cfg(target_arch = "wasm32")]
if let Some(el) = web_ime_element() {
let _ = el.blur();
}
}
}
*selects = layout.selects;
if focus_index.map(|i| i >= layout.focusables.len()).unwrap_or(false) {
*focus_index = None;
}
*focusables = layout.focusables;
*scrolls = layout.scrolls;
*states = layout.states;
let device_handle = &context.devices[state.surface.dev_id];
let surface_texture = match state.surface.surface.get_current_texture() {
CurrentSurfaceTexture::Success(t) | CurrentSurfaceTexture::Suboptimal(t) => t,
other => {
eprintln!("rux: skipping frame ({other:?})");
return;
}
};
state
.renderer
.render_to_texture(
&device_handle.device,
&device_handle.queue,
&state.scene,
&state.surface.target_view,
&RenderParams {
base_color: BG,
width,
height,
antialiasing_method: AaConfig::Area,
},
)
.expect("render to texture");
let mut encoder = device_handle
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("rux: blit to surface"),
});
let view = surface_texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
state
.surface
.blitter
.copy(&device_handle.device, &mut encoder, &state.surface.target_view, &view);
device_handle.queue.submit([encoder.finish()]);
surface_texture.present();
#[cfg(target_arch = "wasm32")]
self.position_web_ime();
}
}
fn make_renderer(context: &RenderContext, surface: &RenderSurface<'static>) -> Renderer {
Renderer::new(
&context.devices[surface.dev_id].device,
RendererOptions {
use_cpu: false,
antialiasing_support: AaSupport::area_only(),
num_init_threads: NonZeroUsize::new(1),
pipeline_cache: None,
},
)
.expect("create renderer")
}
impl ApplicationHandler<RuxEvent> for App {
#[cfg(not(target_arch = "wasm32"))]
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let title = format!(
"Rux · {}",
self.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "M2".into())
);
let attributes = Window::default_attributes()
.with_title(title)
.with_visible(false)
.with_inner_size(winit::dpi::LogicalSize::new(420.0, 640.0));
let window = Arc::new(event_loop.create_window(attributes).expect("create window"));
let access = accesskit_winit::Adapter::with_event_loop_proxy(
event_loop,
&window,
self.proxy.clone(),
);
window.set_visible(true);
let size = window.inner_size();
let surface = pollster::block_on(self.context.create_surface(
window.clone(),
size.width.max(1),
size.height.max(1),
wgpu::PresentMode::AutoVsync,
))
.expect("create surface");
let renderer = make_renderer(&self.context, &surface);
self.state = Some(RenderState {
window,
surface,
renderer,
scene: Scene::new(),
access,
});
self.request_redraw();
}
#[cfg(target_arch = "wasm32")]
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
use winit::platform::web::WindowAttributesExtWebSys;
if self.state.is_some() || self.starting {
return;
}
self.starting = true;
let canvas = WEB_CANVAS.with(|c| c.borrow().clone());
let (lw, lh) = WEB_SIZE.with(|s| *s.borrow());
let attributes = Window::default_attributes()
.with_canvas(canvas)
.with_inner_size(winit::dpi::LogicalSize::new(lw, lh));
let window = Arc::new(event_loop.create_window(attributes).expect("create window"));
let pending = self.pending.clone();
let proxy = WEB_PROXY.with(|p| p.borrow().clone()).expect("event loop proxy");
let mut size = window.inner_size();
if size.width == 0 || size.height == 0 {
size = winit::dpi::LogicalSize::new(lw, lh).to_physical(window.scale_factor());
}
web_sys::console::log_1(
&format!(
"rux: canvas {lw}x{lh} css, surface {}x{} physical, dpr {}",
size.width,
size.height,
window.scale_factor()
)
.into(),
);
wasm_bindgen_futures::spawn_local(async move {
let mut context = RenderContext::new();
let surface = context
.create_surface(
window.clone(),
size.width.max(1),
size.height.max(1),
wgpu::PresentMode::AutoVsync,
)
.await
.expect("create surface");
let renderer = make_renderer(&context, &surface);
*pending.borrow_mut() = Some((
context,
RenderState { window, surface, renderer, scene: Scene::new() },
));
let _ = proxy.send_event(RuxEvent::SurfaceReady);
});
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: RuxEvent) {
match event {
#[cfg(not(target_arch = "wasm32"))]
RuxEvent::Reload => self.reload(),
#[cfg(target_arch = "wasm32")]
RuxEvent::SurfaceReady => {
if let Some((context, state)) = self.pending.borrow_mut().take() {
self.context = context;
self.state = Some(state);
self.starting = false;
}
}
#[cfg(target_arch = "wasm32")]
RuxEvent::SetSource(source) => self.set_source(source),
#[cfg(target_arch = "wasm32")]
RuxEvent::WebText { value, caret, anchor, composing } => {
self.apply_web_text(value, caret, anchor, composing)
}
#[cfg(target_arch = "wasm32")]
RuxEvent::WebRoute(index) => self.apply_web_route(index),
#[cfg(target_arch = "wasm32")]
RuxEvent::WebPaste(text) => {
if let Some(model) = self.focused.clone() {
self.apply_paste(&model, &text);
self.sync_web_ime();
self.request_redraw();
}
}
#[cfg(target_arch = "wasm32")]
RuxEvent::Resize(w, h) => {
if let Some(state) = self.state.as_ref() {
let _ = state
.window
.request_inner_size(winit::dpi::LogicalSize::new(w.max(1.0), h.max(1.0)));
}
}
#[cfg(not(target_arch = "wasm32"))]
RuxEvent::Access(event) => {
match event.window_event {
accesskit_winit::WindowEvent::InitialTreeRequested => {}
accesskit_winit::WindowEvent::ActionRequested(_) => {}
accesskit_winit::WindowEvent::AccessibilityDeactivated => {}
}
self.request_redraw();
return;
}
}
self.request_redraw();
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(state) = self.state.as_mut() {
state.access.process_event(&state.window, &event);
}
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
if let Some(state) = self.state.as_mut() {
self.context.resize_surface(
&mut state.surface,
size.width.max(1),
size.height.max(1),
);
}
self.update_viewport();
self.request_redraw();
}
WindowEvent::MouseWheel { delta, .. } => {
let (dx, dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => (x * LINE, y * LINE),
MouseScrollDelta::PixelDelta(p) => {
let scale = self.scale();
((p.x / scale) as f32, (p.y / scale) as f32)
}
};
let (dx, dy) = if self.shift_held && dx == 0.0 { (dy, 0.0) } else { (dx, dy) };
self.scroll_at(self.pointer, -dx, -dy);
}
WindowEvent::CursorMoved { position, .. } => {
self.pointer = (position.x, position.y);
if self.bar_drag.is_some() {
self.drag_scrollbar(self.pointer);
} else if self.text_drag {
self.drag_text(self.pointer);
} else {
self.update_cursor();
self.update_pointer_state();
}
}
WindowEvent::CursorLeft { .. } => self.clear_pointer_state(),
WindowEvent::Touch(touch) => {
let at = (touch.location.x, touch.location.y);
let scale = self.scale();
let here = ((at.0 / scale) as f32, (at.1 / scale) as f32);
match touch.phase {
TouchPhase::Started => {
self.pointer = at;
self.touch = Some(here);
if self.overlay_covers_physical(at)
|| (!self.press_scrollbar(at) && !self.press_text_touch(at))
{
self.press = Some(at);
}
}
TouchPhase::Moved => {
self.pointer = at;
if self.bar_drag.is_some() {
self.drag_scrollbar(at);
} else if let Some(state) = self.touch_text {
let from = match state {
TouchText::Pending { at, .. } => at,
_ => at,
};
let moved = (at.0 - from.0).hypot(at.1 - from.1);
let next = touch_text_after_move(state, moved);
self.touch_text = Some(next);
match next {
TouchText::Selecting => self.drag_text(at),
TouchText::Caret => self.drag_caret(at),
TouchText::Pending { .. } => {}
}
} else if let Some((lx, ly)) = self.touch.replace(here) {
self.scroll_at(at, lx - here.0, ly - here.1);
}
}
TouchPhase::Ended => {
self.pointer = at;
self.touch = None;
if self.bar_drag.take().is_some() {
return;
}
if std::mem::take(&mut self.text_drag) {
return;
}
if self.touch_text.take().is_some() {
return;
}
if let Some((sx, sy)) = self.press.take() {
if (at.0 - sx).hypot(at.1 - sy) <= TAP_SLOP {
self.dispatch_tap(at.0, at.1);
}
}
}
TouchPhase::Cancelled => {
self.touch = None;
self.press = None;
self.bar_drag = None;
self.text_drag = false;
self.touch_text = None;
}
}
}
WindowEvent::ModifiersChanged(mods) => {
self.shift_held = mods.state().shift_key();
self.ctrl_held = mods.state().control_key();
self.alt_held = mods.state().alt_key();
}
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: button @ (MouseButton::Back | MouseButton::Forward),
..
} => {
let moved = if button == MouseButton::Back {
self.document.back()
} else {
self.document.forward()
};
if moved {
self.request_redraw();
}
}
WindowEvent::Ime(ime) => self.on_ime(&ime),
WindowEvent::KeyboardInput { event, .. } => {
if event.state == ElementState::Pressed && self.preedit.is_none() {
self.on_key(&event.logical_key);
}
}
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
} => {
if self.overlay_covers_physical(self.pointer) {
self.press = Some(self.pointer);
} else if !self.press_scrollbar(self.pointer) && !self.press_text(self.pointer) {
self.press = Some(self.pointer);
self.update_pointer_state();
}
}
WindowEvent::MouseInput {
state: ElementState::Released,
button: MouseButton::Left,
..
} => {
if self.bar_drag.take().is_some() {
self.update_cursor();
return;
}
if std::mem::take(&mut self.text_drag) {
return;
}
if let Some((sx, sy)) = self.press.take() {
self.update_pointer_state();
let (px, py) = self.pointer;
if (px - sx).hypot(py - sy) <= TAP_SLOP {
self.dispatch_tap(px, py);
}
}
}
WindowEvent::RedrawRequested => self.render(),
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if let Some(TouchText::Pending { at, deadline }) = self.touch_text {
if Instant::now() >= deadline {
self.touch_text = Some(TouchText::Selecting);
if self.select_word_at(at) {
self.request_redraw();
}
}
}
if let Some(deadline) = self.blink_deadline {
if Instant::now() >= deadline {
self.caret_visible = !self.caret_visible;
self.blink_deadline = Some(Instant::now() + BLINK);
self.request_redraw();
}
}
let long_press = match self.touch_text {
Some(TouchText::Pending { deadline, .. }) => Some(deadline),
_ => None,
};
match [self.blink_deadline, long_press].into_iter().flatten().min() {
Some(next) => event_loop.set_control_flow(ControlFlow::WaitUntil(next)),
None => event_loop.set_control_flow(ControlFlow::Wait),
}
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn route_from_path(base: &str, pathname: &str) -> String {
let base = base.trim_end_matches('/');
let rest = match pathname.strip_prefix(base) {
Some(rest) => rest,
None if base.trim_start_matches('/') == pathname.trim_start_matches('/') => "",
None => "",
};
if rest.is_empty() || !rest.starts_with('/') {
return rux_runtime::ROOT_PATH.to_string();
}
rest.to_string()
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn path_for_route(base: &str, route: &str) -> String {
let base = base.trim_end_matches('/');
if route == rux_runtime::ROOT_PATH {
return if base.is_empty() { rux_runtime::ROOT_PATH.to_string() } else { base.to_string() };
}
format!("{base}{route}")
}
#[cfg(test)]
mod url_routes {
use super::{path_for_route, route_from_path};
#[test]
fn at_the_root_a_path_is_a_route() {
assert_eq!(route_from_path("/", "/"), "/");
assert_eq!(route_from_path("/", "/settings"), "/settings");
assert_eq!(route_from_path("/", "/user/7"), "/user/7");
}
#[test]
fn a_base_is_subtracted() {
assert_eq!(route_from_path("/app/", "/app/settings"), "/settings");
assert_eq!(route_from_path("/app", "/app/user/7"), "/user/7");
assert_eq!(route_from_path("/app/", "/app/"), "/");
assert_eq!(route_from_path("/app/", "/app"), "/");
}
#[test]
fn a_path_outside_the_base_is_the_root() {
assert_eq!(route_from_path("/app/", "/other/page"), "/");
assert_eq!(route_from_path("/app", "/application"), "/");
}
#[test]
fn a_route_survives_the_round_trip() {
for base in ["/", "/app", "/app/"] {
for route in ["/", "/settings", "/user/7"] {
let path = path_for_route(base, route);
assert_eq!(
route_from_path(base, &path),
route,
"base {base}, route {route}, path {path}"
);
}
}
}
}
#[cfg(target_arch = "wasm32")]
thread_local! {
static WEB_CANVAS: RefCell<Option<web_sys::HtmlCanvasElement>> = const { RefCell::new(None) };
static WEB_PROXY: RefCell<Option<winit::event_loop::EventLoopProxy<RuxEvent>>> =
const { RefCell::new(None) };
static WEB_SIZE: RefCell<(f64, f64)> = const { RefCell::new((420.0, 640.0)) };
static WEB_IME: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
static WEB_COMPOSING: RefCell<usize> = const { RefCell::new(0) };
static WEB_BASE: RefCell<Option<String>> = const { RefCell::new(None) };
}
#[cfg(target_arch = "wasm32")]
fn web_route_now() -> Option<String> {
let base = WEB_BASE.with(|b| b.borrow().clone())?;
let location = web_sys::window()?.location();
let route = route_from_path(&base, &location.pathname().ok()?);
let query = location.search().unwrap_or_default();
Some(format!("{route}{query}"))
}
#[cfg(target_arch = "wasm32")]
fn web_write_history(index: usize, route: &str, replace: bool) {
let Some(base) = WEB_BASE.with(|b| b.borrow().clone()) else { return };
let Some(history) = web_sys::window().and_then(|w| w.history().ok()) else { return };
let url = path_for_route(&base, route);
let state = wasm_bindgen::JsValue::from_f64(index as f64);
let wrote = if replace {
history.replace_state_with_url(&state, "", Some(&url))
} else {
history.push_state_with_url(&state, "", Some(&url))
};
if wrote.is_err() {
web_sys::console::warn_1(
&"rux: this page may not change its URL, so the address bar will not follow the router"
.into(),
);
WEB_BASE.with(|b| *b.borrow_mut() = None);
}
}
#[cfg(target_arch = "wasm32")]
fn web_watch_history() {
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::Closure;
let Some(window) = web_sys::window() else { return };
let on_pop = Closure::<dyn FnMut(web_sys::PopStateEvent)>::new(
move |event: web_sys::PopStateEvent| {
let index = event.state().as_f64().map(|n| n as usize);
WEB_PROXY.with(|p| {
if let Some(proxy) = p.borrow().as_ref() {
let _ = proxy.send_event(RuxEvent::WebRoute(index));
}
});
},
);
let _ = window
.add_event_listener_with_callback("popstate", on_pop.as_ref().unchecked_ref());
on_pop.forget();
}
#[cfg(target_arch = "wasm32")]
fn web_is_touch() -> bool {
web_sys::window()
.and_then(|w| w.match_media("(pointer: coarse)").ok().flatten())
.map(|m| m.matches())
.unwrap_or(false)
}
#[cfg(target_arch = "wasm32")]
fn web_clipboard() -> Option<web_sys::Clipboard> {
Some(web_sys::window()?.navigator().clipboard())
}
#[cfg(target_arch = "wasm32")]
fn web_ime_element() -> Option<web_sys::HtmlInputElement> {
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::Closure;
if let Some(el) = WEB_IME.with(|c| c.borrow().clone()) {
return Some(el);
}
let canvas = WEB_CANVAS.with(|c| c.borrow().clone())?;
let document = web_sys::window()?.document()?;
let el: web_sys::HtmlInputElement =
document.create_element("input").ok()?.dyn_into().ok()?;
el.set_type("text");
let _ = el.set_attribute("autocomplete", "off");
let _ = el.set_attribute("autocapitalize", "off");
let _ = el.set_attribute("autocorrect", "off");
let _ = el.set_attribute("spellcheck", "false");
let _ = el.set_attribute("aria-hidden", "true");
let _ = el.set_attribute(
"style",
"position: absolute; opacity: 0; pointer-events: none; z-index: 1; \
border: 0; padding: 0; margin: 0; background: transparent; \
color: transparent; caret-color: transparent; font-size: 16px; \
width: 1px; height: 1px; left: 0; top: 0;",
);
let parent = canvas.parent_element()?;
parent.append_child(&el).ok()?;
let on_input = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
if let Some(target) = event.target().and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) {
web_send_text(&target);
}
});
let _ = el.add_event_listener_with_callback("input", on_input.as_ref().unchecked_ref());
on_input.forget();
let on_comp = Closure::<dyn FnMut(web_sys::CompositionEvent)>::new(
move |event: web_sys::CompositionEvent| {
let composing = match event.type_().as_str() {
"compositionend" => 0,
_ => event.data().unwrap_or_default().len(),
};
WEB_COMPOSING.with(|c| *c.borrow_mut() = composing);
if let Some(target) =
event.target().and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
web_send_text(&target);
}
},
);
for name in ["compositionstart", "compositionupdate", "compositionend"] {
let _ = el.add_event_listener_with_callback(name, on_comp.as_ref().unchecked_ref());
}
on_comp.forget();
WEB_IME.with(|c| *c.borrow_mut() = Some(el.clone()));
Some(el)
}
#[cfg(target_arch = "wasm32")]
fn web_send_text(el: &web_sys::HtmlInputElement) {
let value = el.value();
let start16 = el.selection_start().ok().flatten().unwrap_or(0) as usize;
let end16 = el.selection_end().ok().flatten().map_or(start16, |v| v as usize);
let backward = el.selection_direction().ok().flatten().as_deref() == Some("backward");
let (anchor16, caret16) = rux_selection(start16, end16, backward);
let caret = utf16_to_byte_index(&value, caret16);
let anchor = utf16_to_byte_index(&value, anchor16);
let composing = WEB_COMPOSING.with(|c| *c.borrow()).min(caret);
WEB_PROXY.with(|p| {
if let Some(proxy) = p.borrow().as_ref() {
let _ = proxy.send_event(RuxEvent::WebText { value, caret, anchor, composing });
}
});
}
#[cfg(any(target_arch = "wasm32", test))]
fn browser_selection(anchor: u32, caret: u32) -> (u32, u32, &'static str) {
if anchor <= caret {
(anchor, caret, "forward")
} else {
(caret, anchor, "backward")
}
}
#[cfg(any(target_arch = "wasm32", test))]
fn rux_selection(start: usize, end: usize, backward: bool) -> (usize, usize) {
if backward {
(end, start)
} else {
(start, end)
}
}
#[cfg(any(target_arch = "wasm32", test))]
fn utf16_to_byte_index(s: &str, units: usize) -> usize {
let mut seen = 0;
for (byte, ch) in s.char_indices() {
if seen >= units {
return byte;
}
let next = seen + ch.len_utf16();
if next > units {
return byte;
}
seen = next;
}
s.len()
}
#[cfg(any(target_arch = "wasm32", test))]
fn byte_to_utf16_index(s: &str, byte: usize) -> usize {
s[..floor_char_boundary(s, byte)].chars().map(char::len_utf16).sum()
}
#[cfg(any(target_arch = "wasm32", test))]
fn floor_char_boundary(s: &str, mut index: usize) -> usize {
index = index.min(s.len());
while index > 0 && !s.is_char_boundary(index) {
index -= 1;
}
index
}
#[cfg(test)]
mod caret_index {
use super::{
Instant, TAP_SLOP, TouchText, browser_selection, byte_to_utf16_index, toolbar_layout,
floor_char_boundary, rux_selection, touch_text_after_move, utf16_to_byte_index,
};
#[test]
fn ascii_indices_are_the_same_in_both_counts() {
let s = "hello";
for i in 0..=s.len() {
assert_eq!(utf16_to_byte_index(s, i), i);
assert_eq!(byte_to_utf16_index(s, i), i);
}
}
#[test]
fn a_cjk_caret_converts_both_ways() {
let s = "日本語";
assert_eq!(utf16_to_byte_index(s, 0), 0);
assert_eq!(utf16_to_byte_index(s, 1), 3);
assert_eq!(utf16_to_byte_index(s, 3), 9);
assert_eq!(byte_to_utf16_index(s, 3), 1);
assert_eq!(byte_to_utf16_index(s, 9), 3);
}
#[test]
fn a_surrogate_pair_never_yields_an_index_inside_a_character() {
let s = "a🙂b";
assert_eq!(utf16_to_byte_index(s, 1), 1);
assert_eq!(utf16_to_byte_index(s, 2), 1, "mid-surrogate falls back to the start");
assert_eq!(utf16_to_byte_index(s, 3), 5);
assert_eq!(byte_to_utf16_index(s, 5), 3);
for i in 0..=s.len() {
assert!(s.is_char_boundary(utf16_to_byte_index(s, i)));
}
}
#[test]
fn indices_past_the_end_clamp() {
let s = "ab";
assert_eq!(utf16_to_byte_index(s, 99), 2);
assert_eq!(byte_to_utf16_index(s, 99), 2);
assert_eq!(floor_char_boundary(s, 99), 2);
assert_eq!(floor_char_boundary("é", 1), 0);
}
#[test]
fn a_selection_keeps_which_end_the_caret_is_at() {
assert_eq!(browser_selection(2, 7), (2, 7, "forward"));
assert_eq!(browser_selection(7, 2), (2, 7, "backward"), "dragged leftwards");
assert_eq!(browser_selection(4, 4), (4, 4, "forward"), "collapsed");
assert_eq!(rux_selection(2, 7, false), (2, 7));
assert_eq!(rux_selection(2, 7, true), (7, 2), "caret at the left end");
assert_eq!(rux_selection(4, 4, false), (4, 4));
}
#[test]
fn the_toolbar_sits_where_its_buttons_are_hit() {
let viewport = (400.0, 800.0);
let ((x, y, w, h), buttons) = toolbar_layout((20.0, 300.0, 200.0, 40.0), viewport);
assert_eq!(buttons.len(), 4);
assert!((buttons[0].1 - x).abs() < f32::EPSILON, "first starts at the panel");
let mut edge = x;
for (_, bx, by, bw, bh) in &buttons {
assert!((bx - edge).abs() < 0.001, "buttons are contiguous");
assert_eq!((*by, *bh), (y, h), "all share the strip's line");
edge += bw;
}
assert!((edge - (x + w)).abs() < 0.001, "and fill it exactly");
assert!(y + h < 300.0, "sits above the field: {y}");
let ((_, below_y, _, _), _) = toolbar_layout((20.0, 0.0, 200.0, 40.0), viewport);
assert!(below_y >= 40.0, "drops below the field instead: {below_y}");
let ((right_x, _, right_w, _), _) = toolbar_layout((380.0, 300.0, 200.0, 40.0), viewport);
assert!(right_x >= 0.0, "never off the left edge");
assert!(right_x + right_w <= viewport.0 + 0.001, "nor off the right: {right_x}");
}
#[test]
fn a_finger_that_moves_before_the_long_press_drags_the_caret() {
let pending = TouchText::Pending { at: (0.0, 0.0), deadline: Instant::now() };
assert_eq!(touch_text_after_move(pending, 0.0), pending);
assert_eq!(touch_text_after_move(pending, TAP_SLOP), pending);
assert_eq!(touch_text_after_move(pending, TAP_SLOP + 0.1), TouchText::Caret);
assert_eq!(touch_text_after_move(TouchText::Caret, 0.0), TouchText::Caret);
assert_eq!(touch_text_after_move(TouchText::Caret, 500.0), TouchText::Caret);
assert_eq!(touch_text_after_move(TouchText::Selecting, 0.0), TouchText::Selecting);
assert_eq!(touch_text_after_move(TouchText::Selecting, 500.0), TouchText::Selecting);
}
#[test]
fn pushing_a_selection_and_reading_it_back_is_lossless() {
for (anchor, caret) in [(0u32, 0u32), (0, 5), (5, 0), (3, 9), (9, 3), (4, 4)] {
let (start, end, direction) = browser_selection(anchor, caret);
let backward = direction == "backward";
let (back_anchor, back_caret) = rux_selection(start as usize, end as usize, backward);
assert_eq!(
(back_anchor as u32, back_caret as u32),
(anchor, caret),
"round trip changed ({anchor}, {caret})"
);
}
}
}
#[cfg(target_arch = "wasm32")]
pub fn start_web(
canvas: web_sys::HtmlCanvasElement,
source: String,
font: Vec<u8>,
base: Option<String>,
) {
use winit::platform::web::EventLoopExtWebSys;
let mut document = match Document::from_source(&source) {
Ok(doc) => doc,
Err(err) => {
web_sys::console::error_1(&format!("rux: {err}").into());
Document::from_source("<template><screen></screen></template>").expect("empty document")
}
};
if let Some(base) = base {
WEB_BASE.with(|b| *b.borrow_mut() = Some(base));
if let Some(route) = web_route_now() {
document.start_at(&route);
}
web_watch_history();
}
let event_loop = EventLoop::<RuxEvent>::with_user_event()
.build()
.expect("create event loop");
event_loop.set_control_flow(ControlFlow::Wait);
let (mut lw, mut lh) = (canvas.client_width() as f64, canvas.client_height() as f64);
if lw <= 0.0 || lh <= 0.0 {
lw = canvas.width() as f64;
lh = canvas.height() as f64;
}
if lw > 0.0 && lh > 0.0 {
WEB_SIZE.with(|s| *s.borrow_mut() = (lw, lh));
}
WEB_CANVAS.with(|c| *c.borrow_mut() = Some(canvas));
WEB_PROXY.with(|p| *p.borrow_mut() = Some(event_loop.create_proxy()));
let mut app = App::new(document);
if !app.text.register_font(font) {
web_sys::console::error_1(&"rux: the supplied font had no usable faces, so text will not render".into());
}
event_loop.spawn_app(app);
}
#[cfg(target_arch = "wasm32")]
pub fn resize_web(w: f64, h: f64) {
WEB_SIZE.with(|s| *s.borrow_mut() = (w, h));
WEB_PROXY.with(|p| {
if let Some(proxy) = p.borrow().as_ref() {
let _ = proxy.send_event(RuxEvent::Resize(w, h));
}
});
}
#[cfg(target_arch = "wasm32")]
pub fn set_web_source(source: String) -> Option<String> {
if let Err(err) = Document::from_source(&source) {
return Some(err.to_string());
}
WEB_PROXY.with(|p| {
if let Some(proxy) = p.borrow().as_ref() {
let _ = proxy.send_event(RuxEvent::SetSource(source));
}
});
None
}
#[cfg(target_arch = "wasm32")]
pub fn diagnose_web_source(source: String) -> String {
let (error, warnings) = match Document::from_source_checked(&source) {
Err(err) => {
let line = err.line.map(|l| l.to_string()).unwrap_or_else(|| "null".into());
let column = err.column.map(|c| c.to_string()).unwrap_or_else(|| "null".into());
let error = format!(
"{{\"message\": {}, \"line\": {line}, \"column\": {column}}}",
rux_runtime::json_string(&err.message)
);
(error, String::from("[]"))
}
Ok(doc) => {
let warnings: Vec<String> =
doc.diagnostics().warnings.iter().map(|w| w.to_json()).collect();
WEB_PROXY.with(|p| {
if let Some(proxy) = p.borrow().as_ref() {
let _ = proxy.send_event(RuxEvent::SetSource(source));
}
});
(String::from("null"), format!("[{}]", warnings.join(", ")))
}
};
format!("{{\"error\": {error}, \"warnings\": {warnings}}}")
}
#[cfg(not(target_arch = "wasm32"))]
pub fn run(path: PathBuf) {
run_at(path, None)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn run_at(path: PathBuf, route: Option<String>) {
let event_loop = EventLoop::<RuxEvent>::with_user_event()
.build()
.expect("create event loop");
event_loop.set_control_flow(ControlFlow::Wait);
let proxy = event_loop.create_proxy();
let watch_dir = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
if !matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
return;
}
let touches_source = event
.paths
.iter()
.any(|p| p.extension().is_some_and(|e| e == "rux" || e == "css"));
if touches_source {
let _ = proxy.send_event(RuxEvent::Reload);
}
})
.expect("create watcher");
watcher
.watch(&watch_dir, RecursiveMode::Recursive)
.expect("watch directory");
let mut app = App::new(path, event_loop.create_proxy());
if let Some(route) = route {
app.document.start_at(&route);
}
event_loop.run_app(&mut app).expect("run app");
drop(watcher); }
#[cfg(test)]
mod tests {
use super::*;
use rux_runtime::{Diagnostics, Warning};
fn warned(message: &str) -> Diagnostics {
Diagnostics { warnings: vec![Warning::new(message)], ..Diagnostics::default() }
}
fn focusable(y: f32, scroll: Option<usize>) -> FocusItem {
FocusItem {
x: 40.0,
y,
width: 200.0,
height: 50.0,
kind: FocusKind::Activate { on_tap: String::new(), instance: None },
scroll,
}
}
fn scroller() -> ScrollRegion {
ScrollRegion {
id: 0,
x: 30.0,
y: 100.0,
width: 220.0,
height: 220.0,
content_width: 220.0,
content_height: 600.0,
max: Offset { x: 0.0, y: 380.0 },
}
}
#[test]
fn a_focus_ring_outside_a_scroller_is_unclipped() {
assert_eq!(focus_ring(&focusable(150.0, None), None).len(), 1);
}
#[test]
fn a_focus_ring_inside_a_scroller_is_clipped_to_it() {
let paints = focus_ring(&focusable(150.0, Some(0)), Some(&scroller()));
assert_eq!(paints.len(), 3, "a clip, the ring, and the matching pop");
assert!(matches!(paints[0], Paint::PushClip { .. }), "{:?}", paints[0]);
assert!(matches!(paints[2], Paint::PopClip), "{:?}", paints[2]);
}
#[test]
fn a_focus_ring_scrolled_out_of_view_is_not_drawn() {
let above = focus_ring(&focusable(-90.0, Some(0)), Some(&scroller()));
assert!(above.is_empty(), "scrolled off the top: {above:?}");
let below = focus_ring(&focusable(400.0, Some(0)), Some(&scroller()));
assert!(below.is_empty(), "scrolled off the bottom: {below:?}");
let edge = focus_ring(&focusable(90.0, Some(0)), Some(&scroller()));
assert_eq!(edge.len(), 3, "partly visible, so still drawn: {edge:?}");
}
#[test]
fn dismissing_the_overlay_hides_it() {
let diag = warned("float does nothing");
assert!(overlay_visible(&diag, None), "shown before it is dismissed");
assert!(!overlay_visible(&diag, Some(&diag)), "hidden after");
}
#[test]
fn a_dismissed_overlay_returns_when_the_diagnostics_change() {
let dismissed = warned("float does nothing");
let another_warning = warned("`:nope` is not supported");
assert!(overlay_visible(&another_warning, Some(&dismissed)));
let now_broken = Diagnostics {
error: Some("parse error".into()),
stale: true,
warnings: dismissed.warnings.clone(),
};
assert!(
overlay_visible(&now_broken, Some(&dismissed)),
"an error arriving after a dismissed warning must show"
);
}
#[test]
fn nothing_wrong_means_no_overlay() {
let clean = Diagnostics::default();
assert!(!overlay_visible(&clean, None));
assert!(!overlay_visible(&clean, Some(&warned("old"))));
}
fn tall() -> ScrollRegion {
ScrollRegion {
id: 0,
x: 0.0,
y: 0.0,
width: 200.0,
height: 200.0,
content_width: 200.0,
content_height: 500.0,
max: Offset { x: 0.0, y: 300.0 },
}
}
#[test]
fn thumb_is_proportional_to_the_content() {
let (x, y, w, h) = bar_thumb(&tall(), Offset::default(), Axis2::Y).expect("a thumb");
assert_eq!(h, 80.0, "200/500 of a 200px track");
assert_eq!(y, 0.0, "unscrolled thumb starts at the top of the track");
assert_eq!(w, BAR_W);
assert_eq!(x, 200.0 - BAR_W, "the bar hugs the box's right edge");
}
#[test]
fn horizontal_thumb_lies_along_the_bottom_edge() {
let mut wide = tall();
wide.content_height = 200.0;
wide.content_width = 500.0;
wide.max = Offset { x: 300.0, y: 0.0 };
let (x, y, w, h) = bar_thumb(&wide, Offset::default(), Axis2::X).expect("a thumb");
assert_eq!(h, BAR_W, "a horizontal thumb is BAR_W *thick*, not BAR_W long");
assert_eq!(w, 80.0, "200/500 of a 200px track");
assert_eq!(x, 0.0);
assert_eq!(y, 200.0 - BAR_W, "it sits on the box's bottom edge");
}
#[test]
fn thumb_reaches_the_end_of_the_track() {
let r = tall();
let (_, y, _, h) = bar_thumb(&r, Offset { x: 0.0, y: 300.0 }, Axis2::Y).expect("a thumb");
assert_eq!(y + h, r.height);
}
#[test]
fn no_thumb_on_an_axis_that_does_not_scroll() {
assert!(bar_thumb(&tall(), Offset::default(), Axis2::X).is_none());
let mut fits = tall();
fits.content_height = 200.0;
fits.max = Offset::default();
assert!(bar_thumb(&fits, Offset::default(), Axis2::Y).is_none());
assert!(!fits.scrollable());
}
#[test]
fn thumb_has_a_floor() {
let mut huge = tall();
huge.content_height = 100_000.0;
huge.max = Offset { x: 0.0, y: 99_800.0 };
let (_, _, _, h) = bar_thumb(&huge, Offset::default(), Axis2::Y).expect("a thumb");
assert_eq!(h, BAR_MIN_THUMB);
}
#[test]
fn tracks_leave_the_corner_free() {
let mut both = tall();
both.content_width = 500.0;
both.max.x = 300.0;
let (_, _, _, vh) = bar_track(&both, Axis2::Y);
let (_, _, hw, _) = bar_track(&both, Axis2::X);
assert_eq!(vh, both.height - BAR_W);
assert_eq!(hw, both.width - BAR_W);
let (_, _, _, full) = bar_track(&tall(), Axis2::Y);
assert_eq!(full, 200.0);
}
}