//! Native developer workspace. Coordinates and scroll offsets are logical pixels. use crate::{ activity::{Activity, Exchange}, app::AppEvent, display_list::{Color, DisplayList, DrawCommand}, document::{CodeNode, RenderDocument}, geometry::{Point, Rect}, sources::SourceWorkspace, system_ui::{ Axis, Button, Cursor, Interaction, Role, Target, Ui, WidgetId, theme::{BAR, BG, BORDER, ERROR, MUTED, OK, PALETTE, TEXT, Theme, readable_accent}, }, text::{self, TextStyle}, }; use std::{ io::{Read, Write}, net::TcpStream, thread, time::Duration, }; use unicode_segmentation::UnicodeSegmentation; mod console; mod network; mod painter; mod selection; mod settings; mod sources; use painter::*; use selection::SourceSelection; const ROW: f32 = 19.0; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum Tab { #[default] Console, Sources, Network, Settings, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Region { Transcript = 0, Tree, Source, Requests, Details, } #[derive(Clone, Copy, Debug, Default)] struct Scroll { x: f32, y: f32, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Focus { Source, Input, Filter, Custom, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Action { ClearConsole, ClearNetwork, Follow, ResetSession, Focus(Focus), Snapshot, Cloud, CloudRefresh, CloudOpen(String), Node(String, usize), Request(u64), Theme(usize), } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Split { Tree, Network, } #[derive(Clone, Copy, Debug)] enum Drag { Split(Split), Scroll(Region, bool, f32), } /// Each workspace keeps its own input, focus, selection, scroll and request state. /// Only appearance and the window-level launcher are shared. pub struct Workspace { states: [DeveloperToolsState; 4], active: usize, } impl Workspace { pub fn new(proxy: winit::event_loop::EventLoopProxy) -> Self { let states = [Tab::Console, Tab::Sources, Tab::Network, Tab::Settings].map(|tab| { DeveloperToolsState { tab, proxy: Some(proxy.clone()), ..Default::default() } }); Self { states, active: 0 } } pub fn select(&mut self, name: Option<&str>) { let next = match name { Some("Sources") => 1, Some("Network") => 2, Some("Settings") => 3, _ => 0, }; let accent = self.states[self.active].accent; if next != self.active { self.states[self.active].blur(); } self.active = next; self.states[next].accent = accent; self.states[next].open = name.is_some(); } } impl std::ops::Deref for Workspace { type Target = DeveloperToolsState; fn deref(&self) -> &Self::Target { &self.states[self.active] } } impl std::ops::DerefMut for Workspace { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.states[self.active] } } #[derive(Debug)] pub struct DeveloperToolsState { pub open: bool, pointer: Point, source_selection: SourceSelection, pub interaction: Interaction, pub repaint: bool, pub proxy: Option>, pub cloud_sources: SourceWorkspace, pub tab: Tab, pub selected_node_id: Option, pub focus: Option, pub input: String, pub cursor: usize, pub selection: bool, pub preedit: String, pub filter: String, pub custom: String, pub accent: Color, pub input_error: String, tree_width: f32, network_fraction: f32, scroll: [Scroll; 5], widths: [Option<(u64, f32)>; 5], reveal_input: bool, console_stamp: u64, drag: Option, cloud: bool, follow: bool, selected_request: Option, console_after: u64, network_after: u64, history: Vec, history_index: Option, draft: String, } impl Default for DeveloperToolsState { fn default() -> Self { Self { open: false, pointer: Point::default(), source_selection: SourceSelection::default(), interaction: Interaction::default(), repaint: false, proxy: None, cloud_sources: SourceWorkspace::default(), tab: Tab::Console, selected_node_id: None, focus: None, input: String::new(), cursor: 0, selection: false, preedit: String::new(), filter: String::new(), custom: "#1671D5".into(), accent: PALETTE[0], input_error: String::new(), tree_width: 190.0, network_fraction: 0.42, scroll: [Scroll::default(); 5], widths: [None; 5], reveal_input: true, console_stamp: 0, drag: None, cloud: false, follow: true, selected_request: None, console_after: 0, network_after: 0, history: Vec::new(), history_index: None, draft: String::new(), } } } #[derive(Clone, Debug, Default)] pub struct DeveloperToolsLayout { pub panel: Rect, pub widgets: Vec>, splits: Vec<(Rect, Split)>, regions: Vec<(Rect, Region, f32, f32)>, thumbs: Vec<(Rect, Region, bool, f32)>, input: Option, source: Option<(Rect, Point, Vec)>, } impl DeveloperToolsLayout { pub fn translate(&mut self, dx: f32, dy: f32) { self.panel.x += dx; self.panel.y += dy; for target in &mut self.widgets { target.rect.x += dx; target.rect.y += dy; } for (rect, _) in &mut self.splits { rect.x += dx; rect.y += dy; } for (rect, _, _, _) in &mut self.regions { rect.x += dx; rect.y += dy; } for (rect, _, _, _) in &mut self.thumbs { rect.x += dx; rect.y += dy; } if let Some(input) = &mut self.input { input.viewport.x += dx; input.viewport.y += dy; input.origin.x += dx; input.origin.y += dy; } if let Some((area, origin, _)) = &mut self.source { area.x += dx; area.y += dy; origin.x += dx; origin.y += dy; } } } #[derive(Clone, Copy, Debug)] struct ConsoleInputLayout { viewport: Rect, origin: Point, } impl DeveloperToolsState { pub fn document_updated(&mut self, doc: &RenderDocument) { if self .selected_node_id .as_deref() .is_none_or(|id| doc.node(id).is_none()) { self.selected_node_id = doc.root.as_ref().map(|n| n.id.clone()); } } pub fn cursor_kind(&self, layout: &DeveloperToolsLayout) -> winit::window::CursorIcon { use winit::window::CursorIcon; if !self.open { return CursorIcon::Default; } if layout .source .as_ref() .is_some_and(|(area, _, _)| area.contains(self.pointer)) { return CursorIcon::Text; } match self.interaction.cursor(&layout.widgets) { Cursor::Default => CursorIcon::Default, Cursor::Pointer => CursorIcon::Pointer, Cursor::Text => CursorIcon::Text, Cursor::ResizeHorizontal => CursorIcon::ColResize, Cursor::ResizeVertical => CursorIcon::RowResize, Cursor::Grab => CursorIcon::Grab, Cursor::Disabled => CursorIcon::NotAllowed, } } pub fn pointer_left(&mut self) { self.interaction.pointer_left(); } pub fn blur(&mut self) { self.interaction.blur(); self.drag = None; self.focus = None; self.preedit.clear(); } pub fn press(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) { if !self.open || !layout.panel.contains(p) { return; } self.preedit.clear(); self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); let target = self.interaction.press(&layout.widgets); self.focus = None; if let Some((area, origin, lines)) = &layout.source && area.contains(p) && target.is_none() { self.focus = Some(Focus::Source); self.selection = false; self.source_selection.begin(p, *origin, lines); } if let Some(target) = target { match target.role { Role::Splitter(_) => { if let Some((_, split)) = layout.splits.iter().find(|(r, _)| { Rect::new(r.x - 3.0, r.y - 3.0, r.width + 6.0, r.height + 6.0).contains(p) }) { self.drag = Some(Drag::Split(*split)); } } Role::Scrollbar(_) => { if let Some((_, region, h, ratio)) = layout.thumbs.iter().find(|(r, _, _, _)| r.contains(p)) { self.drag = Some(Drag::Scroll(*region, *h, *ratio)); } } Role::TextField => self.activate(target.action, layout, p, activity), _ => {} } } } fn activate( &mut self, action: Option, layout: &DeveloperToolsLayout, p: Point, activity: &Activity, ) { self.focus = None; match action { Some(Action::Focus(f)) => { self.focus = Some(f); self.selection = false; if f == Focus::Input && let Some(input) = layout.input { let row = (((p.y - input.origin.y) / ROW).floor().max(0.0) as usize) .min(self.input.split('\n').count() - 1); let mut start = 0; let line = self .input .split('\n') .enumerate() .find_map(|(i, line)| { if i == row { Some(line) } else { start += line.len() + 1; None } }) .unwrap_or(""); let target = (p.x - input.origin.x).max(0.0); let mut offset = line.len(); for (i, part) in line.grapheme_indices(true) { let advance = text::measure(&line[..i + part.len()], TextStyle::code()).width; if advance >= target { offset = i; break; } } self.cursor = (start + offset).min(self.input.len()); self.reveal_cursor(); } } Some(Action::ClearConsole) => { self.console_after = activity.lock().unwrap().records.back().map_or(0, |r| r.id); self.scroll[Region::Transcript as usize] = Scroll::default(); self.reveal_cursor(); } Some(Action::ClearNetwork) => { self.network_after = activity.lock().unwrap().records.back().map_or(0, |r| r.id); self.selected_request = None; } Some(Action::Follow) => { self.follow = !self.follow; self.console_stamp = 0; } Some(Action::ResetSession) => self.submit_console(true), Some(Action::Snapshot) => self.cloud = false, Some(Action::Cloud) => self.cloud = true, Some(Action::CloudRefresh) => self.cloud_sources.refresh(), Some(Action::CloudOpen(id)) => { self.cloud_sources.open(&id); self.scroll[Region::Source as usize] = Scroll::default(); } Some(Action::Node(id, line)) => { self.selected_node_id = Some(id); self.scroll[Region::Source as usize].x = 0.0; self.scroll[Region::Source as usize].y = line.saturating_sub(1) as f32 * ROW; } Some(Action::Request(id)) => { self.selected_request = Some(id); if self.tab == Tab::Console { self.tab = Tab::Network; self.network_after = 0; self.filter.clear(); } self.scroll[Region::Details as usize] = Scroll::default(); } Some(Action::Theme(i)) => { self.accent = PALETTE[i]; self.custom = format!( "#{:02X}{:02X}{:02X}", self.accent.red, self.accent.green, self.accent.blue ); } None => {} } } pub fn release(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) { self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); let action = self.interaction.release(&layout.widgets); self.drag = None; self.source_selection.dragging = false; if action.is_some() { self.activate(action, layout, p, activity); } } pub fn keyboard_control( &mut self, event: &winit::event::KeyEvent, shift: bool, layout: &DeveloperToolsLayout, activity: &Activity, ) -> bool { use winit::{ event::ElementState, keyboard::{Key, NamedKey}, }; if !self.open || !self.preedit.is_empty() { return false; } if event.logical_key == Key::Named(NamedKey::Escape) && self.focus.is_none() { if event.state == ElementState::Pressed { self.interaction.blur(); } return true; } if event.logical_key == Key::Named(NamedKey::Tab) && (self.focus != Some(Focus::Input) || shift) { if event.state == ElementState::Pressed && !event.repeat { let target = self.interaction.focus_next(&layout.widgets, shift); self.focus = target.and_then(|t| match t.action { Some(Action::Focus(f)) => Some(f), _ => None, }); if self.focus == Some(Focus::Input) { self.cursor = self.input.len(); self.reveal_cursor(); } } return true; } let control = self .interaction .focused(&layout.widgets) .is_some_and(|t| t.role != Role::TextField); if control && matches!( event.logical_key, Key::Named(NamedKey::Enter | NamedKey::Space) ) { if event.state == ElementState::Pressed { if !event.repeat { self.interaction.key_press(&layout.widgets); } } else { let action = self.interaction.key_release(&layout.widgets); self.activate(action, layout, Point::default(), activity); } return true; } false } pub fn move_pointer( &mut self, p: Point, delta: Point, layout: &DeveloperToolsLayout, _window_width: f32, ) { self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); if self.source_selection.dragging && let Some((area, origin, lines)) = &layout.source { if p.y < area.y || p.y > area.y + area.height - 9.0 { let direction = if p.y < area.y { -ROW } else { ROW }; self.scroll[Region::Source as usize].y = (self.scroll[Region::Source as usize].y + direction).max(0.0); } self.source_selection.extend(p, *origin, lines); self.repaint = true; } match self.drag { Some(Drag::Split(Split::Tree)) => { self.tree_width = (p.x - layout.panel.x).clamp(120.0, (layout.panel.width - 180.0).max(120.0)) } Some(Drag::Split(Split::Network)) => { self.network_fraction = ((p.y - layout.panel.y - 92.0) / (layout.panel.height - 114.0).max(1.0)) .clamp(0.2, 0.8) } Some(Drag::Scroll(r, h, ratio)) => { let s = &mut self.scroll[r as usize]; if h { s.x = (s.x + delta.x * ratio).max(0.0); } else { s.y = (s.y + delta.y * ratio).max(0.0); } if r == Region::Transcript { self.follow = false; } } None => {} } } pub fn wheel(&mut self, layout: &DeveloperToolsLayout, p: Point, dx: f32, dy: f32) { if let Some((_, r, _, _)) = layout .regions .iter() .rev() .find(|(area, _, _, _)| area.contains(p)) { let s = &mut self.scroll[*r as usize]; s.x = (s.x - dx).max(0.0); s.y = (s.y - dy).max(0.0); if *r == Region::Transcript { self.follow = false; } } } pub fn insert(&mut self, value: &str) { match self.focus { Some(Focus::Input) => { if self.input.len() + value.len() > 1_048_576 { self.input_error = "Input exceeds 1 MiB".into(); return; } if self.selection { self.input.clear(); self.cursor = 0; self.selection = false; } self.input.insert_str(self.cursor, value); self.cursor += value.len(); self.history_index = None; self.reveal_cursor(); } Some(Focus::Filter) => { if self.selection { self.filter.clear(); self.selection = false; } self.filter .extend(value.chars().filter(|c| !c.is_control()).take(200)); self.scroll[Region::Requests as usize].y = 0.0; } Some(Focus::Custom) => { if self.selection { self.custom.clear(); self.selection = false; } self.custom.extend( value .chars() .filter(|c| c.is_ascii_hexdigit() || *c == '#') .take(7), ); } Some(Focus::Source) | None => {} } } pub(crate) fn reveal_cursor(&mut self) { self.reveal_input = true; } pub fn ime_position(&self, layout: &DeveloperToolsLayout) -> Point { if let Some(input) = layout.input { let before = &self.input[..self.cursor]; let row = before.matches('\n').count() as f32; let col = before.rsplit('\n').next().unwrap_or(""); let area = input.viewport; return Point::new( (input.origin.x + text::measure(col, TextStyle::code()).width) .clamp(area.x, area.x + area.width), (input.origin.y + row * ROW).clamp(area.y, area.y + area.height), ); } Point::new(layout.panel.x + 20.0, 60.0) } pub fn selected_text(&self) -> String { if self.focus == Some(Focus::Source) { return self.source_selection.text(self.selection); } if !self.selection { return String::new(); } match self.focus { Some(Focus::Input) => self.input.clone(), Some(Focus::Filter) => self.filter.clone(), Some(Focus::Custom) => self.custom.clone(), Some(Focus::Source) | None => String::new(), } } pub fn key(&mut self, key: &winit::keyboard::NamedKey, shift: bool, command: bool) { use winit::keyboard::NamedKey::*; if *key == Escape { self.focus = None; self.preedit.clear(); self.interaction.blur(); return; } if self.focus == Some(Focus::Source) { if let Some(row) = self.source_selection.navigate(key, shift, command) { self.selection = false; self.scroll[Region::Source as usize].y = (row as f32 * ROW - ROW * 4.0).max(0.0); self.repaint = true; } return; } if self.focus == Some(Focus::Custom) && *key == Enter { let hex = self.custom.trim_start_matches('#'); if hex.len() == 6 && let Ok(v) = u32::from_str_radix(hex, 16) { self.accent = readable_accent(Color::rgb((v >> 16) as u8, (v >> 8) as u8, v as u8)); self.input_error.clear(); } else { self.input_error = "Use a six-digit hex color, e.g. #1671D5".into(); } return; } if self.focus != Some(Focus::Input) { if *key == Backspace { match self.focus { Some(Focus::Filter) => { if self.selection { self.filter.clear(); } else { self.filter.pop(); } } Some(Focus::Custom) => { if self.selection { self.custom.clear(); } else { self.custom.pop(); } } _ => {} } self.selection = false; } return; } match key { Enter if !shift => self.submit(), Enter => self.insert("\n"), Tab => self.insert(" "), Space => self.insert(" "), Backspace | Delete => { if self.selection { self.input.clear(); self.cursor = 0; self.selection = false; } else if *key == Backspace && self.cursor > 0 { let n = self.input[..self.cursor] .grapheme_indices(true) .next_back() .map_or(0, |(i, _)| i); self.input.replace_range(n..self.cursor, ""); self.cursor = n; } else if *key == Delete && self.cursor < self.input.len() { let len = self.input[self.cursor..] .graphemes(true) .next() .unwrap() .len(); self.input.replace_range(self.cursor..self.cursor + len, ""); } } ArrowLeft => { self.cursor = if command { 0 } else { self.input[..self.cursor] .grapheme_indices(true) .next_back() .map_or(0, |(i, _)| i) }; self.selection = false; } ArrowRight => { self.cursor = if command { self.input.len() } else { self.cursor + self.input[self.cursor..] .graphemes(true) .next() .map_or(0, str::len) }; self.selection = false; } Home => { self.cursor = if command { 0 } else { self.input[..self.cursor].rfind('\n').map_or(0, |i| i + 1) }; self.selection = false; } End => { self.cursor = if command { self.input.len() } else { self.input[self.cursor..] .find('\n') .map_or(self.input.len(), |i| self.cursor + i) }; self.selection = false; } ArrowUp | ArrowDown if command || !self.input.contains('\n') => { if self.history.is_empty() { return; } if self.history_index.is_none() { self.draft = self.input.clone(); } let current = self.history_index.unwrap_or(self.history.len()); let index = if *key == ArrowUp { current.saturating_sub(1) } else { (current + 1).min(self.history.len()) }; self.history_index = (index < self.history.len()).then_some(index); self.input = self .history_index .map_or_else(|| self.draft.clone(), |i| self.history[i].clone()); self.cursor = self.input.len(); } ArrowUp | ArrowDown => { let start = self.input[..self.cursor].rfind('\n').map_or(0, |i| i + 1); let col = self.input[start..self.cursor].graphemes(true).count(); let target = if *key == ArrowUp { if start == 0 { 0 } else { self.input[..start - 1].rfind('\n').map_or(0, |i| i + 1) } } else { self.input[self.cursor..] .find('\n') .map_or(self.input.len(), |i| self.cursor + i + 1) }; let end = self.input[target..] .find('\n') .map_or(self.input.len(), |i| target + i); self.cursor = target + self.input[target..end] .graphemes(true) .take(col) .map(str::len) .sum::(); self.selection = false; } Escape => { self.focus = None; self.preedit.clear(); } _ => {} } self.reveal_cursor(); } pub fn initialize_tools(&mut self, name: &str) { let name = name.to_owned(); let proxy = self.proxy.clone(); thread::spawn(move || { let source = format!("ui.developerTools({name:?})"); let send = || -> std::io::Result<()> { let mut stream = TcpStream::connect_timeout( &"127.0.0.1:4737".parse().unwrap(), Duration::from_secs(2), )?; stream.set_read_timeout(Some(Duration::from_secs(30)))?; write!( stream, "POST /v1/namespaces/app.main/repl HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}", source.len(), source )?; let mut response = String::new(); stream.read_to_string(&mut response)?; Ok(()) }; if let Err(error) = send() && let Some(proxy) = proxy { let _ = proxy.send_event(AppEvent::ConsoleFailed { message: error.to_string(), source: String::new(), }); } }); } fn submit(&mut self) { self.submit_console(false); } fn submit_console(&mut self, reset: bool) { if !reset && self.input.trim().is_empty() { return; } let source = if reset { String::new() } else { self.input.clone() }; let proxy = self.proxy.clone(); let spawned=thread::Builder::new().name("tessera-console-client".into()).spawn(move||{ let send=||->std::io::Result<()> { let mut stream=TcpStream::connect_timeout(&"127.0.0.1:4737".parse().unwrap(),Duration::from_secs(2))?; stream.set_read_timeout(Some(Duration::from_secs(120)))?;stream.set_write_timeout(Some(Duration::from_secs(10)))?; let method=if reset{"DELETE"}else{"POST"}; write!(stream,"{method} /v1/namespaces/app.main/repl HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nX-Tessera-Origin: console\r\nContent-Length: {}\r\n\r\n{}",source.len(),source)?; let mut response=String::new();stream.read_to_string(&mut response)?;Ok(()) }; if let Err(error)=send()&&let Some(proxy)=proxy{let _=proxy.send_event(AppEvent::ConsoleFailed{message:error.to_string(),source});} }); match spawned { Ok(_) => { if !reset { self.history.push(self.input.clone()); if self.history.len() > 80 { self.history.remove(0); } self.input.clear(); self.cursor = 0; self.history_index = None; self.selection = false; self.reveal_cursor(); } self.follow = true; self.input_error.clear(); } Err(error) => self.input_error = error.to_string(), } } } pub fn build( width: u32, height: u32, scale: f32, doc: &RenderDocument, state: &mut DeveloperToolsState, activity: &Activity, ) -> (DisplayList, DeveloperToolsLayout) { if !state.open { return (Default::default(), Default::default()); } let width = width as f32 / scale; let height = height as f32 / scale; let w = width; let x = 0.0; let panel = Rect::new(x, 0.0, w, height); let mut p = Painter { ui: Ui::new(Theme::new(state.accent), state.interaction.clone()), layout: DeveloperToolsLayout { panel, ..Default::default() }, }; p.fill(panel, BG); p.ui.list.push(DrawCommand::PushClip(panel)); p.fill(Rect::new(x, 0.0, w, 31.0), BAR); p.label( 12.0, 8.0, match state.tab { Tab::Console => "Console · app.main", Tab::Sources => "Sources · read only", Tab::Network => "Network · captured requests", Tab::Settings => "Settings · appearance", }, MUTED, false, ); let records = activity .lock() .unwrap() .records .iter() .cloned() .collect::>(); let bottom = height - 22.0; match state.tab { Tab::Console => console::build(&mut p, state, &records, panel), Tab::Sources => sources::build(&mut p, state, doc, panel), Tab::Network => network::build(&mut p, state, &records, panel), Tab::Settings => settings::build(&mut p, state, panel), } p.fill(Rect::new(x + 1.0, bottom, w - 1.0, 22.0), BAR); p.label( x + 9.0, bottom + 3.0, &text::fit( &match state.tab { Tab::Console => "Live session · Enter to run".into(), Tab::Sources => "Read only · drag to select · ⌘/Ctrl+C to copy".into(), Tab::Network => format!( "{} captured requests", records .iter() .filter(|r| r.id > state.network_after) .count() ), Tab::Settings => "Appearance · current window session".into(), }, w - 20.0, TextStyle::code(), ), MUTED, false, ); // Splitters must be above toolbars, pane backgrounds and status bars for their // whole length. Register them last so their hit priority matches their paint order. for (rect, kind) in p.layout.splits.clone().into_iter().rev() { p.ui.splitter( rect, WidgetId::new(("split", kind)), match kind { Split::Tree => Axis::Horizontal, Split::Network => Axis::Vertical, }, ); } p.ui.list.push(DrawCommand::PopClip); let mut scaled = DisplayList::default(); for cmd in p.ui.list.commands() { let rect = |r: &Rect| Rect::new(r.x * scale, r.y * scale, r.width * scale, r.height * scale); scaled.push(match cmd { DrawCommand::FillRoundedRect { rect: r, radius, color, } => DrawCommand::FillRoundedRect { rect: rect(r), radius: radius * scale, color: *color, }, DrawCommand::Text { origin, value, style, color, } => DrawCommand::Text { origin: Point::new(origin.x * scale, origin.y * scale), value: value.clone(), style: style.scaled(scale), color: *color, }, DrawCommand::PushClip(r) => DrawCommand::PushClip(rect(r)), DrawCommand::StrokeRoundedRect { rect: r, radius, width, color, } => DrawCommand::StrokeRoundedRect { rect: rect(r), radius: radius * scale, width: width * scale, color: *color, }, other => other.clone(), }); } p.layout.widgets = p.ui.targets; state.repaint = state.interaction.reconcile(&p.layout.widgets); (scaled, p.layout) }