From 58a1ed6fbd0ee7865431583535124d96253efe76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 31 Oct 2026 21:00:43 +0100 Subject: [PATCH 001/194] Give feed rows compact, content-aware story summaries --- Ember/controls/story_card.rs | 121 +++++++++++++++++++++++++++++++++++ Ember/reader_view.rs | 53 ++++----------- 2 files changed, 134 insertions(+), 40 deletions(-) diff --git a/Ember/controls/story_card.rs b/Ember/controls/story_card.rs index db16a7c..dff2eb4 100644 --- a/Ember/controls/story_card.rs +++ b/Ember/controls/story_card.rs @@ -1,3 +1,5 @@ +use ember_content::HtmlDocument; +use ember_core::{relative_time, HnItem, ItemKind}; use ferroui_base::input::InputElementImpl; use ferroui_base::interactivity::InteractiveImpl; use ferroui_base::layout::LayoutableImpl; @@ -5,6 +7,7 @@ use ferroui_base::{ ferro_class, ferro_class_info, ferro_impl_classes, ferro_properties, instantiate, FerroObjectImpl, FerroProperty, Ref, StyledElementImpl, StyledProperty, VisualImpl, }; +use ferroui_controls::automation::AutomationProperties; use ferroui_controls::primitives::TemplatedControlImpl; use ferroui_controls::{Button, ButtonImpl, ContentControlImpl, ControlImpl}; /// A touch and keyboard activatable story, styled through a replaceable ControlTheme. @@ -36,6 +39,31 @@ impl StoryCard { pub fn new() -> Ref { instantiate(Self::construct()) } + /// Build the summary from the fields HN actually supplied. Jobs have no + /// discussion count, and a comment search hit can share its parent story's title. + pub(crate) fn set_item(&self, item: &HnItem, now: u64, show_dead: bool) { + let summary = StorySummary::new(item, now, show_dead); + self.set_headline(Some(&summary.headline)); + self.set_domain(Some(&summary.source)); + self.set_metadata(Some(&summary.byline)); + let score = item + .score + .map(|n| format!("{n} {}", if n == 1 { "pt" } else { "pts" })); + self.set_score_label(score.as_deref()); + self.classes().set("has-score", score.is_some()); + let accessible = [ + Some(summary.headline), + Some(summary.source), + Some(summary.byline), + score, + ] + .into_iter() + .flatten() + .filter(|field| !field.is_empty()) + .collect::>() + .join(", "); + AutomationProperties::set_name(self, Some(&accessible)); + } pub fn headline(&self) -> Option { self.get_value(Self::headline_property()) } @@ -67,3 +95,96 @@ impl StoryCard { self.set_value(Self::status_label_property(), value.map(str::to_owned)); } } + +struct StorySummary { + source: String, + headline: String, + byline: String, +} + +impl StorySummary { + fn new(item: &HnItem, now: u64, show_dead: bool) -> Self { + let comment = item.kind == Some(ItemKind::Comment); + let headline = if item.deleted { + "[deleted]".to_owned() + } else if item.dead && !show_dead { + "[dead]".to_owned() + } else { + let text = if comment { + item.text.as_deref().or(item.title.as_deref()) + } else { + item.title.as_deref().or(item.text.as_deref()) + }; + let decoded = HtmlDocument::parse(text.unwrap_or_default()).plain_text(); + // A search hit or saved comment can contain several pages of prose. + // Keep the feed preview and its accessibility announcement bounded. + let mut chars = decoded.chars(); + let mut preview: String = chars.by_ref().take(240).collect(); + if chars.next().is_some() { + preview.push('…'); + } + if preview.trim().is_empty() { + if comment { "Comment" } else { "Discussion" }.to_owned() + } else { + preview + } + }; + let source = match item.kind { + Some(ItemKind::Comment) if !item.deleted && (!item.dead || show_dead) => item + .title + .as_deref() + .map(|title| format!("Comment on {}", HtmlDocument::parse(title).plain_text())) + .unwrap_or_else(|| "Comment on Hacker News".to_owned()), + Some(ItemKind::Job) => format!("Job · {}", item.domain()), + Some(ItemKind::Poll) => "Hacker News poll".to_owned(), + _ if item.url.is_none() => "Hacker News".to_owned(), + _ => item.domain(), + }; + let mut byline = Vec::new(); + if let Some(author) = item.by.as_ref().filter(|_| !item.deleted) { + byline.push(author.clone()); + } + if item.time.is_some() { + byline.push(relative_time(item.time, now)); + } + if let Some(n) = item.descendants { + byline.push(format!( + "{n} {}", + if n == 1 { "comment" } else { "comments" } + )); + } + Self { + source, + headline, + byline: byline.join(" · "), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jobs_and_comment_results_show_their_own_metadata() { + let item = HnItem { + kind: Some(ItemKind::Job), + title: Some("Engineer".into()), + ..Default::default() + }; + let summary = StorySummary::new(&item, 0, false); + assert_eq!(summary.byline, ""); + assert!(summary.source.starts_with("Job · ")); + let item = HnItem { + kind: Some(ItemKind::Comment), + title: Some("Parent & story".into()), + text: Some("

