system settings
git clone https://git.lucas.co/cce-system-interface.git
refactor: page widgets are handles the context owns
Each page state gets new(ctx); its Default holds Handle::none() for the copies the refresh workers build and merge field by field. Page update functions and AppPage::propagate_widget_changes take the context; row lists remove and reinsert handles when rebuilt; per-frame widgets (a timer's status dot, the usage bars) are inserted, placed and removed; page buttons are reinserted per rebuild. Forms use widget_h / widget_w_h. Shadow A/B identical on every page but for live readings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/app.rs | 42 +++---
src/input_handler.rs | 42 +++---
src/main.rs | 116 ++++++++--------
src/pages/accounts.rs | 193 ++++++++++++++-------------
src/pages/audio.rs | 135 ++++++++++---------
src/pages/bluetooth.rs | 31 +++--
src/pages/browser.rs | 148 ++++++++++++---------
src/pages/default_apps.rs | 53 ++++----
src/pages/mod.rs | 38 +++---
src/pages/network.rs | 41 +++---
src/pages/notifications.rs | 65 +++++----
src/pages/packages.rs | 235 +++++++++++++++++---------------
src/pages/power.rs | 324 ++++++++++++++++++++++++---------------------
src/pages/processes.rs | 2 +-
src/pages/services.rs | 63 +++++----
src/pages/storage.rs | 63 +++++----
src/pages/system_info.rs | 57 +++++---
src/pages/timers.rs | 104 +++++++++------
src/renderer.rs | 52 +++-----
19 files changed, 992 insertions(+), 812 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index 248a1d5..4c9ed53 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,4 +1,3 @@
-use cce_ui::widget::Owned;
use cce_ui::layout::RenderTarget;
use crate::pages::audio;
@@ -35,24 +34,25 @@ pub struct AppState {
pub notifications: notifications::NotificationsState,
}
-impl Default for AppState {
- fn default() -> Self {
+impl AppState {
+ /// Every page's state, their widgets inserted into `ctx`.
+ pub fn new(ctx: &mut cce_ui::context::UiContext) -> Self {
Self {
current_page: Page::ALL[0],
audio: audio::AudioState::default(),
- bluetooth: bluetooth::BluetoothState::default(),
- power: power::PowerState::default(),
- browser: browser::BrowserState::default(),
- default_apps: default_apps::DefaultAppsState::default(),
- network: network::NetworkState::default(),
+ bluetooth: bluetooth::BluetoothState::new(ctx),
+ power: power::PowerState::new(ctx),
+ browser: browser::BrowserState::new(ctx),
+ default_apps: default_apps::DefaultAppsState::new(ctx),
+ network: network::NetworkState::new(ctx),
processes: processes::ProcessesState::default(),
- services: services::ServicesState::default(),
- system_info: system_info::SystemState::default(),
- timers: timers::TimersState::default(),
- storage: storage::StorageState::default(),
- accounts: accounts::AccountsState::default_mock(),
- packages: packages::PackagesState::default(),
- notifications: notifications::NotificationsState::default(),
+ services: services::ServicesState::new(ctx),
+ system_info: system_info::SystemState::new(ctx),
+ timers: timers::TimersState::new(ctx),
+ storage: storage::StorageState::new(ctx),
+ accounts: accounts::AccountsState::new(ctx),
+ packages: packages::PackagesState::new(ctx),
+ notifications: notifications::NotificationsState::new(ctx),
}
}
}
@@ -205,7 +205,7 @@ pub struct PageContent {
/// (button, action, clip): clip is the innermost push_clip_rect at emission
/// time (page coords) — the renderer clamps the drawn quad, label bounds,
/// and the dispatch clone's hit rect to it.
- pub buttons: Vec<(Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>, AppAction, Option<[f32; 4]>)>,
+ pub buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, AppAction, Option<[f32; 4]>)>,
/// Glyphs placed with [`PageContent::icon`] (and a widget's own, through
/// `RenderTarget::icon` — the expanded Dropdown's chevron).
pub icons: Vec<PageIcon>,
@@ -320,7 +320,7 @@ impl PageContent {
.with_hover_bg(hover_bg)
.with_label_color(label_color);
let clip = self.clip_stack.last().copied();
- self.buttons.push((Owned::new(btn), action, clip));
+ self.buttons.push((btn, action, clip));
}
/// A button whose face is a bundled cce-icons glyph instead of a label.
@@ -345,7 +345,7 @@ impl PageContent {
btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(alpha);
}
let clip = self.clip_stack.last().copied();
- self.buttons.push((Owned::new(btn), action, clip));
+ self.buttons.push((btn, action, clip));
}
/// A row of a list, in the toolkit's list style (`Button::new_list_row`,
@@ -359,7 +359,7 @@ impl PageContent {
pub fn list_row(&mut self, x: f32, y: f32, w: f32, h: f32, action: AppAction) {
let btn = cce_ui::widget::Button::new_list_row(x, y, w, h);
let clip = self.clip_stack.last().copied();
- self.buttons.push((Owned::new(btn), action, clip));
+ self.buttons.push((btn, action, clip));
}
/// [`button_icon`](Self::button_icon) with the glyph tinted
@@ -378,7 +378,7 @@ impl PageContent {
btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(label_color[3]);
}
let clip = self.clip_stack.last().copied();
- self.buttons.push((Owned::new(btn), action, clip));
+ self.buttons.push((btn, action, clip));
}
pub fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32,
@@ -391,7 +391,7 @@ impl PageContent {
.with_label_color(label_color)
.with_left_align(true);
let clip = self.clip_stack.last().copied();
- self.buttons.push((Owned::new(btn), action, clip));
+ self.buttons.push((btn, action, clip));
}
}
diff --git a/src/input_handler.rs b/src/input_handler.rs
index 67f38b6..66b36cd 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -165,7 +165,7 @@ impl SystemInterface {
if self.search_open && state == cce_ui::widget::ElementState::Pressed && ly_no_scroll < (sh_logical - 42.0) {
self.search_open = false;
self.search_query.clear();
- self.search_box.set_value_string("");
+ self.ui_context[self.search_box].set_value_string("");
self.ui_context.clear_focus();
self.needs_rebuild = true;
}
@@ -187,8 +187,8 @@ impl SystemInterface {
let dd_ev = cce_ui::widget::Event::MouseButton { button, state, x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
let dd_root = self.page_dropdown.id();
if self.ui_context.propagate_event(&dd_ev, dd_root) {
- if self.page_dropdown.take_change() {
- let idx = self.page_dropdown.selected;
+ if self.ui_context[self.page_dropdown].take_change() {
+ let idx = self.ui_context[self.page_dropdown].selected;
if idx < Page::ALL.len() {
self.ui_context.clear_focus();
self.focused_section = None;
@@ -216,8 +216,8 @@ impl SystemInterface {
button_handled = true;
}
}
- for (btn, action) in &mut self.page_buttons[self.scrollable_buttons_start_idx..] {
- if btn.take_click() {
+ for (btn, action) in &self.page_buttons[self.scrollable_buttons_start_idx..] {
+ if self.ui_context[*btn].take_click() {
clicked_action = Some(action.clone());
button_handled = true;
self.needs_rebuild = true;
@@ -265,7 +265,7 @@ impl SystemInterface {
}
pub(crate) fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
- self.app.get_current_page_mut().propagate_widget_changes(actions);
+ self.app.get_current_page_mut().propagate_widget_changes(actions, &mut self.ui_context);
}
pub(crate) fn handle_mouse_wheel_internal(&mut self, delta: &cce_ui::widget::MouseScrollDelta, px: f32, py: f32) -> bool {
@@ -312,7 +312,7 @@ impl SystemInterface {
let moved = self.page_scroll_motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll_y));
if moved {
self.shift_page_to(self.page_scroll_motion.y.pos());
- self.page_scroll_bar.on_scroll();
+ self.ui_context[self.page_scroll_bar].on_scroll();
return true;
}
}
@@ -340,8 +340,8 @@ impl SystemInterface {
b[3] -= actual_dy;
}
}
- for (btn, _) in &mut self.page_buttons[self.scrollable_buttons_start_idx..] {
- btn.base_mut().y -= actual_dy;
+ for (btn, _) in &self.page_buttons[self.scrollable_buttons_start_idx..] {
+ self.ui_context[*btn].base_mut().y -= actual_dy;
}
// The glyphs ride with the page too — a button's icon face and the
// page's own icons — or they stand still while their rows scroll
@@ -353,7 +353,7 @@ impl SystemInterface {
}
}
self.last_scroll_y = self.scroll_y;
- self.page_scroll_bar.scroll_y = self.scroll_y;
+ self.ui_context[self.page_scroll_bar].scroll_y = self.scroll_y;
true
}
@@ -393,7 +393,7 @@ impl SystemInterface {
event,
Event::PointerMove { .. } | Event::MouseButton { .. } | Event::MouseWheel { .. }
);
- if is_pointer_event && !self.page_scroll_bar.dragging && !self.ui_context.is_dragging {
+ if is_pointer_event && !self.ui_context[self.page_scroll_bar].dragging && !self.ui_context.is_dragging {
if let Event::PointerMove { x, y, .. }
| Event::MouseButton { x, y, .. }
| Event::MouseWheel { x, y, .. } = event
@@ -428,7 +428,7 @@ impl SystemInterface {
}
}
- if self.page_scroll_bar.content_h > self.page_scroll_bar.viewport_h {
+ if self.ui_context[self.page_scroll_bar].content_h > self.ui_context[self.page_scroll_bar].viewport_h {
let mut sb_event = event.clone();
if let Event::PointerMove { y, local_y, .. }
| Event::MouseButton { y, local_y, .. }
@@ -508,7 +508,7 @@ impl SystemInterface {
{
self.search_open = false;
self.search_query.clear();
- self.search_box.set_value_string("");
+ self.ui_context[self.search_box].set_value_string("");
self.ui_context.clear_focus();
self.needs_rebuild = true;
return true;
@@ -527,10 +527,10 @@ impl SystemInterface {
if event.state == cce_ui::widget::ElementState::Pressed && !event.repeat {
if cce_ui::widget::match_key_shortcut(event, &settings_keys().open_search) {
self.search_open = true;
- self.search_box.set_value_string("");
+ self.ui_context[self.search_box].set_value_string("");
self.search_query.clear();
- cce_ui::widget::WidgetHost::focus(&mut self.search_box);
- self.ui_context.set_focused(&mut self.search_box);
+ cce_ui::widget::WidgetHost::focus(&mut self.ui_context[self.search_box]);
+ self.ui_context.set_focused_id(self.search_box.id());
self.needs_rebuild = true;
return true;
}
@@ -687,7 +687,7 @@ impl SystemInterface {
// With smoothing off the axis jumped: land the page now.
self.shift_page_to(self.page_scroll_motion.y.pos());
// Keyboard scrolling raises the bar like the wheel does.
- self.page_scroll_bar.on_scroll();
+ self.ui_context[self.page_scroll_bar].on_scroll();
self.needs_rebuild = true;
key_handled = true;
}
@@ -695,14 +695,14 @@ impl SystemInterface {
}
if self.search_open {
- if self.search_box.take_change() {
- self.search_query = self.search_box.text.clone();
+ if self.ui_context[self.search_box].take_change() {
+ self.search_query = self.ui_context[self.search_box].text.clone();
self.needs_rebuild = true;
}
- if !self.ui_context.is_focused(&self.search_box) {
+ if !self.ui_context.is_focused(&self.ui_context[self.search_box]) {
self.search_open = false;
self.search_query.clear();
- self.search_box.set_value_string("");
+ self.ui_context[self.search_box].set_value_string("");
self.needs_rebuild = true;
}
}
diff --git a/src/main.rs b/src/main.rs
index 8250ea0..0462bfc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,4 @@
-use cce_ui::widget::Owned;
+use cce_ui::widget::Handle;
use cce_ui::widget::hover_animation;
use cce_ui::cosmic_text::{Buffer, FontSystem};
@@ -75,7 +75,7 @@ struct SystemInterface {
/// (`PageContent::control_relief_marks`) — display_list slots each carve
/// back between the rects the widget drew before and after it.
popover_control_relief_marks: Vec<usize>,
- page_buttons: Vec<(Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>, AppAction)>,
+ page_buttons: Vec<(Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>, AppAction)>,
sidebar_width: f32,
header_height: f32,
@@ -139,12 +139,12 @@ struct SystemInterface {
/// a frame that only replays the cached layout paints no widget, and
/// would otherwise read as the field closing.
text_claim: Option<[f32; 4]>,
- page_dropdown: Owned<cce_ui::widget::Adapted<cce_ui::widget::input::Dropdown>>,
+ page_dropdown: Handle<cce_ui::widget::Adapted<cce_ui::widget::input::Dropdown>>,
// Switcher + Page DISSOLVED (Phase 6u): the current page is app.current_page, page
// scroll is scroll_y/max_scroll_y, and the page scrollbar is this app-owned widget
// (rendered into the window assembly, evented directly). content_h feeds it — the
// window pass reads last frame's value, exactly as the legacy Page did.
- page_scroll_bar: Owned<cce_ui::widget::Adapted<crate::scroll_bar::ScrollBar>>,
+ page_scroll_bar: Handle<cce_ui::widget::Adapted<crate::scroll_bar::ScrollBar>>,
content_h: f32,
// Section wells: body box + title tab (page coords, pre-scroll) — carved by
// display_list.
@@ -185,7 +185,7 @@ struct SystemInterface {
scroll_logs: Vec<String>,
search_open: bool,
search_query: String,
- search_box: Owned<cce_ui::widget::Adapted<cce_ui::widget::input::TextBox>>,
+ search_box: Handle<cce_ui::widget::Adapted<cce_ui::widget::input::TextBox>>,
}
@@ -219,7 +219,9 @@ impl cce_ui::engine::Application for SystemInterface {
// The app keeps calloop's sender; `AppSender` converts into it.
let sender: calloop::channel::Sender<Self::Message> = sender.into();
cce_ui::scale::set_scale_factor(1.0);
- let app = AppState::default();
+ // The context owns the widgets; the app keeps their handles.
+ let mut ui_context = cce_ui::context::UiContext::new();
+ let app = AppState::new(&mut ui_context);
// ── Background refresh channels ──
let initial_page_idx = INITIAL_PAGE_INDEX.load(std::sync::atomic::Ordering::SeqCst);
@@ -251,6 +253,11 @@ impl cce_ui::engine::Application for SystemInterface {
let font_system = cce_ui::create_font_system();
+ let search_box = ui_context.insert(
+ cce_ui::widget::input::TextBox::new(String::new())
+ .with_placeholder("Search sections & parameters...")
+ .with_draw_bg_border(false),
+ );
let mut this = Self {
app: app_state,
font_system,
@@ -303,8 +310,8 @@ impl cce_ui::engine::Application for SystemInterface {
laid_out_page: None,
refocus_rect: None,
text_claim: None,
- page_dropdown: Owned::new(page_dropdown),
- page_scroll_bar: Owned::new(crate::scroll_bar::ScrollBar::new()),
+ page_dropdown: ui_context.insert(page_dropdown),
+ page_scroll_bar: ui_context.insert(crate::scroll_bar::ScrollBar::new()),
content_h: 0.0,
page_reliefs: Vec::new(),
page_control_reliefs: Vec::new(),
@@ -317,13 +324,11 @@ impl cce_ui::engine::Application for SystemInterface {
current_page_shared,
seen_snapshots: std::collections::HashMap::new(),
sender,
- ui_context: cce_ui::context::UiContext::new(),
+ ui_context,
scroll_logs: Vec::new(),
search_open: false,
search_query: String::new(),
- search_box: Owned::new(cce_ui::widget::input::TextBox::new(String::new())
- .with_placeholder("Search sections & parameters...")
- .with_draw_bg_border(false)),
+ search_box,
};
this.app.system_info.sender = Some(this.sender.clone());
@@ -398,15 +403,15 @@ impl cce_ui::engine::Application for SystemInterface {
// path's scrolls, which shift cached geometry without a rebuild and
// used to leave the bar frozen until scrolling stopped. Sync first:
// a thumb drag drives the page, anything else drives the thumb.
- if self.page_scroll_bar.dragging {
- self.scroll_y = self.page_scroll_bar.scroll_y;
+ if self.ui_context[self.page_scroll_bar].dragging {
+ self.scroll_y = self.ui_context[self.page_scroll_bar].scroll_y;
} else {
- self.page_scroll_bar.scroll_y = self.scroll_y;
+ self.ui_context[self.page_scroll_bar].scroll_y = self.scroll_y;
}
let page_bar = {
use cce_ui::widget::WidgetHost;
- let (bx, by, bw, bh) = self.page_scroll_bar.rect();
- self.page_scroll_bar
+ let (bx, by, bw, bh) = self.ui_context[self.page_scroll_bar].rect();
+ self.ui_context[self.page_scroll_bar]
.layer_quads(Rect { x: bx, y: by, width: bw, height: bh })
};
// The idle copy: under the translucent window plate, every frame, so
@@ -602,15 +607,15 @@ impl cce_ui::engine::Application for SystemInterface {
// The window chrome, as it paints itself: the page dropdown in the status bar
// and, while open, the search box in its band.
- cce_ui::scene::painter::paint_root_into(&self.ui_context, &*self.page_dropdown, &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.page_dropdown], &mut pc);
if self.search_open {
- cce_ui::scene::painter::paint_root_into(&self.ui_context, &*self.search_box, &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.search_box], &mut pc);
}
// The page scrollbar's fore copy: over the page content at the
// raise's fade, while a scroll or drag holds it up and as it sinks
// (popovers still stack above it).
- let fade = self.page_scroll_bar.fade();
+ let fade = self.ui_context[self.page_scroll_bar].fade();
if fade > 0.001 {
for &(r, mut c) in &page_bar {
c[3] *= fade;
@@ -773,7 +778,7 @@ impl SystemInterface {
// hold runs (keeps frames coming so the sink actually renders) and on
// the raised flip itself. A redraw re-emits the bar at its new depth —
// no layout rebuild needed, display_list draws it fresh each frame.
- if self.page_scroll_bar.tick_activity(dt) {
+ if self.ui_context[self.page_scroll_bar].tick_activity(dt) {
needs_redraw = true;
}
// The page's own wheel glide / flick coast: shifts the cached
@@ -831,7 +836,7 @@ impl SystemInterface {
if !self.fresh_snapshot("audio", &s) {
continue;
}
- audio::update(&mut self.app.audio, audio::AudioMessage::Refreshed(s));
+ audio::update(&mut self.app.audio, audio::AudioMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Audio {
self.needs_rebuild = true;
}
@@ -840,7 +845,7 @@ impl SystemInterface {
if !self.fresh_snapshot("network", &s) {
continue;
}
- network::update(&mut self.app.network, network::NetworkMessage::Refreshed(s));
+ network::update(&mut self.app.network, network::NetworkMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Network {
self.needs_rebuild = true;
}
@@ -849,7 +854,7 @@ impl SystemInterface {
if !self.fresh_snapshot("timers", &s) {
continue;
}
- pages::timers::update(&mut self.app.timers, pages::timers::TimersMessage::Refreshed(s));
+ pages::timers::update(&mut self.app.timers, pages::timers::TimersMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Timers {
self.needs_rebuild = true;
}
@@ -858,7 +863,7 @@ impl SystemInterface {
if !self.fresh_snapshot("bluetooth", &s) {
continue;
}
- pages::bluetooth::update(&mut self.app.bluetooth, pages::bluetooth::BluetoothMessage::Refreshed(s));
+ pages::bluetooth::update(&mut self.app.bluetooth, pages::bluetooth::BluetoothMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Bluetooth {
self.needs_rebuild = true;
}
@@ -867,7 +872,7 @@ impl SystemInterface {
if !self.fresh_snapshot("power", &s) {
continue;
}
- pages::power::update(&mut self.app.power, pages::power::PowerMessage::Refreshed(s));
+ pages::power::update(&mut self.app.power, pages::power::PowerMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Power {
self.needs_rebuild = true;
}
@@ -891,7 +896,7 @@ impl SystemInterface {
if !self.fresh_snapshot("storage", &s) {
continue;
}
- storage::update(&mut self.app.storage, storage::StorageMessage::Refreshed(s));
+ storage::update(&mut self.app.storage, storage::StorageMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Storage {
self.needs_rebuild = true;
}
@@ -900,7 +905,7 @@ impl SystemInterface {
if !self.fresh_snapshot("notifications", &s) {
continue;
}
- pages::notifications::update(&mut self.app.notifications, pages::notifications::NotificationsMessage::Refreshed(s));
+ pages::notifications::update(&mut self.app.notifications, pages::notifications::NotificationsMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Notifications {
self.needs_rebuild = true;
}
@@ -909,7 +914,7 @@ impl SystemInterface {
if !self.fresh_snapshot("browser", &s) {
continue;
}
- pages::browser::update(&mut self.app.browser, pages::browser::BrowserMessage::Refreshed(s));
+ pages::browser::update(&mut self.app.browser, pages::browser::BrowserMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Browser {
self.needs_rebuild = true;
}
@@ -918,7 +923,7 @@ impl SystemInterface {
if !self.fresh_snapshot("default_apps", &s) {
continue;
}
- pages::default_apps::update(&mut self.app.default_apps, pages::default_apps::DefaultAppsMessage::Refreshed(s));
+ pages::default_apps::update(&mut self.app.default_apps, pages::default_apps::DefaultAppsMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::DefaultApps {
self.needs_rebuild = true;
}
@@ -927,7 +932,7 @@ impl SystemInterface {
if !self.fresh_snapshot("services", &s) {
continue;
}
- services::update(&mut self.app.services, services::ServicesMessage::Refreshed(s));
+ services::update(&mut self.app.services, services::ServicesMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Services {
self.needs_rebuild = true;
}
@@ -937,7 +942,7 @@ impl SystemInterface {
if !self.fresh_snapshot("accounts", &s) {
continue;
}
- accounts::update(&mut self.app.accounts, accounts::AccountsMessage::Refreshed(s));
+ accounts::update(&mut self.app.accounts, accounts::AccountsMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Accounts {
self.needs_rebuild = true;
}
@@ -952,7 +957,7 @@ impl SystemInterface {
if !self.fresh_snapshot("packages", &s) {
continue;
}
- pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::Refreshed(s));
+ pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::Refreshed(s), &mut self.ui_context);
if self.app.current_page == Page::Packages {
self.needs_rebuild = true;
}
@@ -979,20 +984,20 @@ impl SystemInterface {
}
match action {
AppAction::Exit | AppAction::Wake => {}
- AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone()),
- AppAction::Network(m) => network::update(&mut self.app.network, m.clone()),
- AppAction::Bluetooth(m) => pages::bluetooth::update(&mut self.app.bluetooth, m.clone()),
- AppAction::Power(m) => pages::power::update(&mut self.app.power, m.clone()),
- AppAction::Timers(m) => pages::timers::update(&mut self.app.timers, m.clone()),
+ AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone(), &mut self.ui_context),
+ AppAction::Network(m) => network::update(&mut self.app.network, m.clone(), &mut self.ui_context),
+ AppAction::Bluetooth(m) => pages::bluetooth::update(&mut self.app.bluetooth, m.clone(), &mut self.ui_context),
+ AppAction::Power(m) => pages::power::update(&mut self.app.power, m.clone(), &mut self.ui_context),
+ AppAction::Timers(m) => pages::timers::update(&mut self.app.timers, m.clone(), &mut self.ui_context),
AppAction::SystemInfo(m) => system_info::update(&mut self.app.system_info, m.clone(), &mut self.ui_context),
AppAction::Processes(m) => processes::update(&mut self.app.processes, m.clone()),
- AppAction::Services(m) => services::update(&mut self.app.services, m.clone()),
- AppAction::DefaultApps(m) => pages::default_apps::update(&mut self.app.default_apps, m.clone()),
- AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone()),
- AppAction::Browser(m) => pages::browser::update(&mut self.app.browser, m.clone()),
+ AppAction::Services(m) => services::update(&mut self.app.services, m.clone(), &mut self.ui_context),
+ AppAction::DefaultApps(m) => pages::default_apps::update(&mut self.app.default_apps, m.clone(), &mut self.ui_context),
+ AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone(), &mut self.ui_context),
+ AppAction::Browser(m) => pages::browser::update(&mut self.app.browser, m.clone(), &mut self.ui_context),
AppAction::Storage(m) => match m {
pages::storage::StorageMessage::StartBackup => {
- pages::storage::update(&mut self.app.storage, pages::storage::StorageMessage::StartBackup);
+ pages::storage::update(&mut self.app.storage, pages::storage::StorageMessage::StartBackup, &mut self.ui_context);
let tx = self.tx_backup.clone();
let wake = self.sender.clone();
tokio::spawn(async move {
@@ -1001,7 +1006,7 @@ impl SystemInterface {
let _ = wake.send(AppAction::Wake);
});
}
- _ => pages::storage::update(&mut self.app.storage, m.clone()),
+ _ => pages::storage::update(&mut self.app.storage, m.clone(), &mut self.ui_context),
},
@@ -1016,23 +1021,24 @@ impl SystemInterface {
pages::accounts::AccountsMessage::StatusMessage(
"A Google sign-in is already waiting on the browser.".to_string(),
),
+ &mut self.ui_context,
);
} else {
- pages::accounts::update(&mut self.app.accounts, m.clone());
+ pages::accounts::update(&mut self.app.accounts, m.clone(), &mut self.ui_context);
let sender = self.sender.clone();
tokio::spawn(async move {
pages::accounts::run_google_login(sender).await;
});
}
}
- _ => pages::accounts::update(&mut self.app.accounts, m.clone()),
+ _ => pages::accounts::update(&mut self.app.accounts, m.clone(), &mut self.ui_context),
},
AppAction::Packages(m) => match m {
pages::packages::PackagesMessage::StartUpdate => {
if self.app.packages.busy() {
return;
}
- pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::StartUpdate);
+ pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::StartUpdate, &mut self.ui_context);
let tx = self.tx_update.clone();
let wake = self.sender.clone();
tokio::spawn(async move {
@@ -1042,7 +1048,7 @@ impl SystemInterface {
});
}
pages::packages::PackagesMessage::UpdateFinished(res) => {
- pages::packages::update(&mut self.app.packages, m.clone());
+ pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
if res.is_ok() {
let tx = self.tx_update.clone();
let wake = self.sender.clone();
@@ -1056,7 +1062,7 @@ impl SystemInterface {
pages::packages::PackagesMessage::SelectPackage(Some(ref name)) => {
let name_clone = name.clone();
let is_installed = self.app.packages.active_tab == pages::packages::PackageTab::Installed;
- pages::packages::update(&mut self.app.packages, m.clone());
+ pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
let tx = self.tx_update.clone();
let wake = self.sender.clone();
tokio::spawn(async move {
@@ -1067,7 +1073,7 @@ impl SystemInterface {
}
pages::packages::PackagesMessage::SelectAndScrollPackage(ref name) => {
let name_clone = name.clone();
- pages::packages::update(&mut self.app.packages, m.clone());
+ pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
let tx = self.tx_update.clone();
let wake = self.sender.clone();
tokio::spawn(async move {
@@ -1078,7 +1084,7 @@ impl SystemInterface {
}
pages::packages::PackagesMessage::PreviewRemoval(ref targets) => {
if !targets.is_empty() && !self.app.packages.busy() {
- pages::packages::update(&mut self.app.packages, m.clone());
+ pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
let targets = targets.clone();
let tx = self.tx_update.clone();
let wake = self.sender.clone();
@@ -1091,7 +1097,7 @@ impl SystemInterface {
}
pages::packages::PackagesMessage::StartUninstall(ref targets) => {
if !targets.is_empty() && !self.app.packages.busy() {
- pages::packages::update(&mut self.app.packages, m.clone());
+ pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
let targets = targets.clone();
let tx = self.tx_update.clone();
let wake = self.sender.clone();
@@ -1104,7 +1110,7 @@ impl SystemInterface {
}
pages::packages::PackagesMessage::SetInstallReason(ref targets, explicit) => {
if !targets.is_empty() && !self.app.packages.busy() {
- pages::packages::update(&mut self.app.packages, m.clone());
+ pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
let (targets, explicit) = (targets.clone(), *explicit);
let tx = self.tx_update.clone();
let wake = self.sender.clone();
@@ -1119,7 +1125,7 @@ impl SystemInterface {
// orphan set, which either can change) is pacman's to report.
pages::packages::PackagesMessage::UninstallFinished(..)
| pages::packages::PackagesMessage::InstallReasonSet(..) => {
- pages::packages::update(&mut self.app.packages, m.clone());
+ pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
let tx = self.tx_update.clone();
let wake = self.sender.clone();
// The details pane shows the install reason; re-read it.
@@ -1134,7 +1140,7 @@ impl SystemInterface {
let _ = wake.send(AppAction::Wake);
});
}
- _ => pages::packages::update(&mut self.app.packages, m.clone()),
+ _ => pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context),
},
}
}
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index fbf458a..100cde4 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -1,5 +1,6 @@
use crate::app::{form_button, form_divider, form_pairs, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::layout::{lay_row, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
use cce_ui::scene::layout::Rect;
use cce_ui::widget::ScrollRegion;
@@ -81,10 +82,10 @@ pub struct AccountsState {
pub accounts: Vec<AccountInfo>,
pub selected_idx: Option<usize>,
pub adding_new: bool,
- pub email_box: Owned<cce_ui::widget::Adapted<TextBox>>,
- pub password_box: Owned<cce_ui::widget::Adapted<TextBox>>,
- pub imap_box: Owned<cce_ui::widget::Adapted<TextBox>>,
- pub smtp_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+ pub email_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+ pub password_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+ pub imap_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+ pub smtp_box: Handle<cce_ui::widget::Adapted<TextBox>>,
pub status_msg: Option<String>,
pub status_msg_timer: f32,
pub oauth_listener_running: bool,
@@ -94,8 +95,8 @@ pub struct AccountsState {
pub editing_email: Option<String>,
/// Per-account OAuth credentials — the copy in `accounts.json` that
/// cce-mail actually refreshes with, not the global template.
- pub oauth_client_id_box: Owned<cce_ui::widget::Adapted<TextBox>>,
- pub oauth_client_secret_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+ pub oauth_client_id_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+ pub oauth_client_secret_box: Handle<cce_ui::widget::Adapted<TextBox>>,
/// Per-address keyring status from the last snapshot, plus optimistic
/// updates from Save/Delete (the 3s watcher pass corrects them).
pub keyring: std::collections::HashMap<String, KeyringStatus>,
@@ -108,21 +109,22 @@ pub struct AccountsState {
}
impl AccountsState {
- pub fn default_mock() -> Self {
+ /// The page's state, its form fields inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
let mut state = Self::default();
- state.email_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Email Address"));
+ state.email_box = ctx.insert(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Email Address"));
state.password_box = {
let mut tb = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Password / App Password");
tb.is_password = true;
- Owned::new(tb)
+ ctx.insert(tb)
};
- state.imap_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("IMAP Server"));
- state.smtp_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("SMTP Server"));
- state.oauth_client_id_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client ID"));
+ state.imap_box = ctx.insert(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("IMAP Server"));
+ state.smtp_box = ctx.insert(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("SMTP Server"));
+ state.oauth_client_id_box = ctx.insert(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client ID"));
state.oauth_client_secret_box = {
let mut tb = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client Secret");
tb.is_password = true;
- Owned::new(tb)
+ ctx.insert(tb)
};
state.list = ScrollRegion::new(cce_ui::layout::spinbox_height(), LIST_GAP).with_sink_behind(true);
state
@@ -771,10 +773,10 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
b.text("Add New Account", 14.0, heading);
b.text("Gmail signs in with Google below; iCloud requires an App Password.", 11.0, TEXT_DIM);
});
- col.widget(&mut state.email_box, widget_h)
- .widget(&mut state.password_box, widget_h)
- .widget(&mut state.imap_box, widget_h)
- .widget(&mut state.smtp_box, widget_h);
+ col.widget_h(ctx, state.email_box, widget_h)
+ .widget_h(ctx, state.password_box, widget_h)
+ .widget_h(ctx, state.imap_box, widget_h)
+ .widget_h(ctx, state.smtp_box, widget_h);
col.row(|r| {
form_button(r, "Save", 0.0, (BTN_PRIMARY.0, BTN_PRIMARY.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::AddAccountSave));
form_button(r, "Cancel", 0.0, (BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::AddAccountCancel));
@@ -798,16 +800,16 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
// An OAuth account has no password to edit; a password one has no
// client credentials. Neither ever shows the other's fields.
if acc.is_oauth {
- col.widget(&mut state.imap_box, widget_h).widget(&mut state.smtp_box, widget_h);
+ col.widget_h(ctx, state.imap_box, widget_h).widget_h(ctx, state.smtp_box, widget_h);
col.block(|b| {
b.text("Credentials this account refreshes tokens with, taking effect", 11.0, TEXT_DIM);
b.text("on the next refresh \u{2014} Re-login to re-issue the tokens now.", 11.0, TEXT_DIM);
});
- col.widget(&mut state.oauth_client_id_box, widget_h).widget(&mut state.oauth_client_secret_box, widget_h);
+ col.widget_h(ctx, state.oauth_client_id_box, widget_h).widget_h(ctx, state.oauth_client_secret_box, widget_h);
} else {
- col.widget(&mut state.password_box, widget_h)
- .widget(&mut state.imap_box, widget_h)
- .widget(&mut state.smtp_box, widget_h);
+ col.widget_h(ctx, state.password_box, widget_h)
+ .widget_h(ctx, state.imap_box, widget_h)
+ .widget_h(ctx, state.smtp_box, widget_h);
}
col.row(|r| {
form_button(r, "Save", 0.0, (BTN_PRIMARY.0, BTN_PRIMARY.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::EditAccountSave));
@@ -882,7 +884,7 @@ fn fill_box(tb: &mut cce_ui::widget::Adapted<TextBox>, value: &str) {
tb.edit_buffer = value.to_string();
}
-pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
+pub fn update(state: &mut AccountsState, msg: AccountsMessage, ctx: &mut UiContext) {
match msg {
AccountsMessage::Refreshed(snap) => {
state.loaded = true;
@@ -893,7 +895,7 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
if let Some(ref e) = state.editing_email {
if !state.accounts.iter().any(|a| a.email == *e) {
state.editing_email = None;
- state.password_box.placeholder = None;
+ ctx[state.password_box].placeholder = None;
}
}
if state.selected_idx.is_none() && !state.accounts.is_empty() {
@@ -912,22 +914,22 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
AccountsMessage::AddAccountStart => {
state.adding_new = true;
state.editing_email = None;
- fill_box(&mut state.email_box, "");
- fill_box(&mut state.password_box, "");
- fill_box(&mut state.imap_box, "");
- fill_box(&mut state.smtp_box, "");
+ fill_box(&mut ctx[state.email_box], "");
+ fill_box(&mut ctx[state.password_box], "");
+ fill_box(&mut ctx[state.imap_box], "");
+ fill_box(&mut ctx[state.smtp_box], "");
// Adding needs a real password; only editing may leave it blank.
- state.password_box.placeholder = None;
+ ctx[state.password_box].placeholder = None;
}
AccountsMessage::AddAccountCancel => {
state.adding_new = false;
state.selected_idx = if state.accounts.is_empty() { None } else { Some(0) };
}
AccountsMessage::AddAccountSave => {
- let email = live_text(&state.email_box);
- let password = live_text(&state.password_box);
- let imap = live_text(&state.imap_box);
- let smtp = live_text(&state.smtp_box);
+ let email = live_text(&ctx[state.email_box]);
+ let password = live_text(&ctx[state.password_box]);
+ let imap = live_text(&ctx[state.imap_box]);
+ let smtp = live_text(&ctx[state.smtp_box]);
if email.is_empty() || password.is_empty() || imap.is_empty() || smtp.is_empty() {
state.status_msg = Some("All fields must be filled!".to_string());
@@ -1067,8 +1069,8 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
state.adding_new = false;
state.selected_idx = Some(idx);
- fill_box(&mut state.imap_box, &acc.imap);
- fill_box(&mut state.smtp_box, &acc.smtp);
+ fill_box(&mut ctx[state.imap_box], &acc.imap);
+ fill_box(&mut ctx[state.smtp_box], &acc.smtp);
if acc.is_oauth {
// Show what this account actually authenticates with: its own
// pinned copy, or the global template it would fall back to.
@@ -1081,13 +1083,13 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
(id.unwrap_or(fallback.client_id), secret.unwrap_or(fallback.client_secret))
}
};
- fill_box(&mut state.oauth_client_id_box, &id);
- fill_box(&mut state.oauth_client_secret_box, &secret);
+ fill_box(&mut ctx[state.oauth_client_id_box], &id);
+ fill_box(&mut ctx[state.oauth_client_secret_box], &secret);
} else {
// The password lives in the keyring. Never read a secret back
// just to prefill a field — blank means "keep what is stored".
- fill_box(&mut state.password_box, "");
- state.password_box.set_placeholder("unchanged \u{2014} type to replace");
+ fill_box(&mut ctx[state.password_box], "");
+ ctx[state.password_box].set_placeholder("unchanged \u{2014} type to replace");
}
}
AccountsMessage::EditAccountSave => {
@@ -1100,16 +1102,16 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
// Validate everything BEFORE touching state.accounts: a mid-way
// bail would otherwise leave memory disagreeing with the file.
- let imap = live_text(&state.imap_box);
- let smtp = live_text(&state.smtp_box);
+ let imap = live_text(&ctx[state.imap_box]);
+ let smtp = live_text(&ctx[state.smtp_box]);
if imap.is_empty() || smtp.is_empty() {
state.status_msg = Some("IMAP and SMTP must be filled!".to_string());
return;
}
let is_oauth = state.accounts[idx].is_oauth;
let creds = if is_oauth {
- let id = live_text(&state.oauth_client_id_box);
- let secret = live_text(&state.oauth_client_secret_box);
+ let id = live_text(&ctx[state.oauth_client_id_box]);
+ let secret = live_text(&ctx[state.oauth_client_secret_box]);
if id.is_empty() || secret.is_empty() {
state.status_msg = Some("Both Client ID and Client Secret are required!".to_string());
return;
@@ -1118,7 +1120,7 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
} else {
None
};
- let password = if is_oauth { String::new() } else { live_text(&state.password_box) };
+ let password = if is_oauth { String::new() } else { live_text(&ctx[state.password_box]) };
let mut msg = "Account updated".to_string();
let mut new_password = None;
@@ -1160,12 +1162,12 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
return;
}
state.editing_email = None;
- state.password_box.placeholder = None;
+ ctx[state.password_box].placeholder = None;
state.status_msg = Some(msg);
}
AccountsMessage::EditAccountCancel => {
state.editing_email = None;
- state.password_box.placeholder = None;
+ ctx[state.password_box].placeholder = None;
}
AccountsMessage::ICloudLoginHelp => {
let mut cmd = std::process::Command::new("xdg-open");
@@ -1235,24 +1237,24 @@ impl crate::pages::AppPage for AccountsState {
view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {
- if self.adding_new && self.email_box.take_change() {
- let email_val = self.email_box.text.trim().to_lowercase();
+ fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+ if self.adding_new && ctx[self.email_box].take_change() {
+ let email_val = ctx[self.email_box].text.trim().to_lowercase();
if email_val.ends_with("@gmail.com") {
- self.imap_box.text = "imap.gmail.com:993".to_string();
- self.imap_box.edit_buffer = "imap.gmail.com:993".to_string();
- self.smtp_box.text = "smtp.gmail.com:465".to_string();
- self.smtp_box.edit_buffer = "smtp.gmail.com:465".to_string();
+ ctx[self.imap_box].text = "imap.gmail.com:993".to_string();
+ ctx[self.imap_box].edit_buffer = "imap.gmail.com:993".to_string();
+ ctx[self.smtp_box].text = "smtp.gmail.com:465".to_string();
+ ctx[self.smtp_box].edit_buffer = "smtp.gmail.com:465".to_string();
} else if email_val.ends_with("@icloud.com") {
- self.imap_box.text = "imap.mail.me.com:993".to_string();
- self.imap_box.edit_buffer = "imap.mail.me.com:993".to_string();
- self.smtp_box.text = "smtp.mail.me.com:587".to_string();
- self.smtp_box.edit_buffer = "smtp.mail.me.com:587".to_string();
+ ctx[self.imap_box].text = "imap.mail.me.com:993".to_string();
+ ctx[self.imap_box].edit_buffer = "imap.mail.me.com:993".to_string();
+ ctx[self.smtp_box].text = "smtp.mail.me.com:587".to_string();
+ ctx[self.smtp_box].edit_buffer = "smtp.mail.me.com:587".to_string();
} else if email_val.ends_with("@outlook.com") || email_val.ends_with("@hotmail.com") {
- self.imap_box.text = "outlook.office365.com:993".to_string();
- self.imap_box.edit_buffer = "outlook.office365.com:993".to_string();
- self.smtp_box.text = "smtp.office365.com:587".to_string();
- self.smtp_box.edit_buffer = "smtp.office365.com:587".to_string();
+ ctx[self.imap_box].text = "outlook.office365.com:993".to_string();
+ ctx[self.imap_box].edit_buffer = "outlook.office365.com:993".to_string();
+ ctx[self.smtp_box].text = "smtp.office365.com:587".to_string();
+ ctx[self.smtp_box].edit_buffer = "smtp.office365.com:587".to_string();
}
}
}
@@ -1300,10 +1302,11 @@ mod tests {
#[test]
fn test_accounts_page_view() {
- let mut state = AccountsState::default_mock();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = AccountsState::new(&mut ui);
state.loaded = true;
let mut layout = PageFlow::new();
- let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+ let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
println!("PC BUTTONS COUNT: {}", pc.buttons.len());
for (i, (btn, _, _)) in pc.buttons.iter().enumerate() {
let base = btn.base();
@@ -1317,17 +1320,18 @@ mod tests {
#[test]
fn list_reserves_its_height_so_later_rows_clear_it() {
+ let mut ui = cce_ui::context::UiContext::new();
// The scroll region advances the section by hand. SectionContext keeps a
// parallel per-column Grid and its `spacing` recomputes
// `content_y = grid.max_height()`, so reserving the height by bumping
// `content_y` alone is silently discarded and every following row draws
// back on top of the list. Caught live: "Add Account" and the detail
// rows were painted over the account rows.
- let mut state = AccountsState::default_mock();
+ let mut state = AccountsState::new(&mut ui);
state.loaded = true;
state.accounts = (0..12).map(|i| acct(&format!("a{i}@example.org"), false)).collect();
let mut layout = PageFlow::new();
- let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+ let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
let list_bottom = state.list.y + state.list.h;
let add = pc
@@ -1346,14 +1350,15 @@ mod tests {
#[test]
fn list_emits_only_the_rows_the_region_virtualizes_on() {
+ let mut ui = cce_ui::context::UiContext::new();
// Row buttons are emitted under the same `get_item_draw_y` predicate the
// region scrolls by, so a list longer than the cap paints the visible
// window rather than all of its rows.
- let mut state = AccountsState::default_mock();
+ let mut state = AccountsState::new(&mut ui);
state.loaded = true;
state.accounts = (0..40).map(|i| acct(&format!("a{i}@example.org"), false)).collect();
let mut layout = PageFlow::new();
- let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+ let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
let rows = pc
.buttons
@@ -1406,30 +1411,32 @@ mod tests {
/// XDG_CONFIG_HOME. Only the early-return paths are exercised here.
#[test]
fn edit_prefills_the_account_but_never_the_password() {
- let mut state = AccountsState::default_mock();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = AccountsState::new(&mut ui);
state.accounts = vec![acct("a@example.org", false)];
- update(&mut state, AccountsMessage::EditAccountStart(0));
+ update(&mut state, AccountsMessage::EditAccountStart(0), &mut ui);
assert_eq!(state.editing_email.as_deref(), Some("a@example.org"));
- assert_eq!(state.imap_box.text, "imap.example.org:993");
- assert_eq!(state.smtp_box.text, "smtp.example.org:465");
+ assert_eq!(ui[state.imap_box].text, "imap.example.org:993");
+ assert_eq!(ui[state.smtp_box].text, "smtp.example.org:465");
// The secret is in the keyring; a blank box plus a placeholder is how
// "keep the stored one" is expressed.
- assert!(state.password_box.text.is_empty());
- assert!(state.password_box.placeholder.is_some());
+ assert!(ui[state.password_box].text.is_empty());
+ assert!(ui[state.password_box].placeholder.is_some());
}
#[test]
fn editing_an_oauth_account_shows_its_pinned_credentials() {
- let mut state = AccountsState::default_mock();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = AccountsState::new(&mut ui);
state.accounts = vec![acct("g@gmail.com", true)];
- update(&mut state, AccountsMessage::EditAccountStart(0));
+ update(&mut state, AccountsMessage::EditAccountStart(0), &mut ui);
- assert_eq!(state.oauth_client_id_box.text, "pinned-id");
- assert_eq!(state.oauth_client_secret_box.text, "pinned-secret");
- assert!(state.oauth_client_secret_box.is_password, "the secret stays masked");
+ assert_eq!(ui[state.oauth_client_id_box].text, "pinned-id");
+ assert_eq!(ui[state.oauth_client_secret_box].text, "pinned-secret");
+ assert!(ui[state.oauth_client_secret_box].is_password, "the secret stays masked");
}
/// The form keys on the address, so a background refresh that reorders the
@@ -1438,34 +1445,35 @@ mod tests {
/// the right row after the reorder.
#[test]
fn edit_follows_the_account_across_a_reorder() {
- let mut state = AccountsState::default_mock();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = AccountsState::new(&mut ui);
state.accounts = vec![acct("first@example.org", false), acct("second@example.org", false)];
- update(&mut state, AccountsMessage::EditAccountStart(1));
+ update(&mut state, AccountsMessage::EditAccountStart(1), &mut ui);
assert_eq!(state.editing_email.as_deref(), Some("second@example.org"));
update(
&mut state,
- AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("second@example.org", false), acct("first@example.org", false)], keyring: Vec::new() }),
- );
+ AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("second@example.org", false), acct("first@example.org", false)], keyring: Vec::new() }),&mut ui);
assert_eq!(state.editing_email.as_deref(), Some("second@example.org"), "the refresh keeps the form open");
- fill_box(&mut state.imap_box, "");
- update(&mut state, AccountsMessage::EditAccountSave);
+ fill_box(&mut ui[state.imap_box], "");
+ update(&mut state, AccountsMessage::EditAccountSave, &mut ui);
assert_eq!(state.status_msg.as_deref(), Some("IMAP and SMTP must be filled!"));
assert!(state.editing_email.is_some(), "a failed save keeps the form open");
}
#[test]
fn a_vanished_account_closes_the_edit_form() {
- let mut state = AccountsState::default_mock();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = AccountsState::new(&mut ui);
state.accounts = vec![acct("gone@example.org", false)];
- update(&mut state, AccountsMessage::EditAccountStart(0));
+ update(&mut state, AccountsMessage::EditAccountStart(0), &mut ui);
- update(&mut state, AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("other@example.org", false)], keyring: Vec::new() }));
+ update(&mut state, AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("other@example.org", false)], keyring: Vec::new() }), &mut ui);
assert!(state.editing_email.is_none());
- assert!(state.password_box.placeholder.is_none(), "the placeholder does not leak into the add form");
+ assert!(ui[state.password_box].placeholder.is_none(), "the placeholder does not leak into the add form");
}
fn get(target: &str) -> String {
@@ -1541,13 +1549,14 @@ mod tests {
/// process, which is worse than the double-bind it prevents.
#[test]
fn oauth_listener_flag_tracks_the_flow() {
- let mut state = AccountsState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = AccountsState::new(&mut ui);
assert!(!state.oauth_listener_running);
- update(&mut state, AccountsMessage::GoogleLoginInit);
+ update(&mut state, AccountsMessage::GoogleLoginInit, &mut ui);
assert!(state.oauth_listener_running, "starting a login marks the port busy");
- update(&mut state, AccountsMessage::GoogleLoginFinished);
+ update(&mut state, AccountsMessage::GoogleLoginFinished, &mut ui);
assert!(!state.oauth_listener_running, "a finished flow frees the button");
}
}
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index 5e60c53..f5020d5 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -1,5 +1,6 @@
use crate::app::{form_button, form_divider, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
use cce_ui::widget::{Spinbox, Slider};
@@ -26,10 +27,10 @@ pub struct AudioState {
pub loaded: bool,
pub sinks: Vec<AudioSink>,
pub sources: Vec<AudioSource>,
- pub sink_spinboxes: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>>,
- pub source_spinboxes: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>>,
- pub sink_sliders: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>>,
- pub source_sliders: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>>,
+ pub sink_spinboxes: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>,
+ pub source_spinboxes: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>,
+ pub sink_sliders: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>,
+ pub source_sliders: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>,
}
#[derive(Debug, Clone)]
@@ -235,8 +236,9 @@ fn device_row<'w>(
active: bool,
muted: bool,
volume: f32,
- slider: &'w mut Owned<cce_ui::widget::Adapted<Slider>>,
- spin: &'w mut Owned<cce_ui::widget::Adapted<Spinbox>>,
+ ctx: &mut UiContext,
+ slider: Handle<cce_ui::widget::Adapted<Slider>>,
+ spin: Handle<cce_ui::widget::Adapted<Spinbox>>,
mute_action: AppAction,
) {
let line_h = cce_ui::layout::form_line_height(12.0);
@@ -248,8 +250,8 @@ fn device_row<'w>(
return;
}
- slider.set_value(volume);
- spin.value = (volume * 100.0).round() as i32;
+ ctx[slider].set_value(volume);
+ ctx[spin].value = (volume * 100.0).round() as i32;
let (mute_label, colors, mute_text) = if muted {
("Unmute", BTN_DANGER, TEXT_DANGER)
} else {
@@ -259,8 +261,8 @@ fn device_row<'w>(
r.draw(name_w, line_h, false, move |pc, c, _| {
pc.text_with_bounds(&name, c.x, c.y, 12.0, TEXT_FG, Some([c.x, c.y - 12.0, c.x + c.width, c.y + 24.0]));
});
- r.widget(slider, cce_ui::layout::slider_height());
- r.widget_w(spin, SPIN_W, cce_ui::layout::spinbox_height());
+ r.widget_h(ctx, slider, cce_ui::layout::slider_height());
+ r.widget_w_h(ctx, spin, SPIN_W, cce_ui::layout::spinbox_height());
form_button(r, mute_label, MUTE_W, (colors.0, colors.1, mute_text), mute_action);
});
}
@@ -298,10 +300,10 @@ pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focu
if state.sinks.is_empty() {
col.text("No output devices found", 12.0, TEXT_DIM);
}
- let devices = state.sinks.iter().zip(state.sink_sliders.iter_mut()).zip(state.sink_spinboxes.iter_mut());
+ let devices = state.sinks.iter().zip(state.sink_sliders.iter().copied()).zip(state.sink_spinboxes.iter().copied());
for ((sink, slider), spin) in devices {
let action = AppAction::Audio(AudioMessage::SinkMute(sink.id));
- device_row(&mut col, sink.name.clone(), name_w, sink.active, sink.muted, sink.volume, slider, spin, action);
+ device_row(&mut col, sink.name.clone(), name_w, sink.active, sink.muted, sink.volume, ctx, slider, spin, action);
}
form_divider(&mut col);
@@ -310,10 +312,10 @@ pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focu
if state.sources.is_empty() {
col.text("No input devices found", 12.0, TEXT_DIM);
}
- let devices = state.sources.iter().zip(state.source_sliders.iter_mut()).zip(state.source_spinboxes.iter_mut());
+ let devices = state.sources.iter().zip(state.source_sliders.iter().copied()).zip(state.source_spinboxes.iter().copied());
for ((src, slider), spin) in devices {
let action = AppAction::Audio(AudioMessage::SourceMute(src.id));
- device_row(&mut col, src.name.clone(), name_w, src.active, src.muted, src.volume, slider, spin, action);
+ device_row(&mut col, src.name.clone(), name_w, src.active, src.muted, src.volume, ctx, slider, spin, action);
}
sec.place(form, ctx);
});
@@ -322,16 +324,27 @@ pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focu
}
-pub fn update(state: &mut AudioState, msg: AudioMessage) {
+/// `rows` resized to `n` widgets, one per device: the context takes the new ones in and
+/// gives the surplus back.
+fn fit<W: cce_ui::widget::WidgetHost + 'static>(rows: &mut Vec<Handle<W>>, n: usize, ctx: &mut UiContext, make: impl Fn() -> W) {
+ for h in rows.drain(n.min(rows.len())..) {
+ ctx.remove(h);
+ }
+ while rows.len() < n {
+ rows.push(ctx.insert(make()));
+ }
+}
+
+pub fn update(state: &mut AudioState, msg: AudioMessage, ctx: &mut UiContext) {
match msg {
AudioMessage::Refreshed(new) => {
state.loaded = new.loaded;
state.sinks = new.sinks;
state.sources = new.sources;
- state.sink_spinboxes.resize_with(state.sinks.len(), || Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))));
- state.source_spinboxes.resize_with(state.sources.len(), || Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))));
- state.sink_sliders.resize_with(state.sinks.len(), || Box::new(Owned::new(Slider::new().with_range(0.0, 1.0).with_scroll(true))));
- state.source_sliders.resize_with(state.sources.len(), || Box::new(Owned::new(Slider::new().with_range(0.0, 1.0).with_scroll(true))));
+ fit(&mut state.sink_spinboxes, state.sinks.len(), ctx, || Spinbox::new(50, 0, 100, 1));
+ fit(&mut state.source_spinboxes, state.sources.len(), ctx, || Spinbox::new(50, 0, 100, 1));
+ fit(&mut state.sink_sliders, state.sinks.len(), ctx, || Slider::new().with_range(0.0, 1.0).with_scroll(true));
+ fit(&mut state.source_sliders, state.sources.len(), ctx, || Slider::new().with_range(0.0, 1.0).with_scroll(true));
}
AudioMessage::SinkVolume(id, vol) => {
if let Some(sink) = state.sinks.iter_mut().find(|s| s.id == id) {
@@ -404,36 +417,40 @@ impl crate::pages::AppPage for AudioState {
view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
- for (i, sb) in self.sink_spinboxes.iter_mut().enumerate() {
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+ for (i, &sb) in self.sink_spinboxes.iter().enumerate() {
+ let sb = &mut ctx[sb];
if sb.take_change() {
let id = self.sinks[i].id;
actions.push(AppAction::Audio(AudioMessage::SinkVolume(id, sb.value as f32 / 100.0)));
}
}
- for (i, sb) in self.source_spinboxes.iter_mut().enumerate() {
+ for (i, &sb) in self.source_spinboxes.iter().enumerate() {
+ let sb = &mut ctx[sb];
if sb.take_change() {
let id = self.sources[i].id;
actions.push(AppAction::Audio(AudioMessage::SourceVolume(id, sb.value as f32 / 100.0)));
}
}
- for (i, slider) in self.sink_sliders.iter_mut().enumerate() {
- if slider.take_change() {
+ for (i, &slider) in self.sink_sliders.iter().enumerate() {
+ if ctx[slider].take_change() {
+ let value = ctx[slider].value();
let id = self.sinks[i].id;
// Keep the paired spinbox display in step, as the old drag path did.
- if let Some(sb) = self.sink_spinboxes.get_mut(i) {
- sb.value = slider.value();
+ if let Some(&sb) = self.sink_spinboxes.get(i) {
+ ctx[sb].value = value;
}
- actions.push(AppAction::Audio(AudioMessage::SinkVolume(id, slider.value() as f32 / 100.0)));
+ actions.push(AppAction::Audio(AudioMessage::SinkVolume(id, value as f32 / 100.0)));
}
}
- for (i, slider) in self.source_sliders.iter_mut().enumerate() {
- if slider.take_change() {
+ for (i, &slider) in self.source_sliders.iter().enumerate() {
+ if ctx[slider].take_change() {
+ let value = ctx[slider].value();
let id = self.sources[i].id;
- if let Some(sb) = self.source_spinboxes.get_mut(i) {
- sb.value = slider.value();
+ if let Some(&sb) = self.source_spinboxes.get(i) {
+ ctx[sb].value = value;
}
- actions.push(AppAction::Audio(AudioMessage::SourceVolume(id, slider.value() as f32 / 100.0)));
+ actions.push(AppAction::Audio(AudioMessage::SourceVolume(id, value as f32 / 100.0)));
}
}
}
@@ -452,6 +469,7 @@ mod tests {
#[test]
fn test_view_layout_grid() {
use cce_ui::widget::{Spinbox, Slider};
+ let mut ui = UiContext::new();
let mut state = AudioState {
loaded: true,
sinks: vec![
@@ -462,24 +480,24 @@ mod tests {
],
sources: vec![],
sink_spinboxes: vec![
- Box::new(Owned::new(Spinbox::new(57, 0, 100, 1))),
- Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))),
- Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))),
- Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))),
+ ui.insert(Spinbox::new(57, 0, 100, 1)),
+ ui.insert(Spinbox::new(50, 0, 100, 1)),
+ ui.insert(Spinbox::new(50, 0, 100, 1)),
+ ui.insert(Spinbox::new(50, 0, 100, 1)),
],
source_spinboxes: vec![],
sink_sliders: vec![
- Box::new(Owned::new(Slider::new())),
- Box::new(Owned::new(Slider::new())),
- Box::new(Owned::new(Slider::new())),
- Box::new(Owned::new(Slider::new())),
+ ui.insert(Slider::new()),
+ ui.insert(Slider::new()),
+ ui.insert(Slider::new()),
+ ui.insert(Slider::new()),
],
source_sliders: vec![],
};
let mut layout = PageFlow::new();
// One flag: section_widgets() returns a single group (the output ids
// with input appended), so the view draws one section and reads [0].
- let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+ let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
for (i, (c, x, y, w, h, r, _)) in pc.rects.iter().enumerate() {
println!("TEST_PC_RECT {}: color={:?}, x={}, y={}, w={}, h={}, r={}", i, c, x, y, w, h, r);
}
@@ -489,30 +507,25 @@ mod tests {
assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
}
+ /// A device's spinbox with its context menu open, given back by the context when a
+ /// refresh drops the device: the menu goes with it rather than outliving its widget.
#[test]
- #[allow(unused_assignments)]
- fn test_boxed_spinbox_right_click_crash() {
+ fn a_removed_spinbox_takes_its_menu_with_it() {
use cce_ui::widget::{WidgetHost, Spinbox};
+ let mut ui = UiContext::new();
let mut state = AudioState::default();
- state.sink_spinboxes.push(Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))));
- let mut ctx = cce_ui::context::UiContext::new();
- let sb = &mut state.sink_spinboxes[0];
- sb.set_rect(0.0, 0.0, 100.0, 44.0);
- let res = sb.mouse_input(
- cce_ui::widget::MouseButton::Right,
- cce_ui::widget::ElementState::Pressed,
- 50.0,
- 20.0,
- &mut ctx,
- );
- assert!(res);
+ fit(&mut state.sink_spinboxes, 1, &mut ui, || Spinbox::new(50, 0, 100, 1));
+ let sb = state.sink_spinboxes[0];
+ ui[sb].set_rect(0.0, 0.0, 100.0, 44.0);
+ let res = ui.lend_h(sb, |w, ctx| {
+ w.mouse_input(cce_ui::widget::MouseButton::Right, cce_ui::widget::ElementState::Pressed, 50.0, 20.0, ctx)
+ });
+ assert_eq!(res, Some(true));
assert!(cce_ui::widget::context_menu::is_visible());
- // Now replace the state simulating config reload/refresh
- let new_state = AudioState::default();
- state = new_state;
-
- // Assert that the context menu is hidden (cleared)
+ // The device is gone on the next refresh: its spinbox leaves the context.
+ fit(&mut state.sink_spinboxes, 0, &mut ui, || Spinbox::new(50, 0, 100, 1));
+ assert!(ui.get(sb).is_none());
assert!(!cce_ui::widget::context_menu::is_visible());
}
}
diff --git a/src/pages/bluetooth.rs b/src/pages/bluetooth.rs
index 1dfd990..3607c90 100644
--- a/src/pages/bluetooth.rs
+++ b/src/pages/bluetooth.rs
@@ -1,5 +1,6 @@
use crate::app::{form_button, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
use cce_ui::widget::{Adapted, Toggle};
@@ -19,7 +20,7 @@ pub struct BluetoothState {
pub enabled: bool,
pub devices: Vec<BluetoothDevice>,
pub scanning: bool,
- pub toggle: Owned<Adapted<Toggle>>,
+ pub toggle: Handle<Adapted<Toggle>>,
}
impl Default for BluetoothState {
@@ -31,11 +32,18 @@ impl Default for BluetoothState {
enabled: false,
devices: Vec::new(),
scanning: false,
- toggle: Owned::new(Toggle::new()),
+ toggle: Handle::none(),
}
}
}
+impl BluetoothState {
+ /// The page's state, its widgets inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
+ Self { toggle: ctx.insert(Toggle::new()), ..Self::default() }
+ }
+}
+
#[derive(Debug, Clone)]
pub enum BluetoothMessage {
Refreshed(BluetoothState),
@@ -81,7 +89,7 @@ pub async fn fetch_bluetooth_page_state() -> BluetoothState {
.unwrap_or(false);
let devices = if enabled { fetch_devices().await } else { Vec::new() };
- BluetoothState { loaded: true, installed, service_active, enabled, devices, scanning: false, toggle: Owned::new(Toggle::new()) }
+ BluetoothState { loaded: true, installed, service_active, enabled, devices, scanning: false, toggle: Handle::none() }
}
async fn fetch_devices() -> Vec<BluetoothDevice> {
@@ -178,10 +186,10 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
} else {
let bt_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 60.0 };
let scan_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 52.0 };
- state.toggle.set_toggled(state.enabled);
- state.toggle.set_label(if state.enabled { "ON" } else { "OFF" });
+ ctx[state.toggle].set_toggled(state.enabled);
+ ctx[state.toggle].set_label(if state.enabled { "ON" } else { "OFF" });
col.row(|r| {
- r.widget_w(&mut state.toggle, bt_btn_w, cce_ui::layout::toggle_height());
+ r.widget_w_h(ctx, state.toggle, bt_btn_w, cce_ui::layout::toggle_height());
form_button(r, "Scan", scan_btn_w, (TOGGLE_OFF, BTN_HOVER, WHITE), AppAction::Bluetooth(BluetoothMessage::Scan));
});
@@ -238,7 +246,7 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
final_pc
}
-pub fn update(state: &mut BluetoothState, msg: BluetoothMessage) {
+pub fn update(state: &mut BluetoothState, msg: BluetoothMessage, _ctx: &mut UiContext) {
match msg {
BluetoothMessage::Refreshed(new) => {
state.loaded = new.loaded;
@@ -297,8 +305,8 @@ impl crate::pages::AppPage for BluetoothState {
view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
- if self.toggle.take_change() {
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+ if ctx[self.toggle].take_change() {
actions.push(crate::app::AppAction::Bluetooth(BluetoothMessage::Toggle));
}
}
@@ -311,7 +319,8 @@ mod tests {
#[test]
fn section_widgets_mirror_branch_chain() {
- let mut st = BluetoothState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = BluetoothState::new(&mut ui);
// Each of the three early branches paints a message (and maybe a plain
// PageContent button) but never `toggle` — the widget lives only in the
// innermost `else`. Unlike a load gate, the middle two are steady
diff --git a/src/pages/browser.rs b/src/pages/browser.rs
index 94ffe63..2403f39 100644
--- a/src/pages/browser.rs
+++ b/src/pages/browser.rs
@@ -7,7 +7,8 @@
use std::fs;
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::layout::{PageFlow, PageLayoutBuilder};
use cce_ui::widget::input::{Dropdown, Toggle};
use cce_ui::widget::TextBox;
@@ -62,27 +63,20 @@ pub struct BrowserState {
pub vi_mode: bool,
pub bar_position: String,
pub color_scheme: String,
- pub homepage_box: Owned<cce_ui::widget::Adapted<TextBox>>,
- pub search_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
- pub bar_position_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
- pub color_scheme_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
- pub download_dir_box: Owned<cce_ui::widget::Adapted<TextBox>>,
- pub history_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
- pub raindrop_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
- pub vi_mode_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
+ pub homepage_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+ pub search_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
+ pub bar_position_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
+ pub color_scheme_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
+ pub download_dir_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+ pub history_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
+ pub raindrop_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
+ pub vi_mode_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
}
impl Default for BrowserState {
+ /// The data half, read from the config; the widgets are `new`'s.
fn default() -> Self {
let config = read_browser_config();
- let mut homepage_box = TextBox::new(config.homepage.clone())
- .with_draw_bg_border(true)
- .with_label("Homepage");
- homepage_box.edit_buffer = config.homepage.clone();
- let mut download_dir_box = TextBox::new(config.download_dir.clone())
- .with_draw_bg_border(true)
- .with_label("Download Directory");
- download_dir_box.edit_buffer = config.download_dir.clone();
Self {
loaded: true,
homepage: config.homepage,
@@ -93,30 +87,56 @@ impl Default for BrowserState {
vi_mode: config.vi_mode,
bar_position: config.bar_position.clone(),
color_scheme: config.color_scheme.clone(),
- homepage_box: Owned::new(homepage_box),
- search_menu: Owned::new(Dropdown::new(
+ homepage_box: Handle::none(),
+ search_menu: Handle::none(),
+ bar_position_menu: Handle::none(),
+ color_scheme_menu: Handle::none(),
+ download_dir_box: Handle::none(),
+ history_toggle: Handle::none(),
+ raindrop_toggle: Handle::none(),
+ vi_mode_toggle: Handle::none(),
+ }
+ }
+}
+
+impl BrowserState {
+ /// The page's state, its widgets inserted into `ctx` and filled from the config.
+ pub fn new(ctx: &mut UiContext) -> Self {
+ let s = Self::default();
+ let mut homepage_box = TextBox::new(s.homepage.clone())
+ .with_draw_bg_border(true)
+ .with_label("Homepage");
+ homepage_box.edit_buffer = s.homepage.clone();
+ let mut download_dir_box = TextBox::new(s.download_dir.clone())
+ .with_draw_bg_border(true)
+ .with_label("Download Directory");
+ download_dir_box.edit_buffer = s.download_dir.clone();
+ Self {
+ homepage_box: ctx.insert(homepage_box),
+ search_menu: ctx.insert(Dropdown::new(
SEARCH_ENGINES.iter().map(|(_, label)| label.to_string()).collect(),
- search_index(&config.search),
+ search_index(&s.search),
)
.with_label("Search Engine")),
- bar_position_menu: Owned::new(Dropdown::new(
+ bar_position_menu: ctx.insert(Dropdown::new(
BAR_POSITIONS.iter().map(|(_, label)| label.to_string()).collect(),
- bar_position_index(&config.bar_position),
+ bar_position_index(&s.bar_position),
)
.with_label("Navigation Bar Position")),
- color_scheme_menu: Owned::new(Dropdown::new(
+ color_scheme_menu: ctx.insert(Dropdown::new(
COLOR_SCHEMES.iter().map(|(_, label)| label.to_string()).collect(),
- color_scheme_index(&config.color_scheme),
+ color_scheme_index(&s.color_scheme),
)
.with_label("Page Color Scheme")),
- download_dir_box: Owned::new(download_dir_box),
+ download_dir_box: ctx.insert(download_dir_box),
// Left-aligned, as the designer's parameter pane sets its toggles:
// a toggle's run is half its width, so the seam where run meets
// well sits at the midpoint, and a centred label on a row-wide
// toggle had it drawn straight through the text.
- history_toggle: Owned::new(Toggle::new().with_label("Record History").with_left_align(true)),
- raindrop_toggle: Owned::new(Toggle::new().with_label("Sync Bookmarks with Raindrop").with_left_align(true)),
- vi_mode_toggle: Owned::new(Toggle::new().with_label("Vi Keys (qutebrowser-style)").with_left_align(true)),
+ history_toggle: ctx.insert(Toggle::new().with_label("Record History").with_left_align(true)),
+ raindrop_toggle: ctx.insert(Toggle::new().with_label("Sync Bookmarks with Raindrop").with_left_align(true)),
+ vi_mode_toggle: ctx.insert(Toggle::new().with_label("Vi Keys (qutebrowser-style)").with_left_align(true)),
+ ..s
}
}
}
@@ -156,7 +176,7 @@ fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> String {
}
}
-pub fn update(state: &mut BrowserState, msg: BrowserMessage) {
+pub fn update(state: &mut BrowserState, msg: BrowserMessage, ctx: &mut UiContext) {
match msg {
BrowserMessage::SetSearch(key) => {
state.search = key.clone();
@@ -183,13 +203,13 @@ pub fn update(state: &mut BrowserState, msg: BrowserMessage) {
write_config_value("vi-mode", &state.vi_mode.to_string());
}
BrowserMessage::Apply => {
- state.homepage = live_text(&state.homepage_box);
+ state.homepage = live_text(&ctx[state.homepage_box]);
if state.homepage.is_empty() {
state.homepage = DEFAULT_HOMEPAGE.to_string();
- state.homepage_box.text = state.homepage.clone();
- state.homepage_box.edit_buffer = state.homepage.clone();
+ ctx[state.homepage_box].text = state.homepage.clone();
+ ctx[state.homepage_box].edit_buffer = state.homepage.clone();
}
- state.download_dir = live_text(&state.download_dir_box);
+ state.download_dir = live_text(&ctx[state.download_dir_box]);
write_config_value("homepage", &state.homepage);
write_config_value("download-dir", &state.download_dir);
}
@@ -202,15 +222,15 @@ pub fn update(state: &mut BrowserState, msg: BrowserMessage) {
state.bar_position = new.bar_position;
state.color_scheme = new.color_scheme;
// Don't clobber fields mid-edit with watcher refreshes.
- if !state.homepage_box.editing && state.homepage != new.homepage {
+ if !ctx[state.homepage_box].editing && state.homepage != new.homepage {
state.homepage = new.homepage.clone();
- state.homepage_box.text = new.homepage.clone();
- state.homepage_box.edit_buffer = new.homepage;
+ ctx[state.homepage_box].text = new.homepage.clone();
+ ctx[state.homepage_box].edit_buffer = new.homepage;
}
- if !state.download_dir_box.editing && state.download_dir != new.download_dir {
+ if !ctx[state.download_dir_box].editing && state.download_dir != new.download_dir {
state.download_dir = new.download_dir.clone();
- state.download_dir_box.text = new.download_dir.clone();
- state.download_dir_box.edit_buffer = new.download_dir;
+ ctx[state.download_dir_box].text = new.download_dir.clone();
+ ctx[state.download_dir_box].edit_buffer = new.download_dir;
}
}
}
@@ -282,14 +302,14 @@ impl AppPage for BrowserState {
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section(&mut final_pc, "Browser Settings", sec_focused.first().copied().unwrap_or(false), |sec| {
- self.search_menu.selected = search_index(&self.search);
- self.bar_position_menu.selected = bar_position_index(&self.bar_position);
- self.color_scheme_menu.selected = color_scheme_index(&self.color_scheme);
- self.history_toggle.set_toggled(self.history);
+ ctx[self.search_menu].selected = search_index(&self.search);
+ ctx[self.bar_position_menu].selected = bar_position_index(&self.bar_position);
+ ctx[self.color_scheme_menu].selected = color_scheme_index(&self.color_scheme);
+ ctx[self.history_toggle].set_toggled(self.history);
// Needs a Raindrop token in the keyring (service=raindrop.io);
// the browser shows the sync's status on cce://bookmarks.
- self.raindrop_toggle.set_toggled(self.raindrop);
- self.vi_mode_toggle.set_toggled(self.vi_mode);
+ ctx[self.raindrop_toggle].set_toggled(self.raindrop);
+ ctx[self.vi_mode_toggle].set_toggled(self.vi_mode);
let (field_h, menu_h, toggle_h) = (
cce_ui::layout::textbox_height(),
@@ -298,14 +318,14 @@ impl AppPage for BrowserState {
);
let mut form = sec.form();
form.column()
- .widget(&mut self.homepage_box, field_h)
- .widget(&mut self.search_menu, menu_h)
- .widget(&mut self.bar_position_menu, menu_h)
- .widget(&mut self.color_scheme_menu, menu_h)
- .widget(&mut self.download_dir_box, field_h)
- .widget(&mut self.history_toggle, toggle_h)
- .widget(&mut self.raindrop_toggle, toggle_h)
- .widget(&mut self.vi_mode_toggle, toggle_h)
+ .widget_h(ctx, self.homepage_box, field_h)
+ .widget_h(ctx, self.search_menu, menu_h)
+ .widget_h(ctx, self.bar_position_menu, menu_h)
+ .widget_h(ctx, self.color_scheme_menu, menu_h)
+ .widget_h(ctx, self.download_dir_box, field_h)
+ .widget_h(ctx, self.history_toggle, toggle_h)
+ .widget_h(ctx, self.raindrop_toggle, toggle_h)
+ .widget_h(ctx, self.vi_mode_toggle, toggle_h)
.draw(0.0, cce_ui::layout::button_height(), false, |pc, r, _| {
pc.button(
"Apply",
@@ -325,39 +345,39 @@ impl AppPage for BrowserState {
final_pc
}
- fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
- if self.search_menu.take_change() {
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
+ if ctx[self.search_menu].take_change() {
let key = SEARCH_ENGINES
- .get(self.search_menu.selected)
+ .get(ctx[self.search_menu].selected)
.map(|(k, _)| k.to_string())
.unwrap_or_else(|| "duckduckgo".to_string());
actions.push(AppAction::Browser(BrowserMessage::SetSearch(key)));
}
- if self.bar_position_menu.take_change() {
+ if ctx[self.bar_position_menu].take_change() {
let key = BAR_POSITIONS
- .get(self.bar_position_menu.selected)
+ .get(ctx[self.bar_position_menu].selected)
.map(|(k, _)| k.to_string())
.unwrap_or_else(|| "top".to_string());
actions.push(AppAction::Browser(BrowserMessage::SetBarPosition(key)));
}
- if self.color_scheme_menu.take_change() {
+ if ctx[self.color_scheme_menu].take_change() {
let key = COLOR_SCHEMES
- .get(self.color_scheme_menu.selected)
+ .get(ctx[self.color_scheme_menu].selected)
.map(|(k, _)| k.to_string())
.unwrap_or_else(|| "dark".to_string());
actions.push(AppAction::Browser(BrowserMessage::SetColorScheme(key)));
}
- if self.history_toggle.take_change() {
+ if ctx[self.history_toggle].take_change() {
actions.push(AppAction::Browser(BrowserMessage::ToggleHistory));
}
- if self.raindrop_toggle.take_change() {
+ if ctx[self.raindrop_toggle].take_change() {
actions.push(AppAction::Browser(BrowserMessage::ToggleRaindrop));
}
- if self.vi_mode_toggle.take_change() {
+ if ctx[self.vi_mode_toggle].take_change() {
actions.push(AppAction::Browser(BrowserMessage::ToggleViMode));
}
// Enter in either text field commits both.
- if self.homepage_box.take_change() || self.download_dir_box.take_change() {
+ if ctx[self.homepage_box].take_change() || ctx[self.download_dir_box].take_change() {
actions.push(AppAction::Browser(BrowserMessage::Apply));
}
}
diff --git a/src/pages/default_apps.rs b/src/pages/default_apps.rs
index 39b9b11..bbbd293 100644
--- a/src/pages/default_apps.rs
+++ b/src/pages/default_apps.rs
@@ -11,7 +11,8 @@
//! entries, and startcce exports as `$TERMINAL` for everything else.
use crate::app::{AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::layout::{PageLayoutBuilder, PageFlow};
use cce_ui::widget::Dropdown;
use std::collections::HashMap;
@@ -61,7 +62,7 @@ pub struct CategoryEntry {
/// Applied value per dropdown option (desktop id for MIME categories, a
/// command for Terminal; None = the "not set" placeholder row).
pub option_ids: Vec<Option<String>>,
- pub dropdown: Owned<cce_ui::widget::Adapted<Dropdown>>,
+ pub dropdown: Handle<cce_ui::widget::Adapted<Dropdown>>,
}
#[derive(Debug, Clone)]
@@ -70,8 +71,9 @@ pub struct DefaultAppsState {
pub categories: Vec<CategoryEntry>,
}
-impl Default for DefaultAppsState {
- fn default() -> Self {
+impl DefaultAppsState {
+ /// The page's state, its dropdowns inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
Self {
loaded: false,
categories: CATEGORIES
@@ -81,7 +83,7 @@ impl Default for DefaultAppsState {
kind,
info: CategoryInfo { candidates: Vec::new(), current: None },
option_ids: vec![None],
- dropdown: Owned::new(Dropdown::new(vec![NOT_SET.to_string()], 0).with_label(label)),
+ dropdown: ctx.insert(Dropdown::new(vec![NOT_SET.to_string()], 0).with_label(label)),
})
.collect(),
}
@@ -97,7 +99,7 @@ pub enum DefaultAppsMessage {
const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
-fn rebuild_entry_options(entry: &mut CategoryEntry) {
+fn rebuild_entry_options(entry: &mut CategoryEntry, ctx: &mut UiContext) {
let mut options = Vec::new();
let mut ids = Vec::new();
let current_idx = entry
@@ -119,8 +121,9 @@ fn rebuild_entry_options(entry: &mut CategoryEntry) {
.as_ref()
.and_then(|cur| ids.iter().position(|i| i.as_deref() == Some(cur)))
.unwrap_or(0);
- entry.dropdown.options = options;
- entry.dropdown.selected = selected;
+ let dropdown = &mut ctx[entry.dropdown];
+ dropdown.options = options;
+ dropdown.selected = selected;
entry.option_ids = ids;
}
@@ -136,7 +139,7 @@ pub fn view(state: &mut DefaultAppsState, cx: f32, cy: f32, cw: f32, ch: f32, _r
col.text("Scanning installed applications...", 12.0, TEXT_DIM);
} else {
for entry in state.categories.iter_mut() {
- col.widget(&mut entry.dropdown, cce_ui::layout::dropdown_height());
+ col.widget_h(ctx, entry.dropdown, cce_ui::layout::dropdown_height());
}
}
sec.place(form, ctx);
@@ -145,17 +148,17 @@ pub fn view(state: &mut DefaultAppsState, cx: f32, cy: f32, cw: f32, ch: f32, _r
final_pc
}
-pub fn update(state: &mut DefaultAppsState, msg: DefaultAppsMessage) {
+pub fn update(state: &mut DefaultAppsState, msg: DefaultAppsMessage, ctx: &mut UiContext) {
match msg {
DefaultAppsMessage::Refreshed(info) => {
state.loaded = true;
for (entry, cat) in state.categories.iter_mut().zip(info.0.into_iter()) {
// Leave an open dropdown alone — the next refresh normalizes it.
- if entry.info == cat || entry.dropdown.open {
+ if entry.info == cat || ctx[entry.dropdown].open {
continue;
}
entry.info = cat;
- rebuild_entry_options(entry);
+ rebuild_entry_options(entry, ctx);
}
}
DefaultAppsMessage::Set(cat_idx, opt_idx) => {
@@ -194,7 +197,7 @@ pub fn update(state: &mut DefaultAppsState, msg: DefaultAppsMessage) {
CategoryKind::Terminal => set_default_terminal(&id),
}
entry.info.current = Some(id);
- rebuild_entry_options(entry);
+ rebuild_entry_options(entry, ctx);
}
}
}
@@ -447,10 +450,10 @@ impl crate::pages::AppPage for DefaultAppsState {
view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
for (i, entry) in self.categories.iter_mut().enumerate() {
- if entry.dropdown.take_change() {
- actions.push(AppAction::DefaultApps(DefaultAppsMessage::Set(i, entry.dropdown.selected)));
+ if ctx[entry.dropdown].take_change() {
+ actions.push(AppAction::DefaultApps(DefaultAppsMessage::Set(i, ctx[entry.dropdown].selected)));
}
}
}
@@ -463,7 +466,8 @@ mod tests {
#[test]
fn section_widgets_mirror_load_gate() {
use crate::pages::AppPage;
- let mut st = DefaultAppsState::default();
+ let mut ui = UiContext::new();
+ let mut st = DefaultAppsState::new(&mut ui);
// Still scanning: the dropdowns are not in the stack yet, so none of
// them may be reported as a dispatch root.
assert_eq!(st.section_widgets(), vec![Vec::new()]);
@@ -490,7 +494,8 @@ mod tests {
#[test]
fn rebuild_options_maps_current() {
- let mut st = DefaultAppsState::default();
+ let mut ui = UiContext::new();
+ let mut st = DefaultAppsState::new(&mut ui);
let e = &mut st.categories[0];
e.info = CategoryInfo {
candidates: vec![
@@ -499,16 +504,16 @@ mod tests {
],
current: Some("b.desktop".into()),
};
- rebuild_entry_options(e);
- assert_eq!(e.dropdown.options, vec!["Alpha".to_string(), "Beta".to_string()]);
- assert_eq!(e.dropdown.selected, 1);
+ rebuild_entry_options(e, &mut ui);
+ assert_eq!(ui[e.dropdown].options, vec!["Alpha".to_string(), "Beta".to_string()]);
+ assert_eq!(ui[e.dropdown].selected, 1);
assert_eq!(e.option_ids[1].as_deref(), Some("b.desktop"));
// No current: placeholder row leads and is selected.
e.info.current = None;
- rebuild_entry_options(e);
- assert_eq!(e.dropdown.options[0], NOT_SET);
- assert_eq!(e.dropdown.selected, 0);
+ rebuild_entry_options(e, &mut ui);
+ assert_eq!(ui[e.dropdown].options[0], NOT_SET);
+ assert_eq!(ui[e.dropdown].selected, 0);
assert!(e.option_ids[0].is_none());
}
}
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 5ca353c..ac89fc4 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -97,7 +97,7 @@ pub trait AppPage {
ctx: &mut cce_ui::context::UiContext,
) -> crate::app::PageContent;
- fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>);
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut cce_ui::context::UiContext);
fn handle_pointer_move(
&mut self,
@@ -171,10 +171,9 @@ mod list_clip_tests {
use super::*;
use cce_ui::widget::ScrollRegion;
- fn render(page: &mut dyn AppPage) -> crate::app::PageContent {
+ fn render(page: &mut dyn AppPage, ui: &mut cce_ui::context::UiContext) -> crate::app::PageContent {
let mut layout = cce_ui::layout::PageFlow::new();
- let mut ctx = cce_ui::context::UiContext::new();
- page.view(10.0, 20.0, 820.0, 640.0, false, &[false; 4], &mut layout, &mut ctx)
+ page.view(10.0, 20.0, 820.0, 640.0, false, &[false; 4], &mut layout, ui)
}
/// A list sits inside its section's clip exactly when the clip it
@@ -208,7 +207,8 @@ mod list_clip_tests {
/// are carved into; a framed one lays its own under its bg.
#[test]
fn every_inner_list_rides_its_centre_line_behind_the_plate() {
- let app = crate::app::AppState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let app = crate::app::AppState::new(&mut ui);
let lists: [(&str, &ScrollRegion); 7] = [
("accounts", &app.accounts.list),
("network", &app.network.wifi_list),
@@ -231,7 +231,8 @@ mod list_clip_tests {
#[test]
fn services_list_inside_its_section() {
- let mut s = services::ServicesState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut s = services::ServicesState::new(&mut ui);
s.loaded = true;
s.services = (0..5)
.map(|i| services::ServiceInfo {
@@ -242,13 +243,14 @@ mod list_clip_tests {
is_system: true,
})
.collect();
- let pc = render(&mut s);
+ let pc = render(&mut s, &mut ui);
assert_list_inside("services", &pc, &s.list);
}
#[test]
fn timers_list_inside_its_section() {
- let mut s = timers::TimersState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut s = timers::TimersState::new(&mut ui);
s.loaded = true;
s.timers = (0..5)
.map(|i| timers::TimerInfo {
@@ -262,47 +264,51 @@ mod list_clip_tests {
editable: false,
})
.collect();
- let pc = render(&mut s);
+ let pc = render(&mut s, &mut ui);
assert_list_inside("timers", &pc, &s.list);
}
#[test]
fn packages_list_inside_its_section() {
- let mut s = packages::PackagesState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut s = packages::PackagesState::new(&mut ui);
s.loaded = true;
s.installed = (0..5)
.map(|i| packages::PackageInfo { name: format!("p{i}"), version: "1.0".into(), ..Default::default() })
.collect();
- let pc = render(&mut s);
+ let pc = render(&mut s, &mut ui);
assert_list_inside("packages", &pc, &s.installed_list);
}
#[test]
fn network_list_inside_its_section() {
- let mut s = network::NetworkState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut s = network::NetworkState::new(&mut ui);
s.loaded = true;
s.wifi_enabled = true;
s.available = (0..3)
.map(|i| network::WifiNetwork { ssid: format!("net{i}"), signal: 50, secured: true, in_use: i == 0 })
.collect();
- let pc = render(&mut s);
+ let pc = render(&mut s, &mut ui);
assert_list_inside("network", &pc, &s.wifi_list);
}
#[test]
fn accounts_list_inside_its_section() {
- let mut s = accounts::AccountsState::default_mock();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut s = accounts::AccountsState::new(&mut ui);
s.loaded = true;
s.accounts = vec![serde_json::from_str(
r#"{"email":"a@example.org","imap":"imap.example.org:993","smtp":"smtp.example.org:465","is_default":true,"password":""}"#,
)
.unwrap()];
- let pc = render(&mut s);
+ let pc = render(&mut s, &mut ui);
assert_list_inside("accounts", &pc, &s.list);
}
#[test]
fn processes_list_inside_its_section() {
+ let mut ui = cce_ui::context::UiContext::new();
let mut s = processes::ProcessesState { loaded: true, ..Default::default() };
s.processes = (0..5)
.map(|i| processes::ProcessRow {
@@ -315,7 +321,7 @@ mod list_clip_tests {
wakeups: None,
})
.collect();
- let pc = render(&mut s);
+ let pc = render(&mut s, &mut ui);
assert_list_inside("processes", &pc, &s.cpu_list);
}
}
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 27c0f15..a93fb09 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,5 +1,6 @@
use crate::app::{AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::widget::ScrollRegion;
use cce_ui::layout::{lay_row, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
use cce_ui::scene::layout::Rect;
@@ -23,7 +24,7 @@ pub struct NetworkState {
pub device: String,
pub available: Vec<WifiNetwork>,
pub wifi_list: ScrollRegion,
- pub wifi_toggle: Owned<Adapted<Toggle>>,
+ pub wifi_toggle: Handle<Adapted<Toggle>>,
}
impl Default for NetworkState {
@@ -37,11 +38,18 @@ impl Default for NetworkState {
device: String::new(),
available: Vec::new(),
wifi_list: ScrollRegion::new(26.0, 4.0).with_sink_behind(true),
- wifi_toggle: Owned::new(Toggle::new()),
+ wifi_toggle: Handle::none(),
}
}
}
+impl NetworkState {
+ /// The page's state, its widgets inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
+ Self { wifi_toggle: ctx.insert(Toggle::new()), ..Self::default() }
+ }
+}
+
#[derive(Debug, Clone)]
pub enum NetworkMessage {
Refreshed(NetworkState),
@@ -101,7 +109,7 @@ pub async fn fetch_network_state() -> NetworkState {
wifi_enabled, connected_ssid, signal_strength: signal,
ip_address, device, available,
wifi_list: ScrollRegion::new(26.0, 4.0).with_sink_behind(true),
- wifi_toggle: Owned::new(Toggle::new()),
+ wifi_toggle: Handle::none(),
}
}
@@ -180,11 +188,11 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
col.text("Loading WiFi interfaces...", 12.0, TEXT_DIM);
} else {
let wifi_btn_w = if sec_w < 200.0 { 40.0 } else { 60.0 };
- state.wifi_toggle.set_toggled(state.wifi_enabled);
- state.wifi_toggle.set_label(if state.wifi_enabled { "ON" } else { "OFF" });
+ ctx[state.wifi_toggle].set_toggled(state.wifi_enabled);
+ ctx[state.wifi_toggle].set_label(if state.wifi_enabled { "ON" } else { "OFF" });
// At its own width, not the row's: the old wide "ON" plate.
col.row(|r| {
- r.widget_w(&mut state.wifi_toggle, wifi_btn_w, cce_ui::layout::toggle_height());
+ r.widget_w_h(ctx, state.wifi_toggle, wifi_btn_w, cce_ui::layout::toggle_height());
});
if state.wifi_enabled {
@@ -261,7 +269,7 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
final_pc
}
-pub fn update(state: &mut NetworkState, msg: NetworkMessage) {
+pub fn update(state: &mut NetworkState, msg: NetworkMessage, _ctx: &mut UiContext) {
match msg {
NetworkMessage::Refreshed(new) => {
state.loaded = new.loaded;
@@ -317,8 +325,8 @@ impl crate::pages::AppPage for NetworkState {
view(self, cx, cy, cw, ch, focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
- if self.wifi_toggle.take_change() {
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+ if ctx[self.wifi_toggle].take_change() {
actions.push(crate::app::AppAction::Network(NetworkMessage::ToggleWifi));
}
}
@@ -360,31 +368,34 @@ mod tests {
#[test]
fn test_view_layout_grid() {
- let mut state = NetworkState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = NetworkState::new(&mut ui);
state.loaded = true;
state.wifi_enabled = true;
let mut layout = cce_ui::layout::PageFlow::new();
- let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut cce_ui::context::UiContext::new());
+ let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut ui);
assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
}
#[test]
fn test_view_layout_connected() {
- let mut state = NetworkState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = NetworkState::new(&mut ui);
state.loaded = true;
state.wifi_enabled = true;
state.connected_ssid = "MyHomeWiFi".to_string();
state.signal_strength = 80;
state.ip_address = "192.168.1.50".to_string();
let mut layout = cce_ui::layout::PageFlow::new();
- let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut cce_ui::context::UiContext::new());
+ let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &mut layout, &mut ui);
assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
}
#[test]
fn section_widgets_mirror_load_gate() {
+ let mut ui = cce_ui::context::UiContext::new();
use crate::pages::AppPage;
- let mut st = NetworkState::default();
+ let mut st = NetworkState::new(&mut ui);
// Not loaded: the view paints only "Loading WiFi interfaces...", so
// reporting the toggle would be a root nothing registered this frame.
assert_eq!(st.section_widgets(), vec![Vec::new()]);
diff --git a/src/pages/notifications.rs b/src/pages/notifications.rs
index 31c7fd6..4c142f5 100644
--- a/src/pages/notifications.rs
+++ b/src/pages/notifications.rs
@@ -1,6 +1,7 @@
use std::fs;
use std::io::Write;
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::widget::input::{Toggle, Dropdown, Spinbox};
use cce_ui::layout::{PageLayoutBuilder, PageFlow};
use crate::app::{AppAction, PageContent};
@@ -18,11 +19,11 @@ pub struct NotificationsConfig {
pub struct NotificationsState {
pub loaded: bool,
pub enable: bool,
- pub enable_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
+ pub enable_toggle: Handle<cce_ui::widget::Adapted<Toggle>>,
pub bell: String,
- pub bell_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
+ pub bell_menu: Handle<cce_ui::widget::Adapted<Dropdown>>,
pub duration: i32,
- pub duration_spinbox: Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>,
+ pub duration_spinbox: Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>,
}
impl Default for NotificationsState {
@@ -30,14 +31,26 @@ impl Default for NotificationsState {
Self {
loaded: false,
enable: true,
+ enable_toggle: Handle::none(),
+ bell: "none".to_string(),
+ bell_menu: Handle::none(),
+ duration: 5,
+ duration_spinbox: Handle::none(),
+ }
+ }
+}
+
+impl NotificationsState {
+ /// The page's state, its widgets inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
+ Self {
// Left-aligned: centred, the label straddled the seam at the
// toggle's midpoint (see the Browser page's toggles).
- enable_toggle: Owned::new(Toggle::new()
+ enable_toggle: ctx.insert(Toggle::new()
.with_label("Enable Notifications")
.with_left_align(true)
.with_config(&get_config_path(), "enable")),
- bell: "none".to_string(),
- bell_menu: Owned::new(Dropdown::new(
+ bell_menu: ctx.insert(Dropdown::new(
vec![
"None".to_string(),
"Bell".to_string(),
@@ -46,11 +59,11 @@ impl Default for NotificationsState {
],
0,
).with_label("Notification Sound")),
- duration: 5,
- duration_spinbox: Owned::new(Spinbox::new(5, 1, 60, 1)
+ duration_spinbox: ctx.insert(Spinbox::new(5, 1, 60, 1)
.with_label("Notification Duration")
.with_unit("s")
.with_config(&get_config_path(), "duration")),
+ ..Self::default()
}
}
}
@@ -64,7 +77,7 @@ pub enum NotificationsMessage {
Refreshed(NotificationsConfig),
}
-pub fn update(state: &mut NotificationsState, msg: NotificationsMessage) {
+pub fn update(state: &mut NotificationsState, msg: NotificationsMessage, _ctx: &mut UiContext) {
match msg {
NotificationsMessage::ToggleNotificationsEnable => {
state.enable = !state.enable;
@@ -212,21 +225,21 @@ impl AppPage for NotificationsState {
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section(&mut final_pc, "Notifications Settings", sec_focused.first().copied().unwrap_or(false), |sec| {
- self.enable_toggle.set_toggled(self.enable);
- self.bell_menu.selected = match self.bell.as_str() {
+ ctx[self.enable_toggle].set_toggled(self.enable);
+ ctx[self.bell_menu].selected = match self.bell.as_str() {
"bell" => 1,
"dialog" => 2,
"message" => 3,
_ => 0,
};
- self.duration_spinbox.value = self.duration;
- self.duration_spinbox.set_label("Notification Duration");
+ ctx[self.duration_spinbox].value = self.duration;
+ ctx[self.duration_spinbox].set_label("Notification Duration");
let mut form = sec.form();
form.column()
- .widget(&mut self.enable_toggle, cce_ui::layout::toggle_height())
- .widget(&mut self.bell_menu, cce_ui::layout::dropdown_height())
- .widget(&mut self.duration_spinbox, cce_ui::layout::spinbox_height())
+ .widget_h(ctx, self.enable_toggle, cce_ui::layout::toggle_height())
+ .widget_h(ctx, self.bell_menu, cce_ui::layout::dropdown_height())
+ .widget_h(ctx, self.duration_spinbox, cce_ui::layout::spinbox_height())
.draw(0.0, cce_ui::layout::button_height(), false, |pc, r, _| {
pc.button(
"Send Test Notification",
@@ -246,12 +259,12 @@ impl AppPage for NotificationsState {
final_pc
}
- fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
- if self.enable_toggle.take_change() {
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
+ if ctx[self.enable_toggle].take_change() {
actions.push(AppAction::Notifications(NotificationsMessage::ToggleNotificationsEnable));
}
- if self.bell_menu.take_change() {
- let sound = match self.bell_menu.selected {
+ if ctx[self.bell_menu].take_change() {
+ let sound = match ctx[self.bell_menu].selected {
0 => "none",
1 => "bell",
2 => "dialog",
@@ -260,8 +273,8 @@ impl AppPage for NotificationsState {
}.to_string();
actions.push(AppAction::Notifications(NotificationsMessage::SetNotificationsBell(sound)));
}
- if self.duration_spinbox.take_change() {
- actions.push(AppAction::Notifications(NotificationsMessage::SetNotificationsDuration(self.duration_spinbox.value)));
+ if ctx[self.duration_spinbox].take_change() {
+ actions.push(AppAction::Notifications(NotificationsMessage::SetNotificationsDuration(ctx[self.duration_spinbox].value)));
}
}
}
@@ -272,11 +285,11 @@ pub(crate) mod tests {
#[test]
fn test_view_layout_grid() {
- let mut state = NotificationsState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = NotificationsState::new(&mut ui);
let mut layout = cce_ui::layout::PageFlow::new();
let sec_focused = vec![false];
- let mut ctx = cce_ui::context::UiContext::new();
- let pc = state.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+ let pc = state.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ui);
assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
}
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index a94b991..00fab5a 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -1,7 +1,8 @@
use crate::app::{button_need, form_button, form_divider, wrap_to_width, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{lay_row, render_widget, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, render_widget_h, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
use cce_ui::scene::layout::Rect;
use cce_ui::widget::{TextBox, InteractiveListItem};
@@ -69,11 +70,11 @@ pub struct PackagesState {
pub installed: Vec<PackageInfo>,
pub updates: Vec<UpdateInfo>,
pub active_tab: PackageTab,
- pub search_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+ pub search_box: Handle<cce_ui::widget::Adapted<TextBox>>,
pub installed_list: ScrollRegion,
- pub installed_items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
+ pub installed_items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
pub updates_list: ScrollRegion,
- pub updates_items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
+ pub updates_items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
pub updating: bool,
pub last_update_res: Option<Result<(), String>>,
pub selected_package: Option<String>,
@@ -99,7 +100,7 @@ impl Default for PackagesState {
installed: Vec::new(),
updates: Vec::new(),
active_tab: PackageTab::Installed,
- search_box: Owned::new(TextBox::new(String::new()).with_placeholder("Filter Packages...")),
+ search_box: Handle::none(),
installed_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
installed_items: Vec::new(),
updates_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
@@ -121,6 +122,16 @@ impl Default for PackagesState {
}
}
+impl PackagesState {
+ /// The page's state, its widgets inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
+ Self {
+ search_box: ctx.insert(TextBox::new(String::new()).with_placeholder("Filter Packages...")),
+ ..Self::default()
+ }
+ }
+}
+
#[derive(Debug, Clone)]
pub enum PackagesMessage {
Refreshed(PackagesState),
@@ -554,24 +565,28 @@ pub fn view(
let text_on = [0.90, 0.90, 0.95, 1.0];
let busy = state.busy();
- let query = if state.search_box.editing {
- state.search_box.edit_buffer.to_lowercase()
+ let query = if ctx[state.search_box].editing {
+ ctx[state.search_box].edit_buffer.to_lowercase()
} else {
- state.search_box.text.to_lowercase()
+ ctx[state.search_box].text.to_lowercase()
};
// What the list shows, worked out before the widgets are lent to the form.
- let visible_installed = state.visible_installed();
+ let visible_installed = state.visible_installed(ctx);
if state.installed_items.len() != visible_installed.len() {
- state.installed_items.clear();
+ for h in state.installed_items.drain(..) {
+ ctx.remove(h);
+ }
for _ in 0..visible_installed.len() {
- state.installed_items.push(Owned::new(InteractiveListItem::new("")));
+ state.installed_items.push(ctx.insert(InteractiveListItem::new("")));
}
}
let filtered_updates: Vec<&UpdateInfo> = state.updates.iter().filter(|p| p.name.to_lowercase().contains(&query)).collect();
if state.updates_items.len() != filtered_updates.len() {
- state.updates_items.clear();
+ for h in state.updates_items.drain(..) {
+ ctx.remove(h);
+ }
for _ in 0..filtered_updates.len() {
- state.updates_items.push(Owned::new(InteractiveListItem::new("")));
+ state.updates_items.push(ctx.insert(InteractiveListItem::new("")));
}
}
@@ -588,7 +603,7 @@ pub fn view(
AppAction::Packages(PackagesMessage::SetTab(PackageTab::Updates)));
});
- col.widget(&mut state.search_box, cce_ui::layout::textbox_height());
+ col.widget_h(ctx, state.search_box, cce_ui::layout::textbox_height());
// ── Update System: a button as wide as its label, its status beside it ──
let status_line = if state.updating {
@@ -842,8 +857,8 @@ pub fn view(
let checked = &state.checked;
let selected = state.selected_package.clone();
let installed = &state.installed;
- let (installed_list, installed_items) = (&mut state.installed_list, &mut state.installed_items);
- let (updates_list, updates_items) = (&mut state.updates_list, &mut state.updates_items);
+ let (installed_list, installed_items) = (&mut state.installed_list, &state.installed_items);
+ let (updates_list, updates_items) = (&mut state.updates_list, &state.updates_items);
col.fill(LIST_MIN_H, move |pc, rect, ctx| {
let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
let inset = cce_ui::layout::plate_padding();
@@ -860,7 +875,8 @@ pub fn view(
for (idx, pkg) in filtered.iter().enumerate() {
if let Some(draw_y) = installed_list.get_item_draw_y(idx, 4.0) {
// Rows dispatch as extra roots (the dissolved list is no parent).
- let item = &mut installed_items[idx];
+ let row = installed_items[idx];
+ let item = &mut ctx[row];
item.title = pkg.name.clone();
let reason = match (pkg.explicit, pkg.orphan) {
(true, _) => "explicit",
@@ -874,7 +890,7 @@ pub fn view(
Some(&pkg.name) == selected.as_ref()
};
let cell = lay_row(Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h }, &[Cell::grow(item_h)])[0];
- render_widget(pc, item, cell.x, cell.y, cell.width, cell.height, ctx);
+ render_widget_h(pc, row, cell.x, cell.y, cell.width, cell.height, ctx);
}
}
pc.pop_clip_rect();
@@ -895,12 +911,13 @@ pub fn view(
for (idx, pkg) in filtered_updates.iter().enumerate() {
if let Some(draw_y) = updates_list.get_item_draw_y(idx, 4.0) {
// Rows dispatch as extra roots (the dissolved list is no parent).
- let item = &mut updates_items[idx];
+ let row = updates_items[idx];
+ let item = &mut ctx[row];
item.title = pkg.name.clone();
item.subtitle = Some(format!("{} -> {}", pkg.old_version, pkg.new_version));
item.selected = Some(&pkg.name) == selected.as_ref();
let cell = lay_row(Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h }, &[Cell::grow(item_h)])[0];
- render_widget(pc, item, cell.x, cell.y, cell.width, cell.height, ctx);
+ render_widget_h(pc, row, cell.x, cell.y, cell.width, cell.height, ctx);
}
}
pc.pop_clip_rect();
@@ -918,7 +935,7 @@ pub fn view(
final_pc
}
-pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
+pub fn update(state: &mut PackagesState, msg: PackagesMessage, ctx: &mut UiContext) {
match msg {
PackagesMessage::Refreshed(new) => {
state.loaded = new.loaded;
@@ -943,8 +960,12 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
state.active_tab = tab;
state.installed_list.set_scroll_y(0.0);
state.updates_list.set_scroll_y(0.0);
- state.installed_items.clear();
- state.updates_items.clear();
+ for h in state.installed_items.drain(..) {
+ ctx.remove(h);
+ }
+ for h in state.updates_items.drain(..) {
+ ctx.remove(h);
+ }
state.selected_package = None;
state.selected_package_info = None;
state.loading_info = false;
@@ -981,7 +1002,9 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
if state.filter != filter {
state.filter = filter;
state.installed_list.set_scroll_y(0.0);
- state.installed_items.clear();
+ for h in state.installed_items.drain(..) {
+ ctx.remove(h);
+ }
}
}
PackagesMessage::ToggleSelectMode => {
@@ -1001,7 +1024,7 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
}
}
PackagesMessage::CheckAllVisible => {
- for i in state.visible_installed() {
+ for i in state.visible_installed(ctx) {
state.checked.insert(state.installed[i].name.clone());
}
}
@@ -1074,7 +1097,7 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
});
}
PackagesMessage::SelectAndScrollPackage(name) => {
- state.select_and_scroll_to(&name);
+ state.select_and_scroll_to(&name, ctx);
}
}
}
@@ -1087,19 +1110,19 @@ impl PackagesState {
}
/// The search query as typed so far (the box commits only on Enter).
- fn query(&self) -> String {
- if self.search_box.editing {
- self.search_box.edit_buffer.to_lowercase()
+ fn query(&self, ctx: &UiContext) -> String {
+ if ctx[self.search_box].editing {
+ ctx[self.search_box].edit_buffer.to_lowercase()
} else {
- self.search_box.text.to_lowercase()
+ ctx[self.search_box].text.to_lowercase()
}
}
/// Indices into `installed` of the rows the Installed tab shows, in order:
/// the filter, then the search. The view paints exactly these and the click
/// mapping resolves against them, so the two can never disagree.
- pub fn visible_installed(&self) -> Vec<usize> {
- let query = self.query();
+ pub fn visible_installed(&self, ctx: &UiContext) -> Vec<usize> {
+ let query = self.query(ctx);
self.installed
.iter()
.enumerate()
@@ -1109,17 +1132,19 @@ impl PackagesState {
.collect()
}
- pub fn select_and_scroll_to(&mut self, pkg_name: &str) {
+ pub fn select_and_scroll_to(&mut self, pkg_name: &str, ctx: &mut UiContext) {
self.active_tab = PackageTab::Installed;
// The scroll target below is an index into the whole list, so the
// list must be unfiltered for it to land on the package.
self.filter = InstalledFilter::All;
self.select_mode = false;
self.checked.clear();
- self.installed_items.clear();
- self.search_box.text.clear();
- self.search_box.edit_buffer.clear();
- self.search_box.editing = false;
+ for h in self.installed_items.drain(..) {
+ ctx.remove(h);
+ }
+ ctx[self.search_box].text.clear();
+ ctx[self.search_box].edit_buffer.clear();
+ ctx[self.search_box].editing = false;
self.selected_package = Some(pkg_name.to_string());
self.selected_package_info = None;
self.loading_info = true;
@@ -1170,17 +1195,18 @@ impl crate::pages::AppPage for PackagesState {
view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
- let query = if self.search_box.editing {
- self.search_box.edit_buffer.to_lowercase()
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
+ let query = if ctx[self.search_box].editing {
+ ctx[self.search_box].edit_buffer.to_lowercase()
} else {
- self.search_box.text.to_lowercase()
+ ctx[self.search_box].text.to_lowercase()
};
match self.active_tab {
PackageTab::Installed => {
- let visible = self.visible_installed();
- for (idx, item) in self.installed_items.iter_mut().enumerate() {
+ let visible = self.visible_installed(ctx);
+ for (idx, &row) in self.installed_items.iter().enumerate() {
+ let item = &mut ctx[row];
if item.just_clicked {
item.just_clicked = false;
if let Some(&i) = visible.get(idx) {
@@ -1198,7 +1224,8 @@ impl crate::pages::AppPage for PackagesState {
let filtered: Vec<&UpdateInfo> = self.updates.iter()
.filter(|p| p.name.to_lowercase().contains(&query))
.collect();
- for (idx, item) in self.updates_items.iter_mut().enumerate() {
+ for (idx, &row) in self.updates_items.iter().enumerate() {
+ let item = &mut ctx[row];
if item.just_clicked {
item.just_clicked = false;
if idx < filtered.len() {
@@ -1230,18 +1257,6 @@ impl crate::pages::AppPage for PackagesState {
.collect()
}
- fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
- let (list, items) = match self.active_tab {
- PackageTab::Installed => (&self.installed_list, &mut self.installed_items),
- PackageTab::Updates => (&self.updates_list, &mut self.updates_items),
- };
- for (idx, i) in items.iter_mut().enumerate() {
- if list.get_item_draw_y(idx, 4.0).is_none() {
- continue;
- }
- ctx.register_host(i);
- }
- }
fn handle_pointer_move(
&mut self,
@@ -1288,7 +1303,8 @@ mod tests {
#[test]
fn refresh_preserves_selection_and_flags() {
- let mut st = PackagesState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = PackagesState::new(&mut ui);
st.loaded = true;
st.installed = vec![PackageInfo { name: "foo".into(), version: "1".into(), ..Default::default() }];
st.selected_package = Some("foo".into());
@@ -1300,14 +1316,14 @@ mod tests {
installed: vec![PackageInfo { name: "foo".into(), version: "2".into(), ..Default::default() }],
..Default::default()
};
- update(&mut st, PackagesMessage::Refreshed(fresh));
+ update(&mut st, PackagesMessage::Refreshed(fresh), &mut ui);
assert_eq!(st.selected_package.as_deref(), Some("foo"), "refresh must keep the selection");
assert!(st.selected_package_info.is_some(), "refresh must keep fetched info");
assert!(st.updating, "refresh must not clear the in-flight update flag");
// A package that vanished from both lists does clear the selection.
let fresh2 = PackagesState { loaded: true, ..Default::default() };
- update(&mut st, PackagesMessage::Refreshed(fresh2));
+ update(&mut st, PackagesMessage::Refreshed(fresh2), &mut ui);
assert!(st.selected_package.is_none());
assert!(st.selected_package_info.is_none());
}
@@ -1316,8 +1332,8 @@ mod tests {
PackageInfo { name: name.into(), version: "1".into(), explicit, orphan }
}
- fn loaded_state() -> PackagesState {
- let mut st = PackagesState::default();
+ fn loaded_state(ui: &mut UiContext) -> PackagesState {
+ let mut st = PackagesState::new(ui);
st.loaded = true;
st.installed = vec![
pkg("alpha", true, false),
@@ -1340,40 +1356,41 @@ mod tests {
#[test]
fn filter_and_search_compose() {
- let mut st = loaded_state();
- assert_eq!(st.visible_installed().len(), 4);
- update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
- assert_eq!(st.visible_installed(), vec![1, 3]);
- update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Explicit));
- assert_eq!(st.visible_installed(), vec![0]);
- update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
- st.search_box.text = "gamma".into();
- assert_eq!(st.visible_installed(), vec![3]);
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded_state(&mut ui);
+ assert_eq!(st.visible_installed(&ui).len(), 4);
+ update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
+ assert_eq!(st.visible_installed(&ui), vec![1, 3]);
+ update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Explicit), &mut ui);
+ assert_eq!(st.visible_installed(&ui), vec![0]);
+ update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
+ ui[st.search_box].text = "gamma".into();
+ assert_eq!(st.visible_installed(&ui), vec![3]);
}
/// A click on visible row N must name the package painted at row N, under
/// a filter too — the view and the click mapping share `visible_installed`.
#[test]
fn row_click_resolves_through_the_filter() {
- let mut st = loaded_state();
- update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded_state(&mut ui);
+ update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
let mut layout = cce_ui::layout::PageFlow::new();
- let mut ctx = cce_ui::context::UiContext::new();
- view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ctx);
+ view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
assert_eq!(st.installed_items.len(), 2);
- st.installed_items[1].just_clicked = true;
+ ui[st.installed_items[1]].just_clicked = true;
let mut actions = Vec::new();
- crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions);
+ crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions, &mut ui);
match actions.as_slice() {
[AppAction::Packages(PackagesMessage::SelectPackage(Some(n)))] => assert_eq!(n, "gamma-orphan"),
other => panic!("unexpected actions: {other:?}"),
}
// In select mode the same click toggles the row instead.
- update(&mut st, PackagesMessage::ToggleSelectMode);
- st.installed_items[1].just_clicked = true;
+ update(&mut st, PackagesMessage::ToggleSelectMode, &mut ui);
+ ui[st.installed_items[1]].just_clicked = true;
let mut actions = Vec::new();
- crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions);
+ crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions, &mut ui);
match actions.as_slice() {
[AppAction::Packages(PackagesMessage::ToggleChecked(n))] => assert_eq!(n, "gamma-orphan"),
other => panic!("unexpected actions: {other:?}"),
@@ -1382,21 +1399,22 @@ mod tests {
#[test]
fn select_mode_checks_and_clears() {
- let mut st = loaded_state();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded_state(&mut ui);
st.selected_package = Some("alpha".into());
- update(&mut st, PackagesMessage::ToggleSelectMode);
+ update(&mut st, PackagesMessage::ToggleSelectMode, &mut ui);
assert!(st.selected_package.is_none(), "select mode frees the details pane");
- update(&mut st, PackagesMessage::ToggleChecked("beta".into()));
- update(&mut st, PackagesMessage::ToggleChecked("beta".into()));
+ update(&mut st, PackagesMessage::ToggleChecked("beta".into()), &mut ui);
+ update(&mut st, PackagesMessage::ToggleChecked("beta".into()), &mut ui);
assert!(st.checked.is_empty(), "a second click unchecks");
- update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
- update(&mut st, PackagesMessage::CheckAllVisible);
+ update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
+ update(&mut st, PackagesMessage::CheckAllVisible, &mut ui);
assert_eq!(st.checked.iter().cloned().collect::<Vec<_>>(), ["beta", "gamma-orphan"]);
// A refresh drops checked names that are gone.
let fresh = PackagesState { loaded: true, installed: vec![pkg("beta", false, true)], ..Default::default() };
- update(&mut st, PackagesMessage::Refreshed(fresh));
+ update(&mut st, PackagesMessage::Refreshed(fresh), &mut ui);
assert_eq!(st.checked.iter().cloned().collect::<Vec<_>>(), ["beta"]);
- update(&mut st, PackagesMessage::ToggleSelectMode);
+ update(&mut st, PackagesMessage::ToggleSelectMode, &mut ui);
assert!(st.checked.is_empty(), "leaving select mode clears the selection");
}
@@ -1405,35 +1423,37 @@ mod tests {
/// where the info parser found no known key and showed nothing at all.
#[test]
fn refused_removal_stays_visible() {
- let mut st = loaded_state();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded_state(&mut ui);
let err = "error: failed to prepare transaction (could not satisfy dependencies)\n\
:: removing beta breaks dependency 'beta' required by alpha".to_string();
- update(&mut st, PackagesMessage::PreviewRemoval(vec!["beta".into()]));
+ update(&mut st, PackagesMessage::PreviewRemoval(vec!["beta".into()]), &mut ui);
assert!(st.previewing);
- update(&mut st, PackagesMessage::RemovalPreviewed(vec!["beta".into()], Err(err.clone())));
+ update(&mut st, PackagesMessage::RemovalPreviewed(vec!["beta".into()], Err(err.clone())), &mut ui);
assert!(!st.previewing);
let mut layout = cce_ui::layout::PageFlow::new();
- let pc = view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
+ let pc = view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
let texts: Vec<String> = pc.texts.iter().map(|t| t.0.clone()).collect();
assert!(texts.iter().any(|t| t.contains("required by alpha")), "the reason is painted: {texts:?}");
// And a failed transaction reports into last_action, not the details.
- update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]));
- update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Err(err)));
+ update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]), &mut ui);
+ update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Err(err)), &mut ui);
assert!(!st.uninstalling);
assert!(matches!(&st.last_action, Some(Err(e)) if e.contains("required by alpha")));
}
#[test]
fn successful_removal_reports_the_whole_plan() {
- let mut st = loaded_state();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded_state(&mut ui);
st.checked.insert("beta".into());
update(&mut st, PackagesMessage::RemovalPreviewed(
vec!["beta".into()],
Ok(parse_removal_preview("beta 1024\nlibbeta 2048\n")),
- ));
- update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]));
- update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Ok(())));
+ ), &mut ui);
+ update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]), &mut ui);
+ update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Ok(())), &mut ui);
assert!(st.removal.is_none());
assert!(st.checked.is_empty());
assert!(matches!(&st.last_action, Some(Ok(m)) if m == "Removed 2 packages"));
@@ -1441,10 +1461,11 @@ mod tests {
#[test]
fn marking_explicit_updates_rows_at_once() {
- let mut st = loaded_state();
- update(&mut st, PackagesMessage::SetInstallReason(vec!["beta".into()], true));
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded_state(&mut ui);
+ update(&mut st, PackagesMessage::SetInstallReason(vec!["beta".into()], true), &mut ui);
assert!(st.busy());
- update(&mut st, PackagesMessage::InstallReasonSet(vec!["beta".into()], true, Ok(())));
+ update(&mut st, PackagesMessage::InstallReasonSet(vec!["beta".into()], true, Ok(())), &mut ui);
assert!(!st.busy());
assert!(st.installed[1].explicit && !st.installed[1].orphan);
assert!(matches!(&st.last_action, Some(Ok(m)) if m == "Marked beta as explicitly installed"));
@@ -1452,9 +1473,10 @@ mod tests {
#[test]
fn jump_to_package_clears_the_filter() {
- let mut st = loaded_state();
- update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans));
- update(&mut st, PackagesMessage::SelectAndScrollPackage("alpha".into()));
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded_state(&mut ui);
+ update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
+ update(&mut st, PackagesMessage::SelectAndScrollPackage("alpha".into()), &mut ui);
assert_eq!(st.filter, InstalledFilter::All);
assert_eq!(st.selected_package.as_deref(), Some("alpha"));
}
@@ -1491,9 +1513,10 @@ mod tests {
#[test]
fn test_view_layout_grid() {
- let mut state = PackagesState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = PackagesState::new(&mut ui);
let mut layout = cce_ui::layout::PageFlow::new();
- let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false, false], &mut layout, &mut cce_ui::context::UiContext::new());
+ let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false, false], &mut layout, &mut ui);
assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
}
}
diff --git a/src/pages/power.rs b/src/pages/power.rs
index 9a2accb..3a96601 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -44,7 +44,8 @@
use crate::app::{AppAction, PageContent};
use crate::power_plan::{self, Automation, ChargeLimit, Lever, Mode, PowerPlan, Source};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::layout::{PageFlow, PageLayoutBuilder};
use cce_ui::widget::{Adapted, Dropdown};
use std::path::{Path, PathBuf};
@@ -130,7 +131,7 @@ pub struct PowerFacts {
#[derive(Debug, Clone)]
pub struct LeverSet {
/// One dropdown per [`Lever::ALL`] entry, in that order.
- pub dds: Vec<Owned<Adapted<Dropdown>>>,
+ pub dds: Vec<Handle<Adapted<Dropdown>>>,
/// The plan value behind each row of each dropdown (options are display
/// text). Row 0 is always the "Not set" row and holds the empty string;
/// an empty Vec means the interface is absent on this host and the
@@ -138,12 +139,13 @@ pub struct LeverSet {
pub rows: Vec<Vec<String>>,
}
-impl Default for LeverSet {
- fn default() -> Self {
+impl LeverSet {
+ /// The set, its dropdowns inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
Self {
dds: Lever::ALL
.iter()
- .map(|l| Owned::new(Dropdown::new(vec!["—".to_string()], 0).with_label(l.label())))
+ .map(|l| ctx.insert(Dropdown::new(vec!["—".to_string()], 0).with_label(l.label())))
.collect(),
rows: vec![Vec::new(); Lever::ALL.len()],
}
@@ -158,32 +160,33 @@ fn lever_index(lever: Lever) -> usize {
pub struct PowerState {
pub loaded: bool,
pub facts: PowerFacts,
- pub dd_limit: Owned<Adapted<Dropdown>>,
+ pub dd_limit: Handle<Adapted<Dropdown>>,
/// The charge window per charge-limit dropdown row (options are display
/// text).
pub limit_values: Vec<ChargeLimit>,
/// Which mode the lever section is editing. Page state, not plan state:
/// it says what is on screen, never what the machine runs.
pub editing: Mode,
- pub dd_mode: Owned<Adapted<Dropdown>>,
+ pub dd_mode: Handle<Adapted<Dropdown>>,
pub levers: LeverSet,
/// One mode picker per [`Source::ALL`] entry, in that order.
- pub dd_assign: Vec<Owned<Adapted<Dropdown>>>,
+ pub dd_assign: Vec<Handle<Adapted<Dropdown>>>,
}
-impl Default for PowerState {
- fn default() -> Self {
+impl PowerState {
+ /// The page's state, its widgets inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
Self {
loaded: false,
facts: PowerFacts::default(),
- dd_limit: Owned::new(Dropdown::new(vec!["—".to_string()], 0).with_label("Battery Charge Limit")),
+ dd_limit: ctx.insert(Dropdown::new(vec!["—".to_string()], 0).with_label("Battery Charge Limit")),
limit_values: Vec::new(),
editing: Mode::default(),
- dd_mode: Owned::new(Dropdown::new(mode_options(), 0).with_label("Mode")),
- levers: LeverSet::default(),
+ dd_mode: ctx.insert(Dropdown::new(mode_options(), 0).with_label("Mode")),
+ levers: LeverSet::new(ctx),
dd_assign: Source::ALL
.iter()
- .map(|s| Owned::new(Dropdown::new(mode_options(), 0).with_label(s.label())))
+ .map(|s| ctx.insert(Dropdown::new(mode_options(), 0).with_label(s.label())))
.collect(),
}
}
@@ -619,17 +622,17 @@ fn set_live(lever: Lever, value: &str, f: &mut PowerFacts) {
/// value missing from the host's list gets appended as its own row. Skipped
/// per dropdown while it is open (the default_apps rule: never yank an open
/// menu out from under the pointer — the next refresh normalizes it).
-fn fill_levers(levers: &mut LeverSet, f: &PowerFacts, mode: Mode) {
+fn fill_levers(levers: &mut LeverSet, f: &PowerFacts, mode: Mode, ctx: &mut UiContext) {
let running = mode == f.plan.assigned(f.source);
for (i, lever) in Lever::ALL.iter().enumerate() {
- if levers.dds[i].open {
+ if ctx[levers.dds[i]].open {
continue;
}
let mut rows = choices(*lever, f);
if rows.is_empty() {
levers.rows[i].clear();
- levers.dds[i].options = vec!["—".to_string()];
- levers.dds[i].selected = 0;
+ ctx[levers.dds[i]].options = vec!["—".to_string()];
+ ctx[levers.dds[i]].selected = 0;
continue;
}
let planned: Option<String> = f.plan.get(mode, *lever).map(str::to_string);
@@ -649,8 +652,8 @@ fn fill_levers(levers: &mut LeverSet, f: &PowerFacts, mode: Mode) {
values.push(v);
options.push(d);
}
- levers.dds[i].selected = planned.and_then(|s| values.iter().position(|v| *v == s)).unwrap_or(0);
- levers.dds[i].options = options;
+ ctx[levers.dds[i]].selected = planned.and_then(|s| values.iter().position(|v| *v == s)).unwrap_or(0);
+ ctx[levers.dds[i]].options = options;
levers.rows[i] = values;
}
}
@@ -698,26 +701,26 @@ fn charge_rows(f: &PowerFacts) -> (Vec<ChargeLimit>, Vec<String>, usize) {
}
/// Rebuild every dropdown's options/selection from fresh facts.
-fn rebuild_options(state: &mut PowerState) {
+fn rebuild_options(state: &mut PowerState, ctx: &mut UiContext) {
let f = &state.facts;
- if !state.dd_limit.open {
+ if !ctx[state.dd_limit].open {
let (values, options, selected) = charge_rows(f);
- state.dd_limit.options = options;
- state.dd_limit.selected = selected;
+ ctx[state.dd_limit].options = options;
+ ctx[state.dd_limit].selected = selected;
state.limit_values = values;
}
- if !state.dd_mode.open {
- state.dd_mode.options = mode_options();
- state.dd_mode.selected = mode_index(state.editing);
+ if !ctx[state.dd_mode].open {
+ ctx[state.dd_mode].options = mode_options();
+ ctx[state.dd_mode].selected = mode_index(state.editing);
}
- fill_levers(&mut state.levers, &state.facts, state.editing);
+ fill_levers(&mut state.levers, &state.facts, state.editing, ctx);
for source in Source::ALL {
let i = source_index(source);
- if state.dd_assign[i].open {
+ if ctx[state.dd_assign[i]].open {
continue;
}
- state.dd_assign[i].options = mode_options();
- state.dd_assign[i].selected = mode_index(state.facts.plan.assigned(source));
+ ctx[state.dd_assign[i]].options = mode_options();
+ ctx[state.dd_assign[i]].selected = mode_index(state.facts.plan.assigned(source));
}
}
@@ -744,6 +747,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
}
let PowerState { facts, dd_limit, editing, dd_mode, levers, dd_assign, .. } = state;
+ let (dd_limit, dd_mode) = (*dd_limit, *dd_mode);
let editing = *editing;
let sources = sources_shown(facts);
let show_assign = assignment_shown(facts);
@@ -801,7 +805,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
});
if f.charge_limit.is_some() {
- col.widget(&mut *dd_limit, menu_h);
+ col.widget_h(ctx, dd_limit, menu_h);
}
if f.battery_present {
@@ -835,7 +839,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
builder.add_section(&mut final_pc, "Power Mode", focused(1), |sec| {
let mut form = sec.form();
let mut col = form.column();
- col.widget(&mut *dd_mode, menu_h);
+ col.widget_h(ctx, dd_mode, menu_h);
col.block(|b| {
if running {
b.text("Running now — picks apply immediately.", 11.0, GOOD);
@@ -846,9 +850,9 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
}
b.text("Not set leaves a lever alone.", 11.0, TEXT_DIM);
});
- for (row, dd) in levers.rows.iter().zip(levers.dds.iter_mut()) {
+ for (row, &dd) in levers.rows.iter().zip(levers.dds.iter()) {
if !row.is_empty() {
- col.widget(dd, menu_h);
+ col.widget_h(ctx, dd, menu_h);
}
}
sec.place(form, ctx);
@@ -866,9 +870,9 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
b.text(format!("{} right now.", f.source.label()), 11.0, GOOD);
});
let wanted: Vec<usize> = sources.iter().map(|s| source_index(*s)).collect();
- for (i, dd) in dd_assign.iter_mut().enumerate() {
+ for (i, &dd) in dd_assign.iter().enumerate() {
if wanted.contains(&i) {
- col.widget(dd, menu_h);
+ col.widget_h(ctx, dd, menu_h);
}
}
sec.place(form, ctx);
@@ -878,7 +882,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
final_pc
}
-pub fn update(state: &mut PowerState, msg: PowerMessage) {
+pub fn update(state: &mut PowerState, msg: PowerMessage, ctx: &mut UiContext) {
match msg {
PowerMessage::Refreshed(facts) => {
// The page opens on the mode the machine is actually running, so
@@ -891,7 +895,7 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
state.loaded = true;
if state.facts != facts {
state.facts = facts;
- rebuild_options(state);
+ rebuild_options(state, ctx);
}
}
PowerMessage::SetLimit(idx) => {
@@ -922,14 +926,14 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
if limit.start.is_some() {
state.facts.charge_start = limit.start;
}
- rebuild_options(state);
+ rebuild_options(state, ctx);
}
PowerMessage::EditMode(idx) => {
// Page-local: switching which mode is on screen writes nothing
// and applies nothing, so it needs no privileged call.
if let Some(mode) = Mode::ALL.get(idx).copied() {
state.editing = mode;
- rebuild_options(state);
+ rebuild_options(state, ctx);
}
}
PowerMessage::Set { lever, idx } => {
@@ -967,7 +971,7 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
set_live(lever, v, &mut state.facts);
}
}
- rebuild_options(state);
+ rebuild_options(state, ctx);
}
PowerMessage::Assign { source, idx } => {
let Some(mode) = Mode::ALL.get(idx).copied() else {
@@ -994,7 +998,7 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
set_live(lever, &value, &mut state.facts);
}
}
- rebuild_options(state);
+ rebuild_options(state, ctx);
}
}
}
@@ -1044,27 +1048,27 @@ impl crate::pages::AppPage for PowerState {
view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
- if self.dd_limit.take_change() {
- actions.push(AppAction::Power(PowerMessage::SetLimit(self.dd_limit.selected)));
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
+ if ctx[self.dd_limit].take_change() {
+ actions.push(AppAction::Power(PowerMessage::SetLimit(ctx[self.dd_limit].selected)));
}
- if self.dd_mode.take_change() {
- actions.push(AppAction::Power(PowerMessage::EditMode(self.dd_mode.selected)));
+ if ctx[self.dd_mode].take_change() {
+ actions.push(AppAction::Power(PowerMessage::EditMode(ctx[self.dd_mode].selected)));
}
for (i, lever) in Lever::ALL.iter().enumerate() {
- if self.levers.dds[i].take_change() {
+ if ctx[self.levers.dds[i]].take_change() {
actions.push(AppAction::Power(PowerMessage::Set {
lever: *lever,
- idx: self.levers.dds[i].selected,
+ idx: ctx[self.levers.dds[i]].selected,
}));
}
}
for source in Source::ALL {
let i = source_index(source);
- if self.dd_assign[i].take_change() {
+ if ctx[self.dd_assign[i]].take_change() {
actions.push(AppAction::Power(PowerMessage::Assign {
source,
- idx: self.dd_assign[i].selected,
+ idx: ctx[self.dd_assign[i]].selected,
}));
}
}
@@ -1120,12 +1124,12 @@ mod tests {
/// Loaded, editing whichever mode the machine is running — which is what
/// the page opens on.
- fn loaded() -> PowerState {
- let mut st = PowerState::default();
+ fn loaded(ui: &mut UiContext) -> PowerState {
+ let mut st = PowerState::new(ui);
st.loaded = true;
st.facts = facts();
st.editing = st.facts.plan.assigned(st.facts.source);
- rebuild_options(&mut st);
+ rebuild_options(&mut st, ui);
st
}
@@ -1137,59 +1141,63 @@ mod tests {
#[test]
fn the_lever_section_shows_the_edited_mode_behind_not_set() {
- let st = loaded();
+ let mut ui = cce_ui::context::UiContext::new();
+ let st = loaded(&mut ui);
// On battery, so Power Saver is running; its plan says low-power.
assert_eq!(st.editing, Mode::PowerSaver);
- assert_eq!(st.dd_mode.options, ["Performance", "Balanced", "Power Saver"]);
- assert_eq!(st.dd_mode.selected, 2);
+ assert_eq!(ui[st.dd_mode].options, ["Performance", "Balanced", "Power Saver"]);
+ assert_eq!(ui[st.dd_mode].selected, 2);
assert_eq!(st.levers.rows[PROFILE], ["", "low-power", "balanced", "performance"]);
- assert_eq!(st.levers.dds[PROFILE].selected, 1);
+ assert_eq!(ui[st.levers.dds[PROFILE]].selected, 1);
// Nothing planned for turbo in this mode → Not set.
- assert_eq!(st.levers.dds[TURBO].selected, 0);
+ assert_eq!(ui[st.levers.dds[TURBO]].selected, 0);
assert_eq!(st.levers.rows[TURBO], ["", "on", "off"]);
}
#[test]
fn the_running_mode_reports_the_live_value_on_its_not_set_row() {
- let mut st = loaded();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
// Power Saver is running: sysfs says balanced and turbo on, and the
// Not set row is where the page says so.
- assert_eq!(st.levers.dds[PROFILE].options[0], "Not set — now Balanced");
- assert_eq!(st.levers.dds[TURBO].options[0], "Not set — now Enabled");
+ assert_eq!(ui[st.levers.dds[PROFILE]].options[0], "Not set — now Balanced");
+ assert_eq!(ui[st.levers.dds[TURBO]].options[0], "Not set — now Enabled");
// A mode that is not running has no live value to report.
st.editing = Mode::Balanced;
- rebuild_options(&mut st);
- assert_eq!(st.levers.dds[PROFILE].options[0], "Not set");
- assert_eq!(st.levers.dds[PROFILE].selected, 0);
+ rebuild_options(&mut st, &mut ui);
+ assert_eq!(ui[st.levers.dds[PROFILE]].options[0], "Not set");
+ assert_eq!(ui[st.levers.dds[PROFILE]].selected, 0);
}
#[test]
fn the_page_opens_on_the_mode_the_machine_is_running() {
- let mut st = PowerState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = PowerState::new(&mut ui);
// Default state edits Balanced; the first read is on battery, which
// runs Power Saver.
assert_eq!(st.editing, Mode::Balanced);
- update(&mut st, PowerMessage::Refreshed(facts()));
+ update(&mut st, PowerMessage::Refreshed(facts()), &mut ui);
assert_eq!(st.editing, Mode::PowerSaver);
- assert_eq!(st.dd_mode.selected, mode_index(Mode::PowerSaver));
+ assert_eq!(ui[st.dd_mode].selected, mode_index(Mode::PowerSaver));
// A later read does not yank the section away from the user's pick.
- update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)));
+ update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)), &mut ui);
let mut plugged = facts();
plugged.source = Source::Ac;
- update(&mut st, PowerMessage::Refreshed(plugged));
+ update(&mut st, PowerMessage::Refreshed(plugged), &mut ui);
assert_eq!(st.editing, Mode::Performance);
}
#[test]
fn switching_the_edited_mode_swaps_the_lever_values() {
- let mut st = loaded();
- assert_eq!(st.levers.dds[PROFILE].selected, 1); // low-power
- update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)));
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
+ assert_eq!(ui[st.levers.dds[PROFILE]].selected, 1); // low-power
+ update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)), &mut ui);
assert_eq!(st.editing, Mode::Performance);
- assert_eq!(st.dd_mode.selected, 0);
- assert_eq!(st.levers.dds[PROFILE].selected, 3); // performance
- update(&mut st, PowerMessage::EditMode(mode_index(Mode::Balanced)));
- assert_eq!(st.levers.dds[PROFILE].selected, 0); // nothing planned
+ assert_eq!(ui[st.dd_mode].selected, 0);
+ assert_eq!(ui[st.levers.dds[PROFILE]].selected, 3); // performance
+ update(&mut st, PowerMessage::EditMode(mode_index(Mode::Balanced)), &mut ui);
+ assert_eq!(ui[st.levers.dds[PROFILE]].selected, 0); // nothing planned
// Editing is page state: it changes no assignment and no plan.
assert_eq!(st.facts.plan.assigned(Source::Battery), Mode::PowerSaver);
assert_eq!(st.facts.plan.get(Mode::Balanced, Lever::Profile), None);
@@ -1197,79 +1205,81 @@ mod tests {
#[test]
fn assignment_dropdowns_follow_the_plan_and_pick_a_mode_per_state() {
- let mut st = loaded();
- assert_eq!(st.dd_assign[source_index(Source::Ac)].selected, mode_index(Mode::Performance));
- assert_eq!(st.dd_assign[source_index(Source::Battery)].selected, mode_index(Mode::PowerSaver));
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
+ assert_eq!(ui[st.dd_assign[source_index(Source::Ac)]].selected, mode_index(Mode::Performance));
+ assert_eq!(ui[st.dd_assign[source_index(Source::Battery)]].selected, mode_index(Mode::PowerSaver));
// Reassigning the live state hands the machine to that mode, and the
// lever section — still editing Power Saver — stops claiming to run.
update(
&mut st,
- PowerMessage::Assign { source: Source::Battery, idx: mode_index(Mode::Balanced) },
- );
+ PowerMessage::Assign { source: Source::Battery, idx: mode_index(Mode::Balanced) },&mut ui);
assert_eq!(st.facts.plan.assigned(Source::Battery), Mode::Balanced);
- assert_eq!(st.dd_assign[source_index(Source::Battery)].selected, mode_index(Mode::Balanced));
+ assert_eq!(ui[st.dd_assign[source_index(Source::Battery)]].selected, mode_index(Mode::Balanced));
assert_eq!(st.editing, Mode::PowerSaver);
- assert_eq!(st.levers.dds[PROFILE].options[0], "Not set");
+ assert_eq!(ui[st.levers.dds[PROFILE]].options[0], "Not set");
// Both states may run the same mode.
update(
&mut st,
- PowerMessage::Assign { source: Source::Ac, idx: mode_index(Mode::Balanced) },
- );
+ PowerMessage::Assign { source: Source::Ac, idx: mode_index(Mode::Balanced) },&mut ui);
assert_eq!(st.facts.plan.assigned(Source::Ac), Mode::Balanced);
}
#[test]
fn charge_limit_rows_are_windows_and_map_current_and_off_list_values() {
+ let mut ui = cce_ui::context::UiContext::new();
let w = |start, end| ChargeLimit { start: Some(start), end: Some(end) };
- let mut st = loaded();
+ let mut st = loaded(&mut ui);
// Nothing planned: the battery's own 75/80 is the longevity preset.
assert_eq!(st.limit_values, [w(0, 100), w(75, 80), w(55, 60)]);
- assert_eq!(st.dd_limit.selected, 1);
+ assert_eq!(ui[st.dd_limit].selected, 1);
// An off-list window gets its own row instead of a wrong match.
st.facts.charge_limit = Some(70);
st.facts.charge_start = Some(65);
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
assert_eq!(st.limit_values[3], w(65, 70));
- assert_eq!(st.dd_limit.selected, 3);
- assert_eq!(st.dd_limit.options[3], "70% — current, from 65%");
+ assert_eq!(ui[st.dd_limit].selected, 3);
+ assert_eq!(ui[st.dd_limit].options[3], "70% — current, from 65%");
// Once planned, the plan is what the row shows — the firmware
// having forgotten it (0/100) is what the helper puts right.
st.facts.plan.set_charge_limit(w(55, 60)).unwrap();
st.facts.charge_limit = Some(100);
st.facts.charge_start = Some(0);
- rebuild_options(&mut st);
- assert_eq!(st.dd_limit.selected, 2);
+ rebuild_options(&mut st, &mut ui);
+ assert_eq!(ui[st.dd_limit].selected, 2);
// A battery with no start threshold is offered the ends alone.
st.facts.plan = PowerPlan::default();
st.facts.charge_start = None;
st.facts.charge_limit = Some(80);
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
assert_eq!(st.limit_values[1], ChargeLimit { start: None, end: Some(80) });
- assert_eq!(st.dd_limit.selected, 1);
+ assert_eq!(ui[st.dd_limit].selected, 1);
}
#[test]
fn a_charge_limit_pick_is_recorded_in_the_plan() {
- let mut st = loaded();
- update(&mut st, PowerMessage::SetLimit(0));
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
+ update(&mut st, PowerMessage::SetLimit(0), &mut ui);
assert_eq!(st.facts.plan.charge_limit(), ChargeLimit { start: Some(0), end: Some(100) });
assert_eq!((st.facts.charge_start, st.facts.charge_limit), (Some(0), Some(100)));
- assert_eq!(st.dd_limit.selected, 0);
+ assert_eq!(ui[st.dd_limit].selected, 0);
// No mode gained a lever from it.
assert!(Mode::ALL.iter().all(|m| st.facts.plan.levers(*m).all(|(l, _)| l == Lever::Profile)));
}
#[test]
fn open_dropdown_is_left_alone_on_refresh() {
- let mut st = loaded();
- st.levers.dds[PROFILE].open = true;
- st.levers.dds[PROFILE].selected = 2;
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
+ ui[st.levers.dds[PROFILE]].open = true;
+ ui[st.levers.dds[PROFILE]].selected = 2;
let mut newer = facts();
newer.profile = "low-power".to_string();
st.facts = newer;
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
// Open menu untouched; the others refreshed.
- assert_eq!(st.levers.dds[PROFILE].selected, 2);
+ assert_eq!(ui[st.levers.dds[PROFILE]].selected, 2);
}
#[test]
@@ -1310,13 +1320,14 @@ mod tests {
#[test]
fn section_widgets_mirror_presence_gates() {
- let mut st = PowerState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = PowerState::new(&mut ui);
// Not loaded: one section, nothing reported (the view paints only the
// loading line).
assert_eq!(st.section_widgets(), vec![Vec::new()]);
st.loaded = true;
st.facts = facts();
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
let counts = |st: &mut PowerState| st.section_widgets().iter().map(Vec::len).collect::<Vec<_>>();
// Every interface present: the charge limit, the mode picker plus all
// eleven levers, and one assignment per adapter state.
@@ -1324,14 +1335,14 @@ mod tests {
// A host without a charge-limit knob or turbo file reports fewer.
st.facts.charge_limit = None;
st.facts.turbo = None;
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
assert_eq!(counts(&mut st), [0, 11, 2]);
// No cpufreq governors and no NVIDIA driver: both drop out too.
st.facts.governors.clear();
st.facts.gpu_limit_w = None;
st.facts.gpu_default_w = None;
st.facts.gpu_min_w = None;
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
assert_eq!(counts(&mut st), [0, 9, 2]);
// No Intel render clocks, an ASPM-less kernel and no snd_hda_intel is
// down to profile, epp, animations and the two idle timeouts — which
@@ -1340,120 +1351,125 @@ mod tests {
st.facts.igpu_min_mhz = None;
st.facts.aspm_policies.clear();
st.facts.hda_idle_secs = None;
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
assert_eq!(counts(&mut st), [0, 6, 2]);
// A desktop: one adapter state, so there is nothing to assign.
st.facts.battery_present = false;
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
assert_eq!(counts(&mut st), [0, 6]);
}
#[test]
fn igpu_rows_come_from_the_hardware_range() {
- let mut st = loaded();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
// RP0, the rounded midpoint, RPn — no invented numbers, behind the
// Not set row.
assert_eq!(st.levers.rows[IGPU], ["", "1500", "800", "100"]);
- assert_eq!(st.levers.dds[IGPU].selected, 0);
+ assert_eq!(ui[st.levers.dds[IGPU]].selected, 0);
// A planned cap that is none of the three earns its own row.
st.facts.plan.put(Mode::PowerSaver, Lever::IgpuMaxMhz, Some("1300")).unwrap();
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
assert_eq!(st.levers.rows[IGPU], ["", "1500", "800", "100", "1300"]);
- assert_eq!(st.levers.dds[IGPU].selected, 4);
- assert_eq!(st.levers.dds[IGPU].options[4], "1300 MHz");
+ assert_eq!(ui[st.levers.dds[IGPU]].selected, 4);
+ assert_eq!(ui[st.levers.dds[IGPU]].options[4], "1300 MHz");
// And a live cap off the list still shows on the Not set row.
st.facts.igpu_mhz = Some(1200);
- rebuild_options(&mut st);
- assert_eq!(st.levers.dds[IGPU].options[0], "Not set — now 1200 MHz");
+ rebuild_options(&mut st, &mut ui);
+ assert_eq!(ui[st.levers.dds[IGPU]].options[0], "Not set — now 1200 MHz");
}
#[test]
fn audio_rows_are_the_three_timeouts_behind_not_set() {
- let mut st = loaded();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
assert_eq!(st.levers.rows[AUDIO], ["", "0", "1", "10"]);
- assert_eq!(st.levers.dds[AUDIO].options[1], "Never suspend");
- assert_eq!(st.levers.dds[AUDIO].selected, 0);
+ assert_eq!(ui[st.levers.dds[AUDIO]].options[1], "Never suspend");
+ assert_eq!(ui[st.levers.dds[AUDIO]].selected, 0);
st.facts.plan.put(Mode::PowerSaver, Lever::AudioIdleSecs, Some("30")).unwrap();
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
assert_eq!(st.levers.rows[AUDIO], ["", "0", "1", "10", "30"]);
- assert_eq!(st.levers.dds[AUDIO].options[4], "After 30 s idle");
+ assert_eq!(ui[st.levers.dds[AUDIO]].options[4], "After 30 s idle");
}
#[test]
fn aspm_rows_come_from_the_kernels_own_list() {
- let mut st = loaded();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
let i = lever_index(Lever::Aspm);
st.facts.plan.put(Mode::PowerSaver, Lever::Aspm, Some("powersave")).unwrap();
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
// fetch strips the brackets; the selection lands behind Not set.
assert_eq!(st.levers.rows[i], ["", "default", "performance", "powersave"]);
- assert_eq!(st.levers.dds[i].selected, 3);
- assert_eq!(st.levers.dds[i].options[3], "Powersave");
+ assert_eq!(ui[st.levers.dds[i]].selected, 3);
+ assert_eq!(ui[st.levers.dds[i]].options[3], "Powersave");
}
#[test]
fn gpu_rows_are_default_and_min_with_an_off_list_live_value() {
- let mut st = loaded();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
let i = lever_index(Lever::GpuLimitW);
// Current == default: two rows, no duplicate.
assert_eq!(st.levers.rows[i], ["", "80", "5"]);
- assert_eq!(st.levers.dds[i].selected, 0);
+ assert_eq!(ui[st.levers.dds[i]].selected, 0);
// A live limit that is neither default nor minimum is reported on the
// Not set row rather than silently selecting the wrong one.
st.facts.gpu_limit_w = Some(60);
- rebuild_options(&mut st);
+ rebuild_options(&mut st, &mut ui);
assert_eq!(st.levers.rows[i], ["", "80", "5"]);
- assert_eq!(st.levers.dds[i].selected, 0);
- assert_eq!(st.levers.dds[i].options[0], "Not set — now 60 W");
+ assert_eq!(ui[st.levers.dds[i]].selected, 0);
+ assert_eq!(ui[st.levers.dds[i]].options[0], "Not set — now 60 W");
}
#[test]
fn the_three_sections_paint_and_the_assignment_one_drops_on_a_desktop() {
- let mut ctx = cce_ui::context::UiContext::new();
- let mut paint = |st: &mut PowerState, sections: usize| {
+ let mut ui = cce_ui::context::UiContext::new();
+ let paint = |st: &mut PowerState, sections: usize, ui: &mut UiContext| {
let mut layout = cce_ui::layout::PageFlow::new();
let sec_focused = vec![false; sections];
- let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+ let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, ui);
assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
};
- let mut st = loaded();
- paint(&mut st, 3);
+ let mut st = loaded(&mut ui);
+ paint(&mut st, 3, &mut ui);
// A desktop has one adapter state and so nothing to assign.
st.facts.battery_present = false;
- rebuild_options(&mut st);
- paint(&mut st, 2);
+ rebuild_options(&mut st, &mut ui);
+ paint(&mut st, 2, &mut ui);
// And the loading gate paints its one line.
- let mut empty = PowerState::default();
- paint(&mut empty, 1);
+ let mut empty = PowerState::new(&mut ui);
+ paint(&mut empty, 1, &mut ui);
}
#[test]
fn a_stale_root_helper_is_named_on_the_page() {
- let mut ctx = cce_ui::context::UiContext::new();
- let mut lines = |st: &mut PowerState| -> Vec<String> {
+ let mut ui = cce_ui::context::UiContext::new();
+ let lines = |st: &mut PowerState, ui: &mut UiContext| -> Vec<String> {
let mut layout = cce_ui::layout::PageFlow::new();
let sec_focused = vec![false; 3];
- let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+ let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, ui);
pc.texts.iter().map(|t| t.0.clone()).collect()
};
- let mut st = loaded();
- assert!(lines(&mut st).iter().any(|l| l.contains("Switches automatically")));
+ let mut st = loaded(&mut ui);
+ assert!(lines(&mut st, &mut ui).iter().any(|l| l.contains("Switches automatically")));
// A helper too old to read a plan with modes applies nothing on plug
// or unplug, and the page says so rather than claiming it switches.
st.facts.automation = Automation::Stale;
- let out = lines(&mut st);
+ let out = lines(&mut st, &mut ui);
assert!(out.iter().any(|l| l.contains("out of date")), "{out:?}");
assert!(out.iter().any(|l| l.contains("ccebuild install-system")), "{out:?}");
st.facts.automation = Automation::Missing;
- assert!(lines(&mut st).iter().any(|l| l.contains("not installed")));
+ assert!(lines(&mut st, &mut ui).iter().any(|l| l.contains("not installed")));
}
#[test]
fn animations_is_offered_everywhere_and_reports_the_live_state() {
- let mut st = loaded();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut st = loaded(&mut ui);
assert_eq!(st.levers.rows[ANIMATIONS], ["", "on", "off"]);
- assert_eq!(st.levers.dds[ANIMATIONS].options, ["Not set — now Enabled", "Enabled", "Disabled"]);
- assert_eq!(st.levers.dds[ANIMATIONS].selected, 0);
+ assert_eq!(ui[st.levers.dds[ANIMATIONS]].options, ["Not set — now Enabled", "Enabled", "Disabled"]);
+ assert_eq!(ui[st.levers.dds[ANIMATIONS]].selected, 0);
// A host with none of the hardware levers still has animations.
st.facts = PowerFacts {
plan: st.facts.plan.clone(),
@@ -1463,12 +1479,12 @@ mod tests {
idle_sleep_secs: None,
..PowerFacts::default()
};
- rebuild_options(&mut st);
- assert_eq!(st.levers.dds[ANIMATIONS].options[0], "Not set — now Disabled");
+ rebuild_options(&mut st, &mut ui);
+ assert_eq!(ui[st.levers.dds[ANIMATIONS]].options[0], "Not set — now Disabled");
// A planned value is selected like any other lever's.
st.facts.plan.put(Mode::PowerSaver, Lever::Animations, Some("off")).unwrap();
- rebuild_options(&mut st);
- assert_eq!(st.levers.dds[ANIMATIONS].selected, 2);
+ rebuild_options(&mut st, &mut ui);
+ assert_eq!(ui[st.levers.dds[ANIMATIONS]].selected, 2);
}
#[test]
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index c06a341..f7c3821 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -500,7 +500,7 @@ impl crate::pages::AppPage for ProcessesState {
view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
+ fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut cce_ui::context::UiContext) {}
fn handle_pointer_move(
&mut self,
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 22fcfe7..8fd1f9d 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -1,7 +1,8 @@
use crate::app::{form_button, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{lay_row, render_widget, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, render_widget_h, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
use cce_ui::scene::layout::Rect;
use cce_ui::widget::{TextBox, InteractiveListItem};
@@ -31,9 +32,9 @@ pub struct ServicesState {
pub loaded: bool,
pub services: Vec<ServiceInfo>,
pub active_tab: ServiceTab,
- pub search_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+ pub search_box: Handle<cce_ui::widget::Adapted<TextBox>>,
pub list: ScrollRegion,
- pub items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
+ pub items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
}
impl Default for ServicesState {
@@ -42,13 +43,23 @@ impl Default for ServicesState {
loaded: false,
services: Vec::new(),
active_tab: ServiceTab::System,
- search_box: Owned::new(TextBox::new(String::new()).with_label("Filter Services")),
+ search_box: Handle::none(),
list: ScrollRegion::new(36.0, 6.0).with_frame(false).with_sink_behind(true),
items: Vec::new(),
}
}
}
+impl ServicesState {
+ /// The page's state, its widgets inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
+ Self {
+ search_box: ctx.insert(TextBox::new(String::new()).with_label("Filter Services")),
+ ..Self::default()
+ }
+ }
+}
+
#[derive(Debug, Clone)]
pub enum ServicesMessage {
Refreshed(Vec<ServiceInfo>),
@@ -85,25 +96,27 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
let (label1, label2) = if sec_w < 250.0 { ("System", "User") } else { ("System Services", "User Services") };
// Filter services
- let query = if state.search_box.editing {
- state.search_box.edit_buffer.to_lowercase()
+ let query = if ctx[state.search_box].editing {
+ ctx[state.search_box].edit_buffer.to_lowercase()
} else {
- state.search_box.text.to_lowercase()
+ ctx[state.search_box].text.to_lowercase()
};
let filtered_services: Vec<&ServiceInfo> = state.services.iter()
.filter(|s| s.is_system == (state.active_tab == ServiceTab::System))
.filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
.collect();
if state.items.len() != filtered_services.len() {
- state.items.clear();
+ for h in state.items.drain(..) {
+ ctx.remove(h);
+ }
for _ in 0..filtered_services.len() {
- state.items.push(Owned::new(InteractiveListItem::new("")));
+ state.items.push(ctx.insert(InteractiveListItem::new("")));
}
}
let active_tab = state.active_tab;
let list = &mut state.list;
- let items = &mut state.items;
+ let items = &state.items;
let mut col = form.column();
col.row(|r| {
form_button(r, label1, 0.0, tab_colors(active_tab == ServiceTab::System),
@@ -111,7 +124,7 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
form_button(r, label2, 0.0, tab_colors(active_tab == ServiceTab::User),
AppAction::Services(ServicesMessage::SetTab(ServiceTab::User)));
});
- col.widget(&mut state.search_box, cce_ui::layout::textbox_height());
+ col.widget_h(ctx, state.search_box, cce_ui::layout::textbox_height());
col.fill(LIST_MIN_H, move |pc, rect, ctx| {
let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
// Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
@@ -172,10 +185,11 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
// Render InteractiveListItem background and text labels
// Rows dispatch as extra roots (the dissolved list is no parent).
- let item_btn = &mut items[idx];
+ let item = items[idx];
+ let item_btn = &mut ctx[item];
item_btn.title = service.name.clone();
item_btn.subtitle = Some(desc_truncated);
- render_widget(pc, item_btn, item_x, draw_y, item_w, item_h, ctx);
+ render_widget_h(pc, item, item_x, draw_y, item_w, item_h, ctx);
let active_txt = [0.90, 0.90, 0.95, 1.0];
// No per-action tints: both controls wear the DE's themed
@@ -301,17 +315,21 @@ pub fn transport_running(active_state: &str, sub_state: &str) -> bool {
}
}
-pub fn update(state: &mut ServicesState, msg: ServicesMessage) {
+pub fn update(state: &mut ServicesState, msg: ServicesMessage, ctx: &mut UiContext) {
match msg {
ServicesMessage::Refreshed(new_services) => {
state.loaded = true;
state.services = new_services;
- state.items.clear();
+ for h in state.items.drain(..) {
+ ctx.remove(h);
+ }
}
ServicesMessage::SetTab(tab) => {
state.active_tab = tab;
state.list.set_scroll_y(0.0);
- state.items.clear();
+ for h in state.items.drain(..) {
+ ctx.remove(h);
+ }
}
ServicesMessage::Start(name, is_system) => {
if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
@@ -439,7 +457,7 @@ impl crate::pages::AppPage for ServicesState {
view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
+ fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut UiContext) {}
// Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
// on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
@@ -454,15 +472,6 @@ impl crate::pages::AppPage for ServicesState {
.collect()
}
- fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
- let (list, items) = (&self.list, &mut self.items);
- for (idx, i) in items.iter_mut().enumerate() {
- if list.get_item_draw_y(idx, 4.0).is_none() {
- continue;
- }
- ctx.register_host(i);
- }
- }
fn handle_pointer_move(
&mut self,
diff --git a/src/pages/storage.rs b/src/pages/storage.rs
index 3f1c147..d4c582b 100644
--- a/src/pages/storage.rs
+++ b/src/pages/storage.rs
@@ -1,5 +1,6 @@
use crate::app::{AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::layout::{PageLayoutBuilder, PageFlow};
use std::fs;
@@ -35,7 +36,7 @@ pub struct StorageState {
pub error_message: Option<String>,
/// Retained so it can hold keyboard focus: ctrl+i descends into the Full
/// System Backup section and lands here, and Enter/Space runs the backup.
- pub backup_button: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+ pub backup_button: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
}
impl Default for StorageState {
@@ -51,16 +52,28 @@ impl Default for StorageState {
last_backup_time: "Never".to_string(),
backup_size: "0 B".to_string(),
error_message: None,
- backup_button: Owned::new(cce_ui::widget::Button::new(0.0, 0.0, 0.0, 32.0)
- // Flat fill + border, not the SDF bevel: PageContent's RenderTarget
- // has no `bevel`, so a raised plate silently draws nothing here
- // (the label renders, the plate does not). The border path is also
- // what carries the keyboard focus ring.
- .with_raised(false)
- .with_label("Run Backup")
- .with_bg(BTN_BG)
- .with_hover_bg(BTN_HOVER)
- .with_label_color(WHITE)),
+ backup_button: Handle::none(),
+ }
+ }
+}
+
+impl StorageState {
+ /// The page's state, its widgets inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
+ Self {
+ backup_button: ctx.insert(
+ cce_ui::widget::Button::new(0.0, 0.0, 0.0, 32.0)
+ // Flat fill + border, not the SDF bevel: PageContent's RenderTarget
+ // has no `bevel`, so a raised plate silently draws nothing here
+ // (the label renders, the plate does not). The border path is also
+ // what carries the keyboard focus ring.
+ .with_raised(false)
+ .with_label("Run Backup")
+ .with_bg(BTN_BG)
+ .with_hover_bg(BTN_HOVER)
+ .with_label_color(WHITE),
+ ),
+ ..Self::default()
}
}
}
@@ -234,7 +247,7 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
// Section 1: Local Storage
builder.add_section(&mut final_pc, "Local Storage", sec_focused.first().copied().unwrap_or(false), |sec| {
let disk_pct = if state.disk_total > 0.0 { state.disk_used / state.disk_total * 100.0 } else { 0.0 };
- let mut disk_bar = Owned::new(
+ let disk_bar = ctx.insert(
cce_ui::widget::UsageBar::new((disk_pct as f32 / 100.0).min(1.0))
.with_colors([0.36, 0.60, 0.36, 1.0], [0.15, 0.15, 0.25, 1.0]),
);
@@ -245,15 +258,17 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
} else {
let usage = format!("{:.0} / {:.0} GiB ({:.0}%)", state.disk_used, state.disk_total, disk_pct);
crate::app::form_pairs(&mut col, 12.0, vec![("Disk".into(), LABEL_FG, usage, TEXT_FG)]);
- col.widget(&mut disk_bar, USAGE_BAR_H);
+ col.widget_h(ctx, disk_bar, USAGE_BAR_H);
}
sec.place(form, ctx);
+ // Drawn this frame only: out of the context once it is placed.
+ ctx.remove(disk_bar);
});
// Section 2: Memory
builder.add_section(&mut final_pc, "Memory", sec_focused.get(1).copied().unwrap_or(false), |sec| {
let ram_pct = if state.ram_total > 0.0 { state.ram_used / state.ram_total * 100.0 } else { 0.0 };
- let mut ram_bar = Owned::new(
+ let ram_bar = ctx.insert(
cce_ui::widget::UsageBar::new((ram_pct as f32 / 100.0).min(1.0))
.with_colors([0.50, 0.50, 0.65, 1.0], [0.15, 0.15, 0.25, 1.0]),
);
@@ -264,9 +279,11 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
} else {
let usage = format!("{:.1} / {:.1} GiB ({:.0}%)", state.ram_used, state.ram_total, ram_pct);
crate::app::form_pairs(&mut col, 12.0, vec![("RAM".into(), LABEL_FG, usage, TEXT_FG)]);
- col.widget(&mut ram_bar, USAGE_BAR_H);
+ col.widget_h(ctx, ram_bar, USAGE_BAR_H);
}
sec.place(form, ctx);
+ // Drawn this frame only: out of the context once it is placed.
+ ctx.remove(ram_bar);
});
// Section 3: Full System Backup
@@ -279,9 +296,9 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
} else {
("Run Backup", BTN_BG, BTN_HOVER)
};
- state.backup_button.set_label(btn_label);
- state.backup_button.bg = Some(bg);
- state.backup_button.hover_bg = Some(hover);
+ ctx[state.backup_button].set_label(btn_label);
+ ctx[state.backup_button].bg = Some(bg);
+ ctx[state.backup_button].hover_bg = Some(hover);
let mut form = sec.form();
let mut col = form.column();
@@ -301,7 +318,7 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
pairs.push(("Error:".into(), RED, err.clone(), RED));
}
crate::app::form_pairs(&mut col, 12.0, pairs);
- col.widget(&mut state.backup_button, cce_ui::layout::button_height());
+ col.widget_h(ctx, state.backup_button, cce_ui::layout::button_height());
}
sec.place(form, ctx);
});
@@ -309,7 +326,7 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
final_pc
}
-pub fn update(state: &mut StorageState, msg: StorageMessage) {
+pub fn update(state: &mut StorageState, msg: StorageMessage, _ctx: &mut UiContext) {
match msg {
StorageMessage::Refreshed(new) => {
// Field-wise, so the retained button and an in-flight backup survive.
@@ -373,9 +390,9 @@ impl crate::pages::AppPage for StorageState {
view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
// Mouse click and Enter/Space on the focused button both land here.
- if self.backup_button.take_click() && !self.backup_in_progress {
+ if ctx[self.backup_button].take_click() && !self.backup_in_progress {
actions.push(AppAction::Storage(StorageMessage::StartBackup));
}
}
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index e331056..f668ede 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -1,5 +1,6 @@
use crate::app::{button_need, form_button, form_button_fit, wrap_to_width, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::layout::{PageLayoutBuilder, PageFlow};
use cce_ui::widget::{Label, WidgetHostExt};
@@ -51,9 +52,9 @@ pub struct SystemState {
pub gpus: Vec<String>,
pub gpu_strings: Vec<String>,
pub builds: Vec<InstalledBuild>,
- pub cpu_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
- pub cpu_usage_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
- pub cpu_temp_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+ pub cpu_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+ pub cpu_usage_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+ pub cpu_temp_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
// Power-related fields
@@ -61,8 +62,8 @@ pub struct SystemState {
pub initialized: bool,
pub sender: Option<calloop::channel::Sender<AppAction>>,
pub sysfiles: SysFiles,
- pub hostname_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
- pub uptime_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+ pub hostname_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+ pub uptime_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
}
impl std::fmt::Debug for SystemState {
@@ -90,16 +91,30 @@ impl Default for SystemState {
gpus: Vec::new(),
gpu_strings: Vec::new(),
builds: Vec::new(),
- cpu_label: Owned::new(Label::new("CPU Info")),
- cpu_usage_label: Owned::new(Label::new("CPU Usage")),
- cpu_temp_label: Owned::new(Label::new("CPU Temp")),
+ cpu_label: Handle::none(),
+ cpu_usage_label: Handle::none(),
+ cpu_temp_label: Handle::none(),
initialized: false,
sender: None,
sysfiles: SysFiles::default(),
- hostname_label: Owned::new(Label::new("")),
- uptime_label: Owned::new(Label::new("")),
+ hostname_label: Handle::none(),
+ uptime_label: Handle::none(),
+ }
+ }
+}
+
+impl SystemState {
+ /// The page's state, its widgets inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
+ Self {
+ cpu_label: ctx.insert(Label::new("CPU Info")),
+ cpu_usage_label: ctx.insert(Label::new("CPU Usage")),
+ cpu_temp_label: ctx.insert(Label::new("CPU Temp")),
+ hostname_label: ctx.insert(Label::new("")),
+ uptime_label: ctx.insert(Label::new("")),
+ ..Self::default()
}
}
}
@@ -689,20 +704,20 @@ pub fn update(state: &mut SystemState, msg: SystemMessage, ctx: &mut cce_ui::con
state.builds = new.builds;
if state.loaded {
- state.hostname_label.set_text(&format!("{} — Linux {}", state.hostname, state.kernel));
- state.uptime_label.set_text(&format!("Uptime: {}", state.uptime));
+ ctx[state.hostname_label].set_text(&format!("{} — Linux {}", state.hostname, state.kernel));
+ ctx[state.uptime_label].set_text(&format!("Uptime: {}", state.uptime));
let cpu_label_text = format!("CPU {} ({} cores)", state.cpu_model, state.cpu_cores);
let cpu_usage_text = format!("Usage {:.0}%", state.cpu_usage);
let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp {:.0}°C", t)).unwrap_or_else(|| "Temp N/A".to_string());
- state.cpu_label.set_text(&cpu_label_text);
- state.cpu_usage_label.set_text(&cpu_usage_text);
- state.cpu_temp_label.set_text(&cpu_temp_text);
+ ctx[state.cpu_label].set_text(&cpu_label_text);
+ ctx[state.cpu_usage_label].set_text(&cpu_usage_text);
+ ctx[state.cpu_temp_label].set_text(&cpu_temp_text);
- state.hostname_label.mark_dirty(ctx);
+ ctx.lend_h(state.hostname_label, |w, ctx| w.mark_dirty(ctx));
}
// First refresh doubles as the first system-files scan, so the
@@ -809,7 +824,7 @@ impl crate::pages::AppPage for SystemState {
view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {
+ fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut UiContext) {
}
}
@@ -863,11 +878,11 @@ mod tests {
#[test]
fn test_view_layout_grid() {
- let mut state = SystemState::default();
+ let mut ui = cce_ui::context::UiContext::new();
+ let mut state = SystemState::new(&mut ui);
let mut layout = cce_ui::layout::PageFlow::new();
let sec_focused = vec![false, false, false, false, false, false, false];
- let mut ctx = cce_ui::context::UiContext::new();
- let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+ let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ui);
assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
}
}
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index 211929f..885d757 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -3,9 +3,10 @@
//! timer units (system scope through pkexec).
use crate::app::{button_need, form_button, AppAction, PageContent};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{lay_row, render_widget, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, render_widget_h, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
use cce_ui::scene::layout::Rect;
use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox};
@@ -36,13 +37,13 @@ pub struct TimersState {
pub timers: Vec<TimerInfo>,
pub active_tab: TimerTab,
pub list: ScrollRegion,
- pub items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
+ pub items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
pub creating: bool,
/// Base unit name (without .timer) being edited, form shared with create.
pub editing: Option<String>,
- pub name_box: Owned<cce_ui::widget::Adapted<TextBox>>,
- pub command_box: Owned<cce_ui::widget::Adapted<TextBox>>,
- pub schedule_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+ pub name_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+ pub command_box: Handle<cce_ui::widget::Adapted<TextBox>>,
+ pub schedule_box: Handle<cce_ui::widget::Adapted<TextBox>>,
pub status_msg: Option<String>,
}
@@ -56,13 +57,25 @@ impl Default for TimersState {
items: Vec::new(),
creating: false,
editing: None,
- name_box: Owned::new(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Name")
+ name_box: Handle::none(),
+ command_box: Handle::none(),
+ schedule_box: Handle::none(),
+ status_msg: None,
+ }
+ }
+}
+
+impl TimersState {
+ /// The page's state, its widgets inserted into `ctx`.
+ pub fn new(ctx: &mut UiContext) -> Self {
+ Self {
+ name_box: ctx.insert(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Name")
.with_placeholder("backup")),
- command_box: Owned::new(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Command")
+ command_box: ctx.insert(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Command")
.with_placeholder("/home/me/bin/backup.sh --fast")),
- schedule_box: Owned::new(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Schedule (OnCalendar)")
+ schedule_box: ctx.insert(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Schedule (OnCalendar)")
.with_placeholder("daily \u{2022} Mon 09:00 \u{2022} *-*-* 03:00:00")),
- status_msg: None,
+ ..Self::default()
}
}
}
@@ -436,15 +449,17 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
.filter(|t| t.is_system == (state.active_tab == TimerTab::System))
.collect();
if state.items.len() != filtered.len() {
- state.items.clear();
+ for h in state.items.drain(..) {
+ ctx.remove(h);
+ }
for _ in 0..filtered.len() {
- state.items.push(Owned::new(InteractiveListItem::new("")));
+ state.items.push(ctx.insert(InteractiveListItem::new("")));
}
}
let active_tab = state.active_tab;
let list = &mut state.list;
- let items = &mut state.items;
+ let items = &state.items;
let mut col = form.column();
col.row(|r| {
form_button(r, label1, 0.0, tab_colors(active_tab == TimerTab::System),
@@ -468,10 +483,10 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
};
col.text(note, 11.0, TEXT_DIM);
if state.creating {
- col.widget(&mut state.name_box, field_h);
+ col.widget_h(ctx, state.name_box, field_h);
}
- col.widget(&mut state.command_box, field_h);
- col.widget(&mut state.schedule_box, field_h);
+ col.widget_h(ctx, state.command_box, field_h);
+ col.widget_h(ctx, state.schedule_box, field_h);
let save_label = if state.editing.is_some() { "Save" } else { "Create" };
col.row(|r| {
form_button(r, save_label, button_need(save_label), ([0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0], text_on),
@@ -537,17 +552,20 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
subtitle_full
};
- let item_btn = &mut items[idx];
+ let item = items[idx];
+ let item_btn = &mut ctx[item];
item_btn.title = timer.unit.clone();
item_btn.subtitle = Some(subtitle);
// Cut at its cell, so a long schedule stops short of the buttons.
pc.push_clip_rect(item_rect.x, item_rect.y, item_rect.width, item_rect.height);
- render_widget(pc, item_btn, item_rect.x, item_rect.y, item_rect.width, item_rect.height, ctx);
+ render_widget_h(pc, item, item_rect.x, item_rect.y, item_rect.width, item_rect.height, ctx);
pc.pop_clip_rect();
let dot_state = if timer.active { DotStatus::Active } else { DotStatus::Inactive };
- let mut dot = Owned::new(StatusDot::new(dot_state));
- render_widget(pc, &mut dot, dot_rect.x, dot_rect.y, dot_rect.width, dot_rect.height, ctx);
+ // Drawn this frame only: out of the context once it is placed.
+ let dot = ctx.insert(StatusDot::new(dot_state));
+ render_widget_h(pc, dot, dot_rect.x, dot_rect.y, dot_rect.width, dot_rect.height, ctx);
+ ctx.remove(dot);
let active_txt = [0.90, 0.90, 0.95, 1.0];
@@ -644,17 +662,21 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
final_pc
}
-pub fn update(state: &mut TimersState, msg: TimersMessage) {
+pub fn update(state: &mut TimersState, msg: TimersMessage, ctx: &mut UiContext) {
match msg {
TimersMessage::Refreshed(timers) => {
state.loaded = true;
state.timers = timers;
- state.items.clear();
+ for h in state.items.drain(..) {
+ ctx.remove(h);
+ }
}
TimersMessage::SetTab(tab) => {
state.active_tab = tab;
state.list.set_scroll_y(0.0);
- state.items.clear();
+ for h in state.items.drain(..) {
+ ctx.remove(h);
+ }
}
TimersMessage::RunNow(service, is_system) => {
if !service.is_empty() {
@@ -677,7 +699,8 @@ pub fn update(state: &mut TimersState, msg: TimersMessage) {
state.creating = true;
state.editing = None;
state.status_msg = None;
- for tb in [&mut state.name_box, &mut state.command_box, &mut state.schedule_box] {
+ for tb in [state.name_box, state.command_box, state.schedule_box] {
+ let tb = &mut ctx[tb];
tb.text = String::new();
tb.edit_buffer = String::new();
}
@@ -700,20 +723,20 @@ pub fn update(state: &mut TimersState, msg: TimersMessage) {
state.creating = false;
state.editing = Some(base.clone());
state.status_msg = None;
- state.name_box.text = base.clone();
- state.name_box.edit_buffer = base;
- state.command_box.text = command.clone();
- state.command_box.edit_buffer = command;
- state.schedule_box.text = schedule.clone();
- state.schedule_box.edit_buffer = schedule;
+ ctx[state.name_box].text = base.clone();
+ ctx[state.name_box].edit_buffer = base;
+ ctx[state.command_box].text = command.clone();
+ ctx[state.command_box].edit_buffer = command;
+ ctx[state.schedule_box].text = schedule.clone();
+ ctx[state.schedule_box].edit_buffer = schedule;
}
TimersMessage::CreateSave => {
- let command = live_text(&state.command_box);
- let schedule = live_text(&state.schedule_box);
+ let command = live_text(&ctx[state.command_box]);
+ let schedule = live_text(&ctx[state.schedule_box]);
let result = if let Some(base) = state.editing.clone() {
update_user_timer(&base, &command, &schedule)
} else {
- create_user_timer(&live_text(&state.name_box), &command, &schedule)
+ create_user_timer(&live_text(&ctx[state.name_box]), &command, &schedule)
};
match result {
Ok(msg) => {
@@ -722,7 +745,9 @@ pub fn update(state: &mut TimersState, msg: TimersMessage) {
state.status_msg = Some(msg);
// Show the unit where it (re)appears on the next refresh.
state.active_tab = TimerTab::User;
- state.items.clear();
+ for h in state.items.drain(..) {
+ ctx.remove(h);
+ }
}
Err(e) => {
state.status_msg = Some(e);
@@ -766,7 +791,7 @@ impl crate::pages::AppPage for TimersState {
view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
}
- fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
+ fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut UiContext) {}
// Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
// on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
@@ -781,15 +806,6 @@ impl crate::pages::AppPage for TimersState {
.collect()
}
- fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
- let (list, items) = (&self.list, &mut self.items);
- for (idx, i) in items.iter_mut().enumerate() {
- if list.get_item_draw_y(idx, 4.0).is_none() {
- continue;
- }
- ctx.register_host(i);
- }
- }
fn handle_pointer_move(
&mut self,
diff --git a/src/renderer.rs b/src/renderer.rs
index 63807ea..28fe205 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -65,7 +65,7 @@ impl SystemInterface {
}
let page_idx = Page::ALL.iter().position(|&p| p == self.app.current_page).unwrap_or(0);
- self.page_dropdown.selected = page_idx;
+ self.ui_context[self.page_dropdown].selected = page_idx;
// root plate container DISSOLVED (Phase 6s): top-level widgets stay parentless
// (render_widget registers them); the window plate, the root aggregate's
@@ -75,7 +75,7 @@ impl SystemInterface {
// Position sidebar and switcher below the titlebar
let mut dummy_pc = PageContent::new();
let dropdown_h = cce_ui::layout::dropdown_height();
- let size = self.page_dropdown.measure(
+ let size = self.ui_context[self.page_dropdown].measure(
cce_ui::widget::LayoutConstraints::new(0.0, 500.0, dropdown_h, dropdown_h),
&self.ui_context,
);
@@ -85,8 +85,8 @@ impl SystemInterface {
let dropdown_y = logical_sh - self.status_height + dropdown_gap;
// The dropdown sits flush against the window's rounded bottom-right corner; with the
// root plate container dissolved, hand it the plate frame for its concentric-corner cut.
- self.page_dropdown.set_corner_frame(Some(((0.0, 0.0, logical_sw, logical_sh), 12.0, (true, true, true, true))));
- cce_ui::layout::render_widget(&mut dummy_pc, &mut self.page_dropdown, dropdown_x, dropdown_y, dropdown_w, dropdown_h, &mut self.ui_context);
+ self.ui_context[self.page_dropdown].set_corner_frame(Some(((0.0, 0.0, logical_sw, logical_sh), 12.0, (true, true, true, true))));
+ cce_ui::layout::render_widget_h(&mut dummy_pc, self.page_dropdown, dropdown_x, dropdown_y, dropdown_w, dropdown_h, &mut self.ui_context);
// The dropdown is laid out by this pass and painted live in display_list
// (`paint_root_into`), chevron and all.
let switcher_h = if self.search_open {
@@ -102,16 +102,16 @@ impl SystemInterface {
let sb_w = crate::scroll_bar::ScrollBar::width();
// TODO(style): the bar's 4px vertical stand-off is the toolkit
// track's own end inset, not a rung of the ladder.
- self.page_scroll_bar.set_rect(
+ self.ui_context[self.page_scroll_bar].set_rect(
lcx + (lcw - sb_w) * 0.5,
self.header_height + 4.0,
sb_w,
switcher_h - 8.0,
);
- if self.page_scroll_bar.dragging {
- self.scroll_y = self.page_scroll_bar.scroll_y;
+ if self.ui_context[self.page_scroll_bar].dragging {
+ self.scroll_y = self.ui_context[self.page_scroll_bar].scroll_y;
}
- self.page_scroll_bar.update(self.scroll_y, self.content_h, switcher_h);
+ self.ui_context[self.page_scroll_bar].update(self.scroll_y, self.content_h, switcher_h);
// The window chrome — the page dropdown and, while open, the search box — is
// painted live in display_list, each as it paints itself (`paint_root_into`).
@@ -145,9 +145,9 @@ impl SystemInterface {
let box_h = cce_ui::layout::textbox_height();
// Laid out here; painted live in display_list.
let mut layout_only = PageContent::new();
- cce_ui::layout::render_widget(
+ cce_ui::layout::render_widget_h(
&mut layout_only,
- &mut self.search_box,
+ self.search_box,
self.sidebar_width + inset,
sh - 42.0 + (42.0 - box_h) / 2.0,
sw - self.sidebar_width - 2.0 * inset,
@@ -170,7 +170,7 @@ impl SystemInterface {
- if self.search_open && !self.search_query.is_empty() && !self.page_scroll_bar.dragging {
+ if self.search_open && !self.search_query.is_empty() && !self.ui_context[self.page_scroll_bar].dragging {
let query_lower = self.search_query.to_lowercase();
let mut first_match_y = None;
for (t, _, _, y, _, _, _) in &pc.texts {
@@ -216,13 +216,13 @@ impl SystemInterface {
}
}
- if self.page_scroll_bar.dragging {
- self.scroll_y = self.page_scroll_bar.scroll_y;
+ if self.ui_context[self.page_scroll_bar].dragging {
+ self.scroll_y = self.ui_context[self.page_scroll_bar].scroll_y;
} else {
- self.page_scroll_bar.scroll_y = self.scroll_y;
+ self.ui_context[self.page_scroll_bar].scroll_y = self.scroll_y;
}
self.content_h = max_y;
- self.page_scroll_bar.update(self.scroll_y, max_y, lch);
+ self.ui_context[self.page_scroll_bar].update(self.scroll_y, max_y, lch);
let scroll_offset_y = self.scroll_y;
@@ -350,7 +350,7 @@ impl SystemInterface {
// The button's own idle and hover faces: the page's colours when
// it passed them, else the toolkit's for the button's kind — a
// list row's transparent-until-hover wash (`PageContent::list_row`).
- let mut probe = (***btn).clone();
+ let mut probe = (**btn).clone();
probe.set_hovered(false);
let bg = cce_ui::widget::Paint::color(&probe);
probe.set_hovered(true);
@@ -646,22 +646,14 @@ impl SystemInterface {
self.widgets = widgets;
self.texts = texts;
- self.page_buttons = page_buttons;
+ // The dispatch copies are the context's: last frame's go back, this frame's go in.
+ for (h, _) in std::mem::take(&mut self.page_buttons) {
+ self.ui_context.remove(h);
+ }
+ let ctx = &mut self.ui_context;
+ self.page_buttons = page_buttons.into_iter().map(|(b, a)| (ctx.insert(b), a)).collect();
self.page_icon_images = page_icon_images;
- // The id-rooted router (`propagate_event(event, WidgetId)`) resolves roots
- // through the registry, and `clear_hierarchy` above wiped it. The view pass
- // re-registers page widgets through `render_widget`; the chrome dispatch roots
- // never go through it, so re-register them here. The page buttons are
- // per-rebuild clones — registration follows the fresh allocations.
- {
- self.ui_context.register_host(&mut self.search_box);
- self.ui_context.register_host(&mut self.page_dropdown);
- self.ui_context.register_host(&mut self.page_scroll_bar);
- for (btn, _) in self.page_buttons.iter_mut() {
- self.ui_context.register_host(btn);
- }
- }
// A Tab step's focus, handed to the fresh clone at the same rect (the
// page buttons above are per-rebuild allocations; see `focus_stepped`).
if let Some((fx, fy, fw, fh)) = self.refocus_rect.take() {