feat(ui): Make the launcher navigatable with the keyboard

This commit is contained in:
LynithDev 2026-07-13 16:10:21 +02:00
parent 21313b2093
commit 557b0e4b7b
18 changed files with 735 additions and 399 deletions

View file

@ -59,6 +59,9 @@ impl Component for Dropdown {
let mut open = use_state(|| false);
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let mut button_area = use_state(|| None::<Area>);
let mut list_size = use_state(|| None::<Size2D>);
@ -115,13 +118,19 @@ impl Component for Dropdown {
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(trigger_bg)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.maybe(focus().is_focused(), |el| {
el.border(border_all_color(1., colors::brand()))
})
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_global_pointer_press(on_global_pointer_press)
.on_sized(move |e: Event<SizedEventData>| {
button_area.set_if_modified(Some(e.area));
})
.on_press(move |e: Event<PressEventData>| {
.on_all_press(move |e: Event<PressEventData>| {
e.prevent_default();
e.stop_propagation();
open.toggle();
@ -211,18 +220,24 @@ impl Component for DropdownOption {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
rect()
.width(Size::fill())
.padding(Gaps::new_symmetric(6., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(if hovering() {
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.background(if hovering() || focus().is_focused() {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
})
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_press(self.on_press.clone())
.on_all_press(self.on_press.clone())
.child(
label()
.text(self.text.clone())

View file

@ -46,19 +46,25 @@ impl Component for MajorVersionCard {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let selected = self.selected;
let hovered = *hovering.read();
let focused = focus().is_focused();
let on_press = self.on_press.clone();
let opacity = if selected {
1.0
} else if hovered {
0.85
let opacity = if selected || hovered || focused {
if selected {
1.0
} else {
0.85
}
} else {
0.6
};
let border_color = if selected {
let border_color = if selected || focused {
colors::brand()
} else if hovered {
colors::component_border_hover()
@ -70,7 +76,10 @@ impl Component for MajorVersionCard {
.key(self.major)
.width(Size::flex(1.0))
.height(Size::px(CARD_HEIGHT_PX))
.on_press(move |e| on_press.call(e))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_all_press(move |e| on_press.call(e))
.on_pointer_enter(move |_| {
*hovering.write() = true;
})

View file

@ -106,7 +106,7 @@ impl Component for NavLink {
Cursor::set(CursorIcon::default());
});
let color = if active || *hovering.peek() || focused.peek().is_focused() {
let color = if active || hovering() || focused().is_focused() {
theme::colors::fg_primary()
} else {
theme::colors::fg_secondary()
@ -114,7 +114,7 @@ impl Component for NavLink {
let underline_width = if active {
27.
} else if *hovering.peek() || focused.peek().is_focused() {
} else if hovering() || focused().is_focused() {
18.
} else {
0.

View file

@ -1,6 +1,6 @@
use freya::prelude::*;
use crate::components::{Icon, IconType};
use crate::components::{Button, Icon, IconType};
use crate::theme::colors;
use crate::ui::border_all_color;
@ -45,18 +45,11 @@ impl Component for Pagination {
let icon_btn = move |target: usize, enabled: bool, icon: IconType| {
let mut page = page;
rect()
.center()
.width(Size::px(32.))
.height(Size::px(32.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.maybe(enabled, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| page.set(target))
})
Button::new()
.secondary()
.icon()
.enabled(enabled)
.on_press(move |_| page.set(target))
.child(Icon::new(icon).size(14.).color(if enabled {
colors::fg_primary()
} else {
@ -68,43 +61,38 @@ impl Component for Pagination {
let num_btn = move |target: usize| {
let mut page = page;
let is_current = target == current;
rect()
.center()
if is_current {
return rect()
.center()
.width(Size::px(32.))
.height(Size::px(32.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg_pressed())
.border(border_all_color(1., colors::brand()))
.child(
label()
.text(format!("{}", target + 1))
.font_size(12.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.into_element();
}
Button::new()
.secondary()
.width(Size::px(32.))
.height(Size::px(32.))
.corner_radius(CornerRadius::new_all(8.))
.background(if is_current {
colors::component_bg_pressed()
} else {
colors::component_bg()
})
.border(border_all_color(
1.,
if is_current {
colors::brand()
} else {
colors::component_border()
},
))
.maybe(!is_current, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| page.set(target))
})
.padding(Gaps::new_symmetric(6., 0.))
.on_press(move |_| page.set(target))
.child(
label()
.text(format!("{}", target + 1))
.font_size(12.)
.font_weight(if is_current {
FontWeight::SEMI_BOLD
} else {
FontWeight::NORMAL
})
.color(if is_current {
colors::fg_primary()
} else {
colors::fg_secondary()
}),
.font_weight(FontWeight::NORMAL)
.max_lines(1)
.color(colors::fg_secondary()),
)
.into_element()
};

View file

@ -101,6 +101,10 @@ impl Component for ClusterCard {
let active = *active_id.read() == Some(self.cluster.id);
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let total = CARD_MS as f32 + (self.count.saturating_sub(1) as f32) * STAGGER_MS as f32;
let elapsed = self.progress * total;
let local =
@ -135,7 +139,7 @@ impl Component for ClusterCard {
.corner_radius(CornerRadius::new_all(12.))
.overflow(Overflow::Clip)
.border(
if active {
if active || focused {
border_all_color(2., colors::brand())
} else if *hovering.read() {
border_all_color(1., colors::component_border_hover())
@ -144,6 +148,9 @@ impl Component for ClusterCard {
}
.alignment(BorderAlignment::Outer),
)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_over(move |_| {
if !*hovering.peek() {
*hovering.write() = true;
@ -160,7 +167,7 @@ impl Component for ClusterCard {
.y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 0)),
)
.on_press(on_press)
.on_all_press(on_press)
.child(
rect()
.width(Size::fill())
@ -220,17 +227,31 @@ impl Component for OtherVersionsTile {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
rect()
.width(Size::px(MORE_TILE_WIDTH_PX))
.height(Size::fill())
.corner_radius(CornerRadius::new_all(12.))
.border(border_all(1.))
.border(
if focused {
border_all_color(2., colors::brand())
} else {
border_all(1.)
}
.alignment(BorderAlignment::Outer),
)
.background(if *hovering.read() {
colors::ghost_overlay_pressed()
} else {
colors::ghost_overlay_hover()
})
.blur(32.)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| {
*hovering.write() = true;
})
@ -245,7 +266,7 @@ impl Component for OtherVersionsTile {
.y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 0)),
)
.on_press(|_| {
.on_all_press(|_| {
let _ = RouterContext::get().push(Route::Clusters {});
})
.child(
@ -259,10 +280,6 @@ impl Component for OtherVersionsTile {
}
fn recent_card_slots_for_width(row_width_px: f32) -> usize {
// Layout is: [card gap]* more-tile — i.e. N cards each followed by a gap,
// then the fixed-width more-tile. So width for N cards is:
// N * MIN_CARD_WIDTH + N * GAP + MORE_TILE_WIDTH
// Solve for the largest N that fits.
if row_width_px <= MORE_TILE_WIDTH_PX + CARD_GAP_PX {
return 0;
}

View file

@ -311,36 +311,42 @@ impl ScrollArea {
let can_scroll_h = horizontal && vp_w > 0. && vp_w < content_w;
let on_wheel = move |e: Event<WheelEventData>| {
let wants_h = horizontal && *shift_held.read();
let shift = *shift_held.read();
let h_delta = if horizontal {
if shift && e.delta_x == 0.0 { e.delta_y } else { e.delta_x }
} else {
0.0
};
let v_delta = if horizontal && shift { 0.0 } else { e.delta_y };
let do_h = h_delta != 0.0 && can_scroll_h;
let do_v = v_delta != 0.0 && can_scroll_v;
// check if scrollable
if (wants_h && !can_scroll_h) || !can_scroll_v {
if !do_h && !do_v {
return;
}
e.stop_propagation();
if (e.delta_y != 0.0 || e.delta_x != 0.0)
&& let Some(cb) = &us_wheel
{
if let Some(cb) = &us_wheel {
cb.call(());
}
if wants_h {
let delta = if e.delta_x != 0.0 { e.delta_x } else { e.delta_y };
if delta != 0.0 {
let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read());
let new_x = scroll_pos_from_wheel(delta as f32, content_w, vp_w, cur_x);
scroll_x.set(new_x as f32);
}
return;
if do_h {
let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read());
let new_x = scroll_pos_from_wheel(h_delta as f32, content_w, vp_w, cur_x);
scroll_x.set(new_x as f32);
}
let (_, cur_y) = controller.into();
let current = corrected_scroll(ct_h, vp_h, cur_y as f32);
let new_y = scroll_pos_from_wheel(e.delta_y as f32, ct_h, vp_h, current);
if new_y != cur_y {
controller.scroll_to_y(new_y);
if do_v {
let (_, cur_y) = controller.into();
let current = corrected_scroll(ct_h, vp_h, cur_y as f32);
let new_y = scroll_pos_from_wheel(v_delta as f32, ct_h, vp_h, current);
if new_y != cur_y {
controller.scroll_to_y(new_y);
}
}
};
@ -403,6 +409,11 @@ impl ScrollArea {
rect()
.width(self.width.clone())
.height(self.height.clone())
.scrollable(true)
.a11y_builder(move |node| {
node.set_scroll_x(corrected_x as f64);
node.set_scroll_y(corrected_y as f64);
})
.on_wheel(on_wheel)
.on_capture_global_pointer_move(on_global_move)
.on_capture_global_pointer_press(on_global_release)

View file

@ -101,16 +101,6 @@ impl<T: Copy + PartialEq + 'static> Component for SegmentedControl<T> {
let equal_width = self.equal_width;
let disabled = self.disabled;
let active_color = |is_selected: bool| {
if disabled {
colors::fg_secondary().with_a(110)
} else if is_selected {
colors::fg_primary()
} else {
colors::fg_secondary()
}
};
rect()
.horizontal()
.height(Size::px(self.height))
@ -121,55 +111,103 @@ impl<T: Copy + PartialEq + 'static> Component for SegmentedControl<T> {
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.children(self.segments.iter().map(move |seg| {
let value = seg.value;
let is_selected = value == active;
let mut selected = selected;
let label_text = seg.label.clone();
SegmentButton {
selected,
value: seg.value,
is_selected: seg.value == active,
icon: seg.icon,
label: seg.label.clone(),
icon_size,
tint,
equal_width,
disabled,
}
.into_element()
}))
}
}
rect()
.horizontal()
.height(Size::fill())
.cross_align(Alignment::Center)
.spacing(6.)
.corner_radius(CornerRadius::new_all(6.))
.map(equal_width, |el, w| {
el.width(Size::px(w)).main_align(Alignment::Center)
})
.maybe(equal_width.is_none(), |el| {
el.padding(Gaps::new_symmetric(0., 12.))
})
.background(if is_selected {
colors::component_bg_pressed()
#[derive(PartialEq)]
struct SegmentButton<T: Copy + PartialEq + 'static> {
selected: State<T>,
value: T,
is_selected: bool,
icon: Option<IconType>,
label: Option<String>,
icon_size: Option<f32>,
tint: bool,
equal_width: Option<f32>,
disabled: bool,
}
impl<T: Copy + PartialEq + 'static> Component for SegmentButton<T> {
fn render(&self) -> impl IntoElement {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let mut selected = self.selected;
let value = self.value;
let is_selected = self.is_selected;
let disabled = self.disabled;
let equal_width = self.equal_width;
let focused = focus().is_focused();
let content_color = if disabled {
colors::fg_secondary().with_a(110)
} else if is_selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
rect()
.horizontal()
.height(Size::fill())
.cross_align(Alignment::Center)
.spacing(6.)
.corner_radius(CornerRadius::new_all(6.))
.a11y_id(a11y_id)
.a11y_focusable(!disabled)
.a11y_role(AccessibilityRole::Button)
.map(equal_width, |el, w| {
el.width(Size::px(w)).main_align(Alignment::Center)
})
.maybe(equal_width.is_none(), |el| {
el.padding(Gaps::new_symmetric(0., 12.))
})
.background(if is_selected {
colors::component_bg_pressed()
} else {
Color::TRANSPARENT
})
.maybe(focused && !disabled, |el| {
el.border(border_all_color(1., colors::brand()))
})
.maybe(!disabled, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_all_press(move |_| *selected.write() = value)
})
.maybe_child(self.icon.map(|icon| {
let mut ic = Icon::new(icon);
if let Some(size) = self.icon_size {
ic = ic.size(size);
}
if self.tint {
ic = ic.color(content_color);
}
ic.into_element()
}))
.maybe_child(self.label.clone().map(|text| {
label()
.text(text)
.font_size(13.)
.font_weight(if is_selected {
FontWeight::MEDIUM
} else {
Color::TRANSPARENT
FontWeight::NORMAL
})
.maybe(!disabled, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| *selected.write() = value)
})
.maybe_child(seg.icon.map(|icon| {
let mut ic = Icon::new(icon);
if let Some(size) = icon_size {
ic = ic.size(size);
}
if tint {
ic = ic.color(active_color(is_selected));
}
ic.into_element()
}))
.maybe_child(label_text.map(|text| {
label()
.text(text)
.font_size(13.)
.font_weight(if is_selected {
FontWeight::MEDIUM
} else {
FontWeight::NORMAL
})
.color(active_color(is_selected))
.into_element()
}))
.color(content_color)
.into_element()
}))
}

View file

@ -6,7 +6,7 @@ pub struct TabItem {
label: String,
active: bool,
count: Option<String>,
on_press: Option<Box<dyn FnMut(Event<PressEventData>)>>,
on_press: Option<EventHandler<Event<PressEventData>>>,
}
impl TabItem {
@ -25,8 +25,8 @@ impl TabItem {
self
}
pub fn on_press(mut self, handler: impl FnMut(Event<PressEventData>) + 'static) -> Self {
self.on_press = Some(Box::new(handler));
pub fn on_press(mut self, handler: impl Into<EventHandler<Event<PressEventData>>>) -> Self {
self.on_press = Some(handler.into());
self
}
}
@ -147,46 +147,79 @@ impl IntoElement for TabBar {
.cross_align(Alignment::Center)
.content(Content::Fit)
.children(self.tabs.into_iter().map(|tab| {
let active = tab.active;
let mut el = rect()
.vertical()
.content(Content::Fit)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()));
if let Some(handler) = tab.on_press {
el = el.on_press(handler);
TabButton {
label: tab.label,
active: tab.active,
count: tab.count,
font_size,
on_press: tab.on_press,
}
el.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.content(Content::Fit)
.child(
rect()
.vertical()
.content(Content::Fit)
.child(tab_label(&tab.label, font_size, active))
.child(
rect()
.height(Size::px(1.5))
.width(Size::fill_minimum())
.margin(Gaps::new_symmetric(0., 4.0))
.corner_radius(CornerRadius::new_all(2.))
.background(if active {
colors::fg_primary()
} else {
Color::TRANSPARENT
}),
),
)
.maybe_child(tab.count.map(|c| count_pill(&c, font_size).into_element())),
)
.into_element()
}))
.into_element()
}
}
#[derive(PartialEq)]
struct TabButton {
label: String,
active: bool,
count: Option<String>,
font_size: f32,
on_press: Option<EventHandler<Event<PressEventData>>>,
}
impl Component for TabButton {
fn render(&self) -> impl IntoElement {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let active = self.active;
let font_size = self.font_size;
let underline_on = active || focus().is_focused();
let mut el = rect()
.vertical()
.content(Content::Fit)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()));
if let Some(handler) = self.on_press.clone() {
el = el.on_all_press(move |e| handler.call(e));
}
el.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.content(Content::Fit)
.child(
rect()
.vertical()
.content(Content::Fit)
.child(tab_label(&self.label, font_size, active))
.child(
rect()
.height(Size::px(1.5))
.width(Size::fill_minimum())
.margin(Gaps::new_symmetric(0., 4.0))
.corner_radius(CornerRadius::new_all(2.))
.background(if underline_on {
colors::fg_primary()
} else {
Color::TRANSPARENT
}),
),
)
.maybe_child(
self.count
.as_ref()
.map(|c| count_pill(c, font_size).into_element()),
),
)
}
}

View file

@ -50,8 +50,17 @@ impl Component for Switch {
let on_press = self.on_press.clone();
let on = *value.read();
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let knob_align = if on { Alignment::End } else { Alignment::Start };
let track_border = if focus().is_focused() {
colors::fg_primary()
} else {
colors::component_border()
};
let background = use_animation_transition(value, |_, on| {
if on {
AnimColor::new(colors::component_bg(), colors::brand())
@ -84,9 +93,12 @@ impl Component for Switch {
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| {
.on_all_press(move |_| {
on_press.call(());
})
.child(
@ -104,7 +116,7 @@ impl Component for Switch {
.main_align(knob_align)
.cross_align(Alignment::Center)
.background(&*background.read())
.border(ui::border_all_color(1., colors::component_border()))
.border(ui::border_all_color(1., track_border))
.child(
rect()
.width(Size::px(HANDLE_SIZE))

View file

@ -717,6 +717,9 @@ impl Component for SidebarItem {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let tab = self.tab;
let active = self.active;
let route = tab.route();
@ -731,7 +734,7 @@ impl Component for SidebarItem {
let has_dot = matches!(tab, SettingsTab::Changelog);
rect()
let mut el = rect()
.horizontal()
.content(Content::Flex)
.width(Size::fill())
@ -741,6 +744,9 @@ impl Component for SidebarItem {
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(7.))
.background(background)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| {
*hovering.write() = true;
Cursor::set(CursorIcon::Pointer);
@ -750,11 +756,21 @@ impl Component for SidebarItem {
Cursor::set(CursorIcon::default());
})
.map(route, |el, route| {
el.on_press(move |_| {
el.on_all_press(move |_| {
let _ = RouterContext::get().push(route.clone());
})
})
.child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary()))
});
if focus().is_focused() {
el = el.border(
Border::new()
.fill(colors::brand())
.width(1.)
.alignment(BorderAlignment::Inner),
);
}
el.child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary()))
.child(
rect()
.horizontal()

View file

@ -66,6 +66,10 @@ impl Component for PackageCard {
let icon_url = self.item.icon_url.clone();
let cluster_id = self.cluster_id;
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
use_drop(|| {
Cursor::set(CursorIcon::default());
});
@ -76,11 +80,21 @@ impl Component for PackageCard {
.height(Size::px(CARD_H))
.corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.border(border_all_color(
1.,
if focused {
colors::brand()
} else {
colors::component_border()
},
))
.overflow(Overflow::Clip)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.child(
rect()
.margin(Gaps::new_all(1.))
@ -141,9 +155,35 @@ impl Component for PackageCard {
}
pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str) -> impl IntoElement {
let id = item.id.clone();
let provider = item.provider;
let package_type = package_type.to_string();
ListRow {
item,
cluster_id,
package_type: package_type.to_string(),
}
}
#[derive(PartialEq)]
struct ListRow {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
}
impl Component for ListRow {
fn render(&self) -> impl IntoElement {
let item = self.item.clone();
let cluster_id = self.cluster_id;
let id = item.id.clone();
let provider = item.provider;
let package_type = self.package_type.clone();
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
use_drop(|| {
Cursor::set(CursorIcon::default());
});
rect()
.horizontal()
@ -154,12 +194,22 @@ pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str
.padding(Gaps::new_all(16.))
.corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.border(border_all_color(
1.,
if focused {
colors::brand()
} else {
colors::component_border()
},
))
.overflow(Overflow::Clip)
.content(Content::Flex)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.))
.child(
rect()
@ -198,6 +248,7 @@ pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str
),
)
.child(downloads_row(item.downloads))
}
}
fn downloads_row(downloads: u64) -> impl IntoElement {

View file

@ -77,10 +77,15 @@ impl KeyExt for CategoryRow {
impl Component for CategoryRow {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let selected = self.selected;
let on_toggle = self.on_toggle.clone();
let hovered = *hovering.read();
let color = if selected || hovered {
let focused = focus().is_focused();
let color = if selected || hovered || focused {
colors::fg_primary()
} else {
colors::fg_secondary()
@ -89,6 +94,9 @@ impl Component for CategoryRow {
rect()
.width(Size::fill())
.padding(Gaps::new_symmetric(3., 0.))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| {
hovering.set(true);
Cursor::set(CursorIcon::Pointer);
@ -97,7 +105,7 @@ impl Component for CategoryRow {
hovering.set(false);
Cursor::set(CursorIcon::default());
})
.on_press(move |_| on_toggle.call(()))
.on_all_press(move |_| on_toggle.call(()))
.child(
label()
.text(capitalize(&self.name))

View file

@ -184,7 +184,7 @@ impl Component for BrowserPackage {
.vertical()
.width(Size::flex(1.0))
.spacing(12.)
.child(tabs(current, active_tab, has_gallery))
.child(tabs(active_tab, has_gallery))
.child(body),
)
.into_element();

View file

@ -6,7 +6,7 @@ use oneclient_core::packages::types::{
use oneclient_core::packages::ProviderId;
use crate::BridgeDispatch;
use crate::components::{Button, Icon, IconType};
use crate::components::{Button, Icon, IconType, Segment, SegmentedControl};
use crate::hooks::VERSIONS_PAGE_SIZE;
use crate::theme::colors;
use crate::ui::border_all_color;
@ -27,40 +27,15 @@ pub(super) fn loading_body() -> impl IntoElement {
.into_element()
}
pub(super) fn tabs(current: usize, active_tab: State<usize>, has_gallery: bool) -> impl IntoElement {
let mut labels: Vec<&str> = vec!["About", "Versions"];
pub(super) fn tabs(active_tab: State<usize>, has_gallery: bool) -> impl IntoElement {
let mut control = SegmentedControl::new(active_tab)
.height(36.)
.segment(Segment::new(0usize).label("About"))
.segment(Segment::new(1usize).label("Versions"));
if has_gallery {
labels.push("Gallery");
control = control.segment(Segment::new(2usize).label("Gallery"));
}
rect()
.horizontal()
.spacing(6.)
.padding(Gaps::new_all(4.))
.corner_radius(CornerRadius::new_all(9.))
.background(colors::component_bg())
.children(labels.into_iter().enumerate().map(move |(i, t)| {
let selected = i == current;
let mut active_tab = active_tab;
rect()
.center()
.padding(Gaps::new_symmetric(6., 16.))
.corner_radius(CornerRadius::new_all(7.))
.background(if selected {
colors::component_bg_pressed()
} else {
Color::TRANSPARENT
})
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| *active_tab.write() = i)
.child(label().text(t).font_size(13.).color(if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
}))
.into_element()
}))
.into_element()
control
}
pub(super) fn about_panel(project: &ProjectDetail) -> impl IntoElement {

View file

@ -174,44 +174,78 @@ fn authors_card(project: &ProjectDetail, confirm: State<Option<String>>) -> impl
card("Authors", rows)
}
fn author_row(member: &ProjectMember, mut confirm: State<Option<String>>) -> Element {
let url = member.url.clone();
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.corner_radius(CornerRadius::new_all(6.))
.padding(Gaps::new_all(4.))
.maybe(url.is_some(), |el| {
let url = url.clone().unwrap_or_default();
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| confirm.set(Some(url.clone())))
})
.child(Thumbnail::new(member.avatar_url.clone(), 32.).radius(6.))
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.child(
label()
.text(member.name.clone())
.font_size(12.)
.max_lines(1)
.color(colors::fg_primary()),
)
.maybe(!member.role.is_empty(), |el| {
el.child(
fn author_row(member: &ProjectMember, confirm: State<Option<String>>) -> Element {
AuthorRow {
name: member.name.clone(),
role: member.role.clone(),
url: member.url.clone(),
avatar_url: member.avatar_url.clone(),
confirm,
}
.into_element()
}
#[derive(PartialEq)]
struct AuthorRow {
name: String,
role: String,
url: Option<String>,
avatar_url: Option<String>,
confirm: State<Option<String>>,
}
impl Component for AuthorRow {
fn render(&self) -> impl IntoElement {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let url = self.url.clone();
let interactive = url.is_some();
let mut confirm = self.confirm;
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.corner_radius(CornerRadius::new_all(6.))
.padding(Gaps::new_all(4.))
.maybe(interactive, |el| {
let url = url.clone().unwrap_or_default();
el.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_all_press(move |_| confirm.set(Some(url.clone())))
})
.maybe(interactive && focused, |el| {
el.border(border_all_color(1., colors::brand()))
})
.child(Thumbnail::new(self.avatar_url.clone(), 32.).radius(6.))
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.child(
label()
.text(member.role.clone())
.font_size(10.)
.text(self.name.clone())
.font_size(12.)
.max_lines(1)
.color(colors::fg_secondary()),
.color(colors::fg_primary()),
)
}),
)
.into_element()
.maybe(!self.role.is_empty(), |el| {
el.child(
label()
.text(self.role.clone())
.font_size(10.)
.max_lines(1)
.color(colors::fg_secondary()),
)
}),
)
}
}
fn details_card(project: &ProjectDetail) -> impl IntoElement {
@ -282,25 +316,57 @@ fn links_card(links: &[(String, String)], confirm: State<Option<String>>) -> imp
card("Links", rows)
}
fn link_row(label_text: String, url: String, mut confirm: State<Option<String>>) -> Element {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| confirm.set(Some(url.clone())))
.child(
Icon::new(IconType::Link03)
.size(14.)
.color(colors::code_info()),
)
.child(
label()
.text(label_text)
.font_size(12.)
.color(colors::code_info()),
)
.into_element()
fn link_row(label_text: String, url: String, confirm: State<Option<String>>) -> Element {
LinkRow {
label_text,
url,
confirm,
}
.into_element()
}
#[derive(PartialEq)]
struct LinkRow {
label_text: String,
url: String,
confirm: State<Option<String>>,
}
impl Component for LinkRow {
fn render(&self) -> impl IntoElement {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let url = self.url.clone();
let mut confirm = self.confirm;
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.corner_radius(CornerRadius::new_all(6.))
.padding(Gaps::new_all(4.))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.maybe(focused, |el| {
el.border(border_all_color(1., colors::brand()))
})
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_all_press(move |_| confirm.set(Some(url.clone())))
.child(
Icon::new(IconType::Link03)
.size(14.)
.color(colors::code_info()),
)
.child(
label()
.text(self.label_text.clone())
.font_size(12.)
.color(colors::code_info()),
)
}
}

View file

@ -6,7 +6,7 @@ use oneclient_core::packages::domain::GameLoader;
use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution};
use crate::components::{
Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number,
Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number
};
use crate::hooks::{
ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation,
@ -147,16 +147,15 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen
colors::fg_secondary().with_a(60)
};
rect()
.center()
.width(Size::px(30.))
.height(Size::px(30.))
Button::new()
.small()
.ghost()
.icon()
.corner_radius(CornerRadius::new_all(7.))
.maybe(overridden, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| on_reset.call(()))
el.on_press(move |_| on_reset.call(()))
})
.enabled(overridden)
.child(Icon::new(IconType::RefreshCcw02).size(15.).color(color))
.into_element()
}

View file

@ -355,6 +355,9 @@ impl Component for LogPicker {
let mut open = use_state(|| false);
let filter = use_state(String::new);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let enabled = self.enabled;
let label_text = self.label.clone();
let files = self.files.clone();
@ -400,14 +403,24 @@ impl Component for LogPicker {
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(9.))
.background(colors::component_bg())
.border(ui::border_all_color(1., colors::component_border()))
.a11y_id(a11y_id)
.a11y_focusable(enabled)
.a11y_role(AccessibilityRole::Button)
.border(ui::border_all_color(
1.,
if enabled && focus().is_focused() {
colors::brand()
} else {
colors::component_border()
},
))
.on_pointer_enter(move |_| {
if enabled {
Cursor::set(CursorIcon::Pointer)
}
})
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |e: Event<PressEventData>| {
.on_all_press(move |e: Event<PressEventData>| {
if !enabled {
return;
}
@ -495,6 +508,9 @@ impl Component for PickerRow {
let mut hovering = use_state(|| false);
let file = self.file.clone();
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
rect()
.width(Size::fill())
.horizontal()
@ -503,7 +519,10 @@ impl Component for PickerRow {
.spacing(8.)
.padding(Gaps::new_symmetric(6., 8.))
.corner_radius(CornerRadius::new_all(7.))
.background(if hovering() {
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.background(if hovering() || focus().is_focused() {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
@ -516,7 +535,7 @@ impl Component for PickerRow {
hovering.set(false);
Cursor::set(CursorIcon::default());
})
.on_press(self.on_press.clone())
.on_all_press(self.on_press.clone())
.child(
rect()
.vertical()

View file

@ -1,5 +1,6 @@
use super::*;
use freya::animation::{AnimNum, Ease, OnCreation, use_animation};
use freya::router::RouterContext;
use oneclient_core::packages::ContentType;
use oneclient_core::settings::ViewLayout;
@ -184,7 +185,6 @@ struct FilterButton {
impl Component for FilterButton {
fn render(&self) -> impl IntoElement {
let mut open = use_state(|| false);
let mut hovering = use_state(|| false);
let sort = self.sort;
let current_sort = self.current_sort;
@ -194,11 +194,6 @@ impl Component for FilterButton {
let active =
current_sort != SortMode::NameAsc || *enabled_filter.read() != EnabledFilter::All;
let bg = if is_open || hovering() {
colors::component_bg_hover()
} else {
colors::component_bg()
};
let icon_color = if active {
colors::brand()
} else {
@ -208,21 +203,11 @@ impl Component for FilterButton {
rect()
.width(Size::px(FILTER_BTN_W))
.child(
rect()
Button::new()
.secondary()
.icon()
.width(Size::px(FILTER_BTN_W))
.height(Size::px(34.))
.center()
.corner_radius(CornerRadius::new_all(8.))
.background(bg)
.border(crate::ui::border_all_color(1., colors::component_border()))
.on_pointer_enter(move |_| {
hovering.set(true);
Cursor::set(CursorIcon::Pointer);
})
.on_pointer_leave(move |_| {
hovering.set(false);
Cursor::set(CursorIcon::default());
})
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
open.toggle();
@ -230,67 +215,112 @@ impl Component for FilterButton {
.child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)),
)
.maybe_child(is_open.then(|| {
filter_popover(sort, current_sort, enabled_filter, move || open.set(false))
.into_element()
let on_close: EventHandler<()> = (move |()| open.set(false)).into();
FilterPopover {
sort,
current_sort,
enabled_filter,
on_close,
}
.into_element()
}))
}
}
fn filter_popover(
mut sort: State<Option<String>>,
#[derive(PartialEq)]
struct FilterPopover {
sort: State<Option<String>>,
current_sort: SortMode,
mut enabled_filter: State<EnabledFilter>,
on_close: impl FnMut() + Clone + 'static,
) -> impl IntoElement {
let mut close_backdrop = on_close.clone();
enabled_filter: State<EnabledFilter>,
on_close: EventHandler<()>,
}
let show = *enabled_filter.read();
impl Component for FilterPopover {
fn render(&self) -> impl IntoElement {
let mut sort = self.sort;
let current_sort = self.current_sort;
let mut enabled_filter = self.enabled_filter;
let backdrop_close = self.on_close.clone();
let key_close = self.on_close.clone();
let mut panel = rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.padding(Gaps::new_all(8.))
.corner_radius(CornerRadius::new_all(10.))
.border(crate::ui::border_all_color(1., colors::component_border()))
.background(colors::page_elevated())
.child(section_label("Sort by"));
let a11y_id = use_a11y();
for mode in SortMode::ALL {
let selected = mode == current_sort;
panel = panel.child(choice_row(mode.label(), selected, move |_| {
sort.set(Some(mode.key().to_string()));
}));
let fade = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(160).ease(Ease::Out)
});
let progress = fade.read().value();
let show = *enabled_filter.read();
let mut panel = rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.padding(Gaps::new_all(8.))
.opacity(progress)
.offset_y((progress - 1.0) * 6.)
.corner_radius(CornerRadius::new_all(10.))
.background(colors::page_elevated().with_a(230))
.blur(12.)
.border(crate::ui::border_all_color(1., colors::component_border()))
.shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(120, 0, 0, 0))))
.a11y_id(a11y_id)
.a11y_role(AccessibilityRole::Dialog)
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape) {
key_close.call(());
}
})
.child(section_label("Sort by"));
for mode in SortMode::ALL {
let selected = mode == current_sort;
let on_press: EventHandler<Event<PressEventData>> = (move |_| {
sort.set(Some(mode.key().to_string()));
})
.into();
panel = panel.child(ChoiceRow {
text: mode.label(),
selected,
on_press,
});
}
panel = panel.child(section_label("Show"));
for filter in EnabledFilter::ALL {
let selected = filter == show;
let on_press: EventHandler<Event<PressEventData>> = (move |_| {
enabled_filter.set(filter);
})
.into();
panel = panel.child(ChoiceRow {
text: filter.label(),
selected,
on_press,
});
}
rect()
.height(Size::px(0.))
.width(Size::px(FILTER_PANEL_W))
.layer(Layer::Overlay)
.child(
rect()
.layer(Layer::RelativeOverlay(10))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.on_press(move |_| backdrop_close.call(())),
)
.child(
rect()
.width(Size::fill())
.layer(Layer::RelativeOverlay(12))
.margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W)))
.child(panel),
)
}
panel = panel.child(section_label("Show"));
for filter in EnabledFilter::ALL {
let selected = filter == show;
panel = panel.child(choice_row(filter.label(), selected, move |_| {
enabled_filter.set(filter);
}));
}
rect()
.height(Size::px(0.))
.width(Size::px(FILTER_PANEL_W))
.layer(Layer::Overlay)
.child(
rect()
.layer(Layer::RelativeOverlay(10))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.on_press(move |_| close_backdrop()),
)
.child(
rect()
.width(Size::fill())
.layer(Layer::RelativeOverlay(12))
.margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W)))
.child(panel),
)
.into_element()
}
fn section_label(text: &'static str) -> impl IntoElement {
@ -301,45 +331,68 @@ fn section_label(text: &'static str) -> impl IntoElement {
.color(colors::fg_secondary())
}
fn choice_row(
#[derive(PartialEq)]
struct ChoiceRow {
text: &'static str,
selected: bool,
on_press: impl FnMut(Event<PressEventData>) + 'static,
) -> impl IntoElement {
let color = if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.padding(Gaps::new_symmetric(5., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(if selected {
on_press: EventHandler<Event<PressEventData>>,
}
impl Component for ChoiceRow {
fn render(&self) -> impl IntoElement {
let text = self.text;
let selected = self.selected;
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let color = if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
let bg = if selected {
colors::component_bg()
} else if focused {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
})
.content(Content::Flex)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(on_press)
.child(
label()
.text(text)
.font_size(12.)
.width(Size::flex(1.0))
.color(color),
)
.maybe_child(selected.then(|| {
Icon::new(IconType::Check)
.size(14.)
.color(colors::brand())
.into_element()
}))
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.padding(Gaps::new_symmetric(5., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(bg)
.content(Content::Flex)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::MenuItemRadio)
.maybe(focused, |el| {
el.border(crate::ui::border_all_color(1., colors::brand()))
})
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_all_press(self.on_press.clone())
.child(
label()
.text(text)
.font_size(12.)
.width(Size::flex(1.0))
.color(color),
)
.maybe_child(selected.then(|| {
Icon::new(IconType::Check)
.size(14.)
.color(colors::brand())
.into_element()
}))
}
}
fn add_from_file_button(
@ -470,14 +523,29 @@ impl Component for ContentBox {
ContentKind::Other => empty_state(noun_plural).into_element(),
});
let header = (count > 0)
.then(|| match kind {
ContentKind::External => Some(
action_header(browse_button(cluster_id, package_type)).into_element(),
),
ContentKind::Local => Some(
action_header(add_from_file_button(cluster_id, content_type, dispatch.clone()))
.into_element(),
),
ContentKind::Other => None,
})
.flatten();
let mut bottom_corners = CornerRadius::new_all(0.);
bottom_corners.fill_bottom(12.);
let drop_dispatch = dispatch.clone();
rect()
.vertical()
.width(Size::fill())
.height(Size::flex(1.0))
.spacing(8.)
.padding(Gaps::new_all(8.))
.corner_radius(bottom_corners)
.background(colors::page_elevated())
@ -498,11 +566,22 @@ impl Component for ContentBox {
drop_dispatch.import_local_file(cluster_id, content_type, path.clone());
}
})
.maybe_child(header)
.maybe_child(scroll)
.maybe_child(empty)
}
}
fn action_header(button: impl IntoElement) -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.child(rect().width(Size::flex(1.0)))
.child(button)
}
fn grid_content(
items: &[PackageEntry],
package_type: &'static str,