Matching reply

".into()), + by: Some("reader".into()), + ..Default::default() + }; + let summary = StorySummary::new(&item, 0, false); + assert_eq!(summary.headline, "Matching reply"); + assert_eq!(summary.source, "Comment on Parent & story"); + assert_eq!(summary.byline, "reader"); + } +} diff --git a/Ember/reader_view.rs b/Ember/reader_view.rs index 0d889d2..6a5a261 100644 --- a/Ember/reader_view.rs +++ b/Ember/reader_view.rs @@ -726,32 +726,8 @@ impl ReaderView { let card = StoryCard::new(); let state = self.state.borrow(); card.set_tag(Some(Rc::new(item.id))); - let title = ember_content::HtmlDocument::parse( - item.title - .as_deref() - .or(item.text.as_deref()) - .unwrap_or("Discussion"), - ) - .plain_text(); - card.set_headline(Some(&title)); - card.set_domain(Some(&item.domain())); - card.set_metadata(Some(&format!( - "{} · {} · {} comments", - item.author(), - ember_core::relative_time(item.time, services::now()), - item.descendants.unwrap_or(0) - ))); - card.set_score_label(Some(&format!("↑ {}", item.score.unwrap_or(0)))); + card.set_item(item, services::now(), state.preferences.show_dead); self.update_card_state(&card, item.id, &state); - AutomationProperties::set_name( - &card, - Some(&format!( - "{}; {} points; {} comments", - title, - item.score.unwrap_or(0), - item.descendants.unwrap_or(0) - )), - ); let id = item.id; let weak = self.to_ref().downgrade(); card.click(move |_, _| { @@ -763,22 +739,19 @@ impl ReaderView { card } fn update_card_state(&self, card: &StoryCard, id: u64, state: &ReaderState) { - card.set_status_label(Some(if state.is_saved(id) { - "SAVED" - } else if state.is_read(id) { - "READ" - } else { - "" + let read = state.is_read(id); + let saved = state.is_saved(id); + card.set_status_label(Some(if saved { "SAVED" } else if read { "READ" } else { "" })); + card.classes().set("read", read); + card.classes().set("saved", saved); + card.classes().set("compact", state.preferences.compact); + card.classes().set("selected", self.selected.get() == Some(id)); + AutomationProperties::set_help_text(card, Some(match (saved, read) { + (true, true) => "Saved, read. Open discussion.", + (true, false) => "Saved. Open discussion.", + (false, true) => "Read. Open discussion.", + (false, false) => "Open discussion.", })); - card.classes() - .set("selected", self.selected.get() == Some(id)); - if state.preferences.compact { - card.set_min_height(110.0); - card.set_padding(Thickness::uniform(12.0)); - } else { - card.set_min_height(142.0); - card.set_padding(Thickness::symmetric(18.0, 16.0)); - } } fn wire_card_swipe(&self, card: &StoryCard, id: u64) { let swipe = SwipeGestureRecognizer::new(); From 488b215b814c65c87711cb7541fca0b5ac28a881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 31 Oct 2026 21:01:41 +0100 Subject: [PATCH 002/194] Refine reader surfaces and preserve accent contrast --- Ember/App.xaml | 34 +++++++++++++++++--------------- Ember/theme.rs | 53 +++++++++++++++++++++++++++++++++++++------------- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/Ember/App.xaml b/Ember/App.xaml index fef6416..880d2ea 100644 --- a/Ember/App.xaml +++ b/Ember/App.xaml @@ -6,26 +6,28 @@ - - + + + - - - - - + + + + + - - - - - - - - - + + + + + + + + + + diff --git a/Ember/theme.rs b/Ember/theme.rs index 6582702..fe863b7 100644 --- a/Ember/theme.rs +++ b/Ember/theme.rs @@ -11,6 +11,24 @@ pub(crate) fn set_accent(theme: &WinUITheme, hex: &str) { return; }; let (d1, d2, d3, l1, l2, l3) = SystemAccentColors::calculate_accent_shades(accent); + // Small accent labels and selection indicators share one ink per theme. + // Use the lowest-contrast reading surface in each built-in palette. + for (key, surface, text) in [ + ( + "EmberLightAccentColor", + Color::from_rgb(232, 227, 222), + Color::from_rgb(0, 0, 0), + ), + ( + "EmberDarkAccentColor", + Color::from_rgb(63, 54, 49), + Color::from_rgb(255, 255, 255), + ), + ] { + theme + .resources() + .set(key, Some(Rc::new(readable_ink(accent, text, &[surface])))); + } let keys = [ SystemAccentColors::ACCENT_KEY, SystemAccentColors::ACCENT_DARK1_KEY, @@ -77,7 +95,7 @@ pub(crate) fn apply(preferences: &Preferences) { } } -const CUSTOM_KEYS: [&str; 9] = [ +const CUSTOM_KEYS: [&str; 10] = [ "EmberBackground", "EmberRail", "EmberSurface", @@ -87,10 +105,11 @@ const CUSTOM_KEYS: [&str; 9] = [ "EmberMuted", "EmberTint", "EmberCode", + "EmberAccentInk", ]; struct CustomPalette { dark: bool, - colors: [Color; 9], + colors: [Color; 10], } /// The base variant follows relative sRGB luminance. Derived reading surfaces @@ -108,23 +127,27 @@ fn custom_palette(background: Color, accent: Color) -> CustomPalette { let tint = safe_surface(surface, accent, 0.14, text); let border = blend(background, text, 0.22); let surfaces = [background, rail, surface, card, tint, code]; - let mut muted = text; - for step in 0..=20 { - let candidate = blend(blend(text, background, 0.35), text, step as f64 / 20.0); + let muted = readable_ink(blend(text, background, 0.35), text, &surfaces); + let accent_ink = readable_ink(accent, text, &surfaces); + CustomPalette { + dark, + colors: [ + background, rail, surface, card, border, text, muted, tint, code, accent_ink, + ], + } +} + +fn readable_ink(preferred: Color, fallback: Color, surfaces: &[Color]) -> Color { + for step in 0..=32 { + let candidate = blend(preferred, fallback, step as f64 / 32.0); if surfaces .iter() .all(|&surface| contrast(candidate, surface) >= 4.5) { - muted = candidate; - break; + return candidate; } } - CustomPalette { - dark, - colors: [ - background, rail, surface, card, border, text, muted, tint, code, - ], - } + fallback } fn safe_surface(base: Color, target: Color, amount: f64, foreground: Color) -> Color { @@ -167,6 +190,10 @@ mod tests { ] { let palette = custom_palette(Color::parse(hex).unwrap(), Color::from_rgb(255, 107, 53)); for index in [0, 1, 2, 3, 7, 8] { + assert!( + contrast(palette.colors[9], palette.colors[index]) >= 4.5, + "{hex}, accent ink on surface {index}" + ); assert!( contrast(palette.colors[5], palette.colors[index]) >= 4.5, "{hex}, surface {index}" From 9579ebdc90ed1542db3132e422e3c259267fbc00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 31 Oct 2026 21:01:44 +0100 Subject: [PATCH 003/194] Use native accessible icon commands and responsive form rows --- Ember/icons.rs | 14 ++-- Ember/widgets.rs | 181 +++++++++++++++++++++++++++++++---------------- 2 files changed, 129 insertions(+), 66 deletions(-) diff --git a/Ember/icons.rs b/Ember/icons.rs index a575678..50ad982 100644 --- a/Ember/icons.rs +++ b/Ember/icons.rs @@ -2,8 +2,9 @@ //! Source and MIT license: `Assets/Fluent/NOTICE.md` and `Assets/Fluent/LICENSE`. //! Geometry is parsed once per UI thread and shared; foreground is inherited from //! the current control theme, so custom accents and light/dark variants stay native. -use ferroui_base::media::Geometry; +use ferroui_base::media::{Geometry, RotateTransform}; use ferroui_base::Ref; +use ferroui_controls::automation::{AccessibilityView, AutomationProperties}; use ferroui_controls::PathIcon; use std::cell::RefCell; use std::collections::BTreeMap; @@ -22,8 +23,13 @@ pub(crate) fn icon(name: &str) -> Ref { .clone() }); let icon = PathIcon::new(); - icon.set_width(20.0); - icon.set_height(20.0); + icon.set_width(18.0); + icon.set_height(18.0); + icon.set_is_hit_test_visible(false); + AutomationProperties::set_accessibility_view(&icon, AccessibilityView::Raw); + if name == "chevron_down" { + icon.set_render_transform(Some(RotateTransform::with_angle(180.0).into())); + } icon.set_data(Some(geometry)); icon } @@ -39,7 +45,7 @@ fn path_data(name: &str) -> &'static str { "briefcase" => "F1 M13.75 2C14.9926 2 16 3.00736 16 4.25V6H18.75C20.5449 6 22 7.45507 22 9.25V17.75C22 19.5449 20.5449 21 18.75 21H5.25C3.45507 21 2 19.5449 2 17.75V9.25C2 7.45507 3.45507 6 5.25 6H8V4.25C8 3.00736 9.00736 2 10.25 2H13.75ZM20.5 13.4873C19.9947 13.811 19.3947 14 18.75 14H14C14 14.5523 13.5523 15 13 15H11C10.4477 15 10 14.5523 10 14H5.25C4.60533 14 4.00532 13.811 3.5 13.4873V17.75C3.5 18.7165 4.2835 19.5 5.25 19.5H18.75C19.7165 19.5 20.5 18.7165 20.5 17.75V13.4873ZM5.25 7.5C4.2835 7.5 3.5 8.2835 3.5 9.25V10.75C3.5 11.7165 4.2835 12.5 5.25 12.5H10V12C10 11.4477 10.4477 11 11 11H13C13.5523 11 14 11.4477 14 12V12.5H18.75C19.7165 12.5 20.5 11.7165 20.5 10.75V9.25C20.5 8.2835 19.7165 7.5 18.75 7.5H5.25ZM10.25 3.5C9.83579 3.5 9.5 3.83579 9.5 4.25V6H14.5V4.25C14.5 3.83579 14.1642 3.5 13.75 3.5H10.25Z", "chat" => "F1 M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C10.3817 22 8.81782 21.6146 7.41286 20.888L3.58704 21.9553C2.92212 22.141 2.23258 21.7525 2.04691 21.0876C1.98546 20.8676 1.98549 20.6349 2.04695 20.4151L3.11461 16.5922C2.38637 15.186 2 13.6203 2 12C2 6.47715 6.47715 2 12 2ZM12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 13.4696 3.87277 14.8834 4.57303 16.1375L4.72368 16.4072L3.61096 20.3914L7.59755 19.2792L7.86709 19.4295C9.12006 20.1281 10.5322 20.5 12 20.5C16.6944 20.5 20.5 16.6944 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM8.75 13H13.2483C13.6625 13 13.9983 13.3358 13.9983 13.75C13.9983 14.1297 13.7161 14.4435 13.35 14.4932L13.2483 14.5H8.75C8.33579 14.5 8 14.1642 8 13.75C8 13.3703 8.28215 13.0565 8.64823 13.0068L8.75 13H13.2483H8.75ZM8.75 9.5H15.2545C15.6687 9.5 16.0045 9.83579 16.0045 10.25C16.0045 10.6297 15.7223 10.9435 15.3563 10.9932L15.2545 11H8.75C8.33579 11 8 10.6642 8 10.25C8 9.8703 8.28215 9.55651 8.64823 9.50685L8.75 9.5H15.2545H8.75Z", "check" | "checkmark" => "F1 M4.53033 12.9697C4.23744 12.6768 3.76256 12.6768 3.46967 12.9697C3.17678 13.2626 3.17678 13.7374 3.46967 14.0303L7.96967 18.5303C8.26256 18.8232 8.73744 18.8232 9.03033 18.5303L20.0303 7.53033C20.3232 7.23744 20.3232 6.76256 20.0303 6.46967C19.7374 6.17678 19.2626 6.17678 18.9697 6.46967L8.5 16.9393L4.53033 12.9697Z", - "chevron_up" | "collapse" => "F1 M4.21967 15.5303C4.51256 15.8232 4.98744 15.8232 5.28033 15.5303L12 8.81066L18.7197 15.5303C19.0126 15.8232 19.4874 15.8232 19.7803 15.5303C20.0732 15.2374 20.0732 14.7626 19.7803 14.4697L12.5303 7.21967C12.2374 6.92678 11.7626 6.92678 11.4697 7.21967L4.21967 14.4697C3.92678 14.7626 3.92678 15.2374 4.21967 15.5303Z", + "chevron_up" | "chevron_down" | "collapse" => "F1 M4.21967 15.5303C4.51256 15.8232 4.98744 15.8232 5.28033 15.5303L12 8.81066L18.7197 15.5303C19.0126 15.8232 19.4874 15.8232 19.7803 15.5303C20.0732 15.2374 20.0732 14.7626 19.7803 14.4697L12.5303 7.21967C12.2374 6.92678 11.7626 6.92678 11.4697 7.21967L4.21967 14.4697C3.92678 14.7626 3.92678 15.2374 4.21967 15.5303Z", "clock" => "F1 M3.5 12C3.5 7.30558 7.30558 3.5 12 3.5C16.6944 3.5 20.5 7.30558 20.5 12C20.5 16.6944 16.6944 20.5 12 20.5C7.30558 20.5 3.5 16.6944 3.5 12ZM12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM11.9931 6.64827C11.9435 6.28233 11.6295 6 11.25 6C10.836 6 10.5 6.336 10.5 6.75V12.75L10.5069 12.8517C10.5565 13.2177 10.8705 13.5 11.25 13.5H15.25L15.3517 13.4931C15.7177 13.4435 16 13.1295 16 12.75C16 12.336 15.664 12 15.25 12H12V6.75L11.9931 6.64827Z", "copy" => "F1 M8.5 8.5H6.25C5.2835 8.5 4.5 9.2835 4.5 10.25V17.75C4.5 18.7165 5.2835 19.5 6.25 19.5H11.25C12.1315 19.5 12.8591 18.8479 12.9805 18H14.4893C14.3616 19.6781 12.9608 21 11.25 21H6.25C4.45507 21 3 19.5449 3 17.75V10.25C3 8.45507 4.45507 7 6.25 7H8.5V8.5ZM17.75 3C19.5449 3 21 4.45507 21 6.25V13.75C21 15.5449 19.5449 17 17.75 17H12.75C10.9551 17 9.5 15.5449 9.5 13.75V6.25C9.5 4.45507 10.9551 3 12.75 3H17.75ZM12.75 4.5C11.7835 4.5 11 5.2835 11 6.25V13.75C11 14.7165 11.7835 15.5 12.75 15.5H17.75C18.7165 15.5 19.5 14.7165 19.5 13.75V6.25C19.5 5.2835 18.7165 4.5 17.75 4.5H12.75Z", "close" | "dismiss" => "F1 M4.39705 4.55379L4.46967 4.46967C4.73594 4.2034 5.1526 4.1792 5.44621 4.39705L5.53033 4.46967L12 10.939L18.4697 4.46967C18.7626 4.17678 19.2374 4.17678 19.5303 4.46967C19.8232 4.76256 19.8232 5.23744 19.5303 5.53033L13.061 12L19.5303 18.4697C19.7966 18.7359 19.8208 19.1526 19.6029 19.4462L19.5303 19.5303C19.2641 19.7966 18.8474 19.8208 18.5538 19.6029L18.4697 19.5303L12 13.061L5.53033 19.5303C5.23744 19.8232 4.76256 19.8232 4.46967 19.5303C4.17678 19.2374 4.17678 18.7626 4.46967 18.4697L10.939 12L4.46967 5.53033C4.2034 5.26406 4.1792 4.8474 4.39705 4.55379L4.46967 4.46967L4.39705 4.55379Z", diff --git a/Ember/widgets.rs b/Ember/widgets.rs index fde8761..659bd6b 100644 --- a/Ember/widgets.rs +++ b/Ember/widgets.rs @@ -6,10 +6,48 @@ use ferroui_base::media::{FontWeight, TextWrapping}; use ferroui_base::{BoxedValue, CornerRadius, FerroObject, FerroProperty, Ref, Thickness}; use ferroui_controls::automation::AutomationProperties; use ferroui_controls::{ - Border, Button, ComboBox, Control, ItemsSource, ScrollViewer, StackPanel, TextBlock, TextBox, + Border, Button, ComboBox, Control, Expander, ItemsSource, ScrollViewer, StackPanel, TextBlock, + TextBox, ToolTip, }; use ferroui_markup_xaml::markup_extensions::DynamicResourceExtension; use std::rc::Rc; +/// Places related form fields side by side only when their own pane has room. +pub(crate) fn field_pair(parent: &StackPanel) -> (Ref, Ref) { + use ferroui_controls::{ColumnDefinitions, Grid, RowDefinitions}; + + let grid = Grid::new(); + grid.set_row_definitions(RowDefinitions::parse("Auto,Auto").expect("form rows")); + grid.set_column_spacing(12.0); + grid.set_row_spacing(8.0); + let first = stack(4.0); + let second = stack(4.0); + Grid::set_row(&second, 1); + grid.children().add(first.clone()); + grid.children().add(second.clone()); + let weak_grid = grid.downgrade(); + let weak_second = second.downgrade(); + grid.size_changed(move |_, e| { + const TWO_COLUMNS: f64 = 400.0; + let wide = e.new_size().width >= TWO_COLUMNS; + if wide == (e.previous_size().width >= TWO_COLUMNS) { + return; + } + if let (Some(grid), Some(second)) = (weak_grid.upgrade(), weak_second.upgrade()) { + let (rows, columns, row, column) = if wide { + ("Auto", "*,*", 0, 1) + } else { + ("Auto,Auto", "*", 1, 0) + }; + grid.set_column_definitions(ColumnDefinitions::parse(columns).expect("form columns")); + grid.set_row_definitions(RowDefinitions::parse(rows).expect("form rows")); + Grid::set_row(&second, row); + Grid::set_column(&second, column); + } + }); + parent.children().add(grid); + (first, second) +} + pub(crate) fn text(value: &str, size: f64) -> Ref { let block = TextBlock::new(); block.set_text(Some(value)); @@ -40,18 +78,43 @@ pub(crate) fn bind_resource(target: &FerroObject, property: &'static FerroProper } /// A titled card of a form, added to `parent`; returns the panel its rows go in. pub(crate) fn section(parent: &StackPanel, title: &str) -> Ref { - let content = stack(12.0); - content.children().add(label(title, 19.0, false)); + let content = stack(8.0); + let title = label(title, 18.0, false); + AutomationProperties::set_heading_level(&title, 2); + content.children().add(title); let border = Border::new(); - border.set_padding(Thickness::uniform(20.0)); - border.set_corner_radius(CornerRadius::uniform(12.0)); + border.set_padding(Thickness::uniform(14.0)); + border.set_corner_radius(CornerRadius::uniform(8.0)); border.set_border_thickness(Thickness::uniform(1.0)); - bind_resource(&border, Border::background_property().as_property(), "EmberSurface"); - bind_resource(&border, Border::border_brush_property().as_property(), "EmberBorder"); + bind_resource( + &border, + Border::background_property().as_property(), + "EmberSurface", + ); + bind_resource( + &border, + Border::border_brush_property().as_property(), + "EmberBorder", + ); border.set_child(content.clone()); parent.children().add(border); content } +/// A native disclosure keeps the header keyboard-operable and exposes its expanded state. +pub(crate) fn disclosure(parent: &StackPanel, title: &str, expanded: bool) -> Ref { + let content = stack(8.0); + let expander = Expander::new(); + expander.set_header(Some(Rc::new(title.to_owned()) as BoxedValue)); + expander.set_content(Some(Control::boxed(&content))); + expander.set_horizontal_alignment(HorizontalAlignment::Stretch); + expander.set_horizontal_content_alignment(HorizontalAlignment::Stretch); + expander.set_padding(Thickness::uniform(12.0)); + expander.set_is_expanded(expanded); + AutomationProperties::set_name(&expander, Some(title)); + parent.children().add(expander); + content +} + /// A labelled text field of at most `max_length` characters, added to `parent`. pub(crate) fn field( parent: &StackPanel, @@ -61,7 +124,8 @@ pub(crate) fn field( max_length: i32, multiline: bool, ) -> Ref { - parent.children().add(label(label_text, 13.0, false)); + let caption = label(label_text, 13.0, false); + parent.children().add(caption.clone()); let input = TextBox::new(); input.set_text(Some(value)); input.set_placeholder_text(Some(placeholder)); @@ -74,6 +138,7 @@ pub(crate) fn field( input.set_max_height(160.0); } AutomationProperties::set_name(&input, Some(label_text)); + AutomationProperties::set_labeled_by(&input, caption.upcast::()); parent.children().add(input.clone()); input } @@ -84,7 +149,8 @@ pub(crate) fn combo( values: &[&str], selected: i32, ) -> Ref { - parent.children().add(label(label_text, 13.0, false)); + let caption = label(label_text, 13.0, false); + parent.children().add(caption.clone()); let input = ComboBox::new(); input.set_items_source(Some(ItemsSource::from_values( values.iter().map(|s| (*s).to_owned()).collect::>(), @@ -93,22 +159,23 @@ pub(crate) fn combo( input.set_min_height(44.0); input.set_horizontal_alignment(HorizontalAlignment::Stretch); AutomationProperties::set_name(&input, Some(label_text)); + AutomationProperties::set_labeled_by(&input, caption.upcast::()); parent.children().add(input.clone()); input } -/// A full-width form button; its text is plain content, so the WinUI button templates -/// (`accent` included) colour it. +/// An intrinsic-width action using the standard WinUI button or accent template. pub(crate) fn form_button(label_text: &str) -> Ref