diff --git a/resources/icons/back.svg b/resources/icons/back.svg
new file mode 100644
index 000000000..7c26d1e33
--- /dev/null
+++ b/resources/icons/back.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/resources/icons/chevron_down.svg b/resources/icons/chevron_down.svg
new file mode 100644
index 000000000..b3e5e3b1a
--- /dev/null
+++ b/resources/icons/chevron_down.svg
@@ -0,0 +1,4 @@
+
diff --git a/resources/icons/chevron_up.svg b/resources/icons/chevron_up.svg
new file mode 100644
index 000000000..c23ba12b1
--- /dev/null
+++ b/resources/icons/chevron_up.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs
index cce62da59..4c1b0dc33 100644
--- a/src/home/home_screen.rs
+++ b/src/home/home_screen.rs
@@ -5,6 +5,9 @@ use crate::{
home::{
invite_screen::InviteScreenWidgetExt,
navigation_tab_bar::{NavigationBarAction, SelectedTab},
+ room_action_bar::{
+ RoomActionBarAction, RoomActionBarWidgetExt, handle_default_action_stub,
+ },
room_screen::RoomScreenWidgetExt,
rooms_list::RoomsListAction,
space_lobby::SpaceLobbyScreenWidgetExt,
@@ -30,7 +33,11 @@ script_mod! {
width: Fill, height: Fill
draw_bg.color: (COLOR_PRIMARY)
header +: {
- height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT),
+ // `Fit` lets the header grow from one row (45px) to two rows
+ // (90px) when the `RoomActionBar` inside is expanded. The
+ // enclosing `HomeScreen` updates the body's top margin in step
+ // with this animation (see `RoomActionBarAction::ExpansionToggled`).
+ height: Fit,
padding: 0
align: Align{y: 0.5}
@@ -117,33 +124,29 @@ script_mod! {
}
}
- content +: {
- height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT)
- align: Align{y: 0.5}
- button_container +: {
- padding: 0,
- margin: 0
- left_button +: {
- width: Fit, height: Fit,
- padding: Inset{left: 20, right: 23, top: 10, bottom: 10}
- margin: Inset{left: 8, right: 0, top: 0, bottom: 0}
- draw_icon +: { color: (ROOM_NAME_TEXT_COLOR) }
- icon_walk: Walk{width: 13, height: Fit}
- spacing: 0
- text: ""
- }
- }
- title_container +: {
- // padding: Inset{top: 8}
- title +: {
- draw_text +: {
- color: (ROOM_NAME_TEXT_COLOR)
- }
- }
- }
+ // The `RoomActionBar` owns the entire header content: back
+ // button, room-name label, inline action buttons, the chevron
+ // expand/collapse trigger, and the full-width row-2 overflow.
+ // We fully replace the base's `content` (`:=` rather than `+:`)
+ // because the widget manages its own internal flow and the
+ // base's `title_container` / `button_container` children would
+ // otherwise contribute confusing extra nodes.
+ //
+ // The back button is named `left_button` inside the widget so
+ // the base `StackNavigationView` keeps emitting
+ // `StackNavigationAction::Pop` for its click.
+ content := mod.widgets.RoomActionBar {
+ width: Fill
+ height: Fit
}
}
body +: {
+ // Starts at one-row height (45). `HomeScreen` bumps this to 90
+ // when the active room's action bar fires
+ // `RoomActionBarAction::ExpansionToggled{is_expanded: true}`,
+ // and back to 45 on collapse — in sync with the header's
+ // animated height so the message list tracks the header's
+ // bottom edge.
margin: Inset{top: (mod.widgets.STACK_VIEW_HEADER_HEIGHT)}
}
}
@@ -519,6 +522,35 @@ impl Widget for HomeScreen {
_ => {}
}
+ // Room action bar click from the mobile StackNavigationView
+ // header. The desktop path captures these inside RoomScreen;
+ // on mobile the action bar lives in the header — a sibling of
+ // RoomScreen — so the action bubbles up to us.
+ if let Some(RoomActionBarAction::ButtonClicked { id })
+ = action.downcast_ref()
+ {
+ handle_default_action_stub(*id);
+ continue;
+ }
+
+ // Keep the active stack view's body-top margin in sync with
+ // the header height as the `RoomActionBar` animates between
+ // one and two rows. Only relevant on mobile; on desktop the
+ // bar lives inside `room_top_header` (`height: Fit`) which
+ // grows naturally.
+ if let Some(RoomActionBarAction::ExpansionToggled { is_expanded })
+ = action.downcast_ref()
+ {
+ let depth = self.view.stack_navigation(cx, ids!(view_stack)).depth();
+ let margin_top = if *is_expanded {
+ crate::home::room_action_bar::ROOM_ACTION_BAR_EXPANDED_HEIGHT
+ } else {
+ crate::home::room_action_bar::ROOM_ACTION_BAR_ROW_HEIGHT
+ };
+ self.set_mobile_body_margin_top_at_depth(cx, depth, margin_top);
+ continue;
+ }
+
// When a stack navigation pop is initiated (back button pressed),
// pop the mobile nav stack so it stays in sync with StackNavigation.
if let StackNavigationAction::Pop = action.as_widget_action().cast() {
@@ -610,7 +642,7 @@ impl HomeScreen {
) {
let new_depth = self.view.stack_navigation(cx, ids!(view_stack)).depth();
- let view_id = match &selected_room {
+ let (view_id, show_action_buttons) = match &selected_room {
SelectedRoom::JoinedRoom { room_name_id }
| SelectedRoom::Thread { room_name_id, .. } => {
let (view_id, room_screen_id) = Self::room_ids_for_depth(new_depth);
@@ -622,25 +654,32 @@ impl HomeScreen {
self.view
.room_screen(cx, &[room_screen_id])
.set_displayed_room(cx, room_name_id, thread_root);
- view_id
+ (view_id, true)
}
SelectedRoom::InvitedRoom { room_name_id } => {
self.view
.invite_screen(cx, ids!(invite_screen))
.set_displayed_invite(cx, room_name_id);
- id!(invite_view)
+ (id!(invite_view), false)
}
SelectedRoom::Space { space_name_id } => {
self.view
.space_lobby_screen(cx, ids!(space_lobby_screen))
.set_displayed_space(cx, space_name_id);
- id!(space_lobby_view)
+ (id!(space_lobby_view), false)
}
};
- // Set the header title for the view being pushed.
- let title_path = &[view_id, live_id!(header), live_id!(content), live_id!(title_container), live_id!(title)];
- self.view.label(cx, title_path).set_text(cx, &selected_room.display_name());
+ // Configure this stack view's header `RoomActionBar`: back
+ // button is always visible on mobile; room-name label reflects
+ // the display name. Action buttons are shown only for actual
+ // room views — invites and space lobbies hide them since
+ // search / threads / members / info don't apply there.
+ let action_bar_path = &[view_id, live_id!(header), live_id!(content)];
+ let action_bar = self.view.room_action_bar(cx, action_bar_path);
+ action_bar.set_back_button_visible(cx, true);
+ action_bar.set_action_buttons_visible(cx, show_action_buttons);
+ action_bar.set_room_name(cx, &selected_room.display_name());
// Save the current selected_room onto the navigation stack before replacing it.
if let Some(prev) = app_state.selected_room.take() {
@@ -652,5 +691,24 @@ impl HomeScreen {
self.view.stack_navigation(cx, ids!(view_stack)).push(cx, view_id);
self.view.redraw(cx);
}
+
+ /// Applies a body-top margin to the stack view at the given depth so
+ /// the message list stays flush with the bottom of its header as the
+ /// header's `RoomActionBar` animates between one and two rows.
+ fn set_mobile_body_margin_top_at_depth(
+ &mut self,
+ cx: &mut Cx,
+ depth: usize,
+ margin_top_px: f64,
+ ) {
+ if depth == 0 || depth > Self::ROOM_VIEW_IDS.len() {
+ return;
+ }
+ let view_id = Self::ROOM_VIEW_IDS[depth - 1];
+ let mut body = self.view.view(cx, &[view_id, live_id!(body)]);
+ script_apply_eval!(cx, body, {
+ margin: Inset{top: #(margin_top_px)}
+ });
+ }
}
diff --git a/src/home/mod.rs b/src/home/mod.rs
index 23a1de96d..5e1287888 100644
--- a/src/home/mod.rs
+++ b/src/home/mod.rs
@@ -13,6 +13,7 @@ pub mod loading_pane;
pub mod location_preview;
pub mod main_desktop_ui;
pub mod main_mobile_ui;
+pub mod room_action_bar;
pub mod room_screen;
pub mod room_read_receipt;
pub mod rooms_list;
@@ -50,6 +51,7 @@ pub fn script_mod(vm: &mut ScriptVm) {
invite_modal::script_mod(vm);
invite_screen::script_mod(vm);
tombstone_footer::script_mod(vm);
+ room_action_bar::script_mod(vm);
room_screen::script_mod(vm);
rooms_sidebar::script_mod(vm);
welcome_screen::script_mod(vm);
diff --git a/src/home/room_action_bar.rs b/src/home/room_action_bar.rs
new file mode 100644
index 000000000..207f021be
--- /dev/null
+++ b/src/home/room_action_bar.rs
@@ -0,0 +1,569 @@
+//! `RoomActionBar`: the full two-row header for a room screen.
+//!
+//! The widget owns every element that appears in the header:
+//! * an optional back button (shown on mobile, hidden on desktop);
+//! * the room name label (ellipsis-truncated only as a last resort);
+//! * the four fixed action buttons (search, threads, members, info); and
+//! * an expand / collapse chevron trigger that appears when there isn't
+//! enough horizontal room to show all four action buttons inline.
+//!
+//! ## Visual states
+//! There are exactly three states, driven by the bar's allocated width and
+//! the current room name:
+//!
+//! 1. **All inline.** Back + title + four action buttons all fit on a
+//! single row. No chevron is shown; the second row is collapsed.
+//! 2. **Expand-only.** There isn't room for every action button alongside
+//! the full room name, but there IS room for the name plus a single
+//! chevron. The four action buttons move to row 2, which appears
+//! beneath row 1 when the chevron is tapped. The room name is NOT
+//! abbreviated.
+//! 3. **Ellipsized.** There isn't room even for the name plus the chevron.
+//! The name is ellipsized; the chevron still sits flush with the right
+//! edge and its tap still reveals row 2.
+//!
+//! ## Why the icons are declared statically
+//! An earlier revision set button icons at runtime through
+//! `script_apply_eval!` on `draw_icon.svg`. That approach was unreliable
+//! when rapidly re-applied across instances, so every icon on this bar is
+//! now pre-declared in the DSL and layout only toggles widget visibility.
+//! The expand / collapse affordance is likewise split into two separate
+//! buttons (`expand_button` with chevron-down, `collapse_button` with
+//! chevron-up) so swapping them is also a visibility toggle.
+
+use makepad_widgets::*;
+
+use crate::shared::popup_list::{PopupKind, enqueue_popup_notification};
+
+/// Pixel size of each icon button (square).
+const BUTTON_SIZE: f64 = 36.0;
+/// Horizontal spacing between adjacent icons.
+const BUTTON_SPACING: f64 = 4.0;
+/// Pitch = button + one spacing gap.
+const BUTTON_PITCH: f64 = BUTTON_SIZE + BUTTON_SPACING;
+/// Right-edge padding reserved outside the layout math.
+const BAR_EDGE_PADDING: f64 = 8.0;
+/// Approximate width of the back button region when shown.
+const BACK_BUTTON_RESERVE: f64 = 58.0;
+/// Rough per-character width estimate for the title font. Over-estimate
+/// slightly so we pick the more-conservative layout state when the true
+/// width is ambiguous (fewer inline buttons is the "safer" decision).
+const TITLE_CHAR_WIDTH_PX: f64 = 8.2;
+
+/// Height of a single row of icons. The bar's collapsed height.
+pub const ROOM_ACTION_BAR_ROW_HEIGHT: f64 = 45.0;
+
+/// Height of the bar when expanded (exactly two rows).
+pub const ROOM_ACTION_BAR_EXPANDED_HEIGHT: f64 = 2.0 * ROOM_ACTION_BAR_ROW_HEIGHT;
+
+/// Minimum width the bar should ever occupy: one button (the chevron
+/// trigger) plus right-edge padding. Used as the DSL-level `Fill{min:..}`
+/// so the trigger can never be pushed off-screen.
+pub const ROOM_ACTION_BAR_MIN_WIDTH: f64 = BUTTON_SIZE + BAR_EDGE_PADDING;
+
+/// The four configured actions. Kept as plain constants so external click
+/// handlers can match on them without a dedicated enum.
+pub const ACTION_ID_SEARCH: LiveId = live_id!(search);
+pub const ACTION_ID_THREADS: LiveId = live_id!(threads);
+pub const ACTION_ID_MEMBERS: LiveId = live_id!(members);
+pub const ACTION_ID_INFO: LiveId = live_id!(info);
+
+script_mod! {
+ use mod.prelude.widgets.*
+ use mod.widgets.*
+
+ mod.widgets.ROOM_ACTION_BAR_ROW_HEIGHT = 45
+ mod.widgets.ROOM_ACTION_BAR_EXPANDED_HEIGHT = 90
+ mod.widgets.ROOM_ACTION_BAR_BUTTON_SIZE = 36
+ mod.widgets.ROOM_ACTION_BAR_ANIMATION_DURATION_SECS = 0.2
+
+ // Each icon button is `RobrixIconButton` tuned to be icon-only with a
+ // transparent background and a discreet hover tint. The SVG is set on
+ // the slot directly in the DSL below — never swapped at runtime.
+ mod.widgets.RoomActionBarInlineButton = RobrixIconButton {
+ width: (mod.widgets.ROOM_ACTION_BAR_BUTTON_SIZE)
+ height: (mod.widgets.ROOM_ACTION_BAR_BUTTON_SIZE)
+ visible: false
+ padding: Inset{left: 8, right: 8, top: 8, bottom: 8}
+ margin: 0
+ spacing: 0
+ align: Align{x: 0.5, y: 0.5}
+ icon_walk: Walk{width: 20, height: 20}
+ draw_bg +: {
+ color: (COLOR_TRANSPARENT)
+ color_hover: #00000014
+ color_down: #00000024
+ border_size: 0.0
+ border_radius: 4.0
+ }
+ draw_icon +: { color: (COLOR_TEXT) }
+ text: ""
+ }
+
+ mod.widgets.RoomActionBar = set_type_default() do #(RoomActionBar::register_widget(vm)) {
+ ..mod.widgets.View
+
+ width: Fill{min: (mod.widgets.ROOM_ACTION_BAR_ROW_HEIGHT)}
+ height: (mod.widgets.ROOM_ACTION_BAR_ROW_HEIGHT)
+ flow: Down
+ clip_x: true
+ clip_y: true
+
+ row_1 := View {
+ width: Fill
+ height: (mod.widgets.ROOM_ACTION_BAR_ROW_HEIGHT)
+ flow: Right
+ align: Align{y: 0.5}
+ padding: Inset{left: 0, right: 8, top: 0, bottom: 0}
+ spacing: 0
+
+ // Back button — visible by default (mobile case). Desktop
+ // calls `set_back_button_visible(false)` to hide the wrapping
+ // container. Named `left_button` so the path matches the
+ // Makepad base, but the click-to-Pop wiring is handled by
+ // `RoomActionBar::handle_actions` below (emitting
+ // `StackNavigationAction::Pop` directly) — not by the base's
+ // `self.button(cx, ids!(left_button))` scan, which doesn't
+ // reliably descend into this custom widget's subtree.
+ back_button_container := View {
+ width: Fit, height: Fit
+ align: Align{y: 0.5}
+ left_button := ButtonFlatterIcon {
+ width: Fit, height: Fit
+ padding: Inset{left: 20, right: 23, top: 10, bottom: 10}
+ margin: Inset{left: 8, right: 0, top: 0, bottom: 0}
+ spacing: 0
+ text: ""
+ icon_walk: Walk{width: 13, height: 13}
+ draw_icon +: {
+ color: (ROOM_NAME_TEXT_COLOR)
+ svg: crate_resource("self://resources/icons/back.svg")
+ }
+ }
+ }
+
+ // Title fills whatever space remains between the back button
+ // (if shown) and the right-side button cluster. It ellipsizes
+ // only when the available width forces it to — state #3.
+ title_container := View {
+ width: Fill, height: Fit
+ align: Align{x: 0.0, y: 0.5}
+ padding: Inset{left: 12, right: 8}
+ title := Label {
+ width: Fill, height: Fit
+ max_lines: 1
+ text_overflow: Ellipsis
+ margin: 0
+ draw_text +: {
+ color: (ROOM_NAME_TEXT_COLOR)
+ text_style: mod.widgets.TITLE_TEXT {}
+ }
+ text: ""
+ }
+ }
+
+ // The four action-button slots, one per action, each with its
+ // static SVG. Visibility is toggled by `layout_for_width`
+ // based on whether we're in the "all inline" or "expand-only"
+ // state. Never created or destroyed at runtime.
+ row_1_search := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_SEARCH) }
+ }
+ row_1_threads := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_DOUBLE_CHAT) }
+ }
+ row_1_members := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_ADD_USER) }
+ }
+ row_1_info := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_INFO) }
+ }
+
+ // Two separate chevron buttons — the "swap" is a visibility
+ // toggle, not an SVG re-apply. Exactly one is visible at a
+ // time while the bar is in the expand-only state; both hidden
+ // in the all-inline state.
+ expand_button := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_CHEVRON_DOWN) }
+ }
+ collapse_button := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_CHEVRON_UP) }
+ }
+ }
+
+ // Row 2 spans the full bar width. Its buttons right-align against
+ // the right edge so they line up with the chevron on row 1. These
+ // are shown together (all four) only while the bar is expanded.
+ row_2 := View {
+ width: Fill
+ height: (mod.widgets.ROOM_ACTION_BAR_ROW_HEIGHT)
+ flow: Right
+ align: Align{x: 1.0, y: 0.5}
+ padding: Inset{left: 0, right: 8, top: 0, bottom: 0}
+ spacing: 0
+
+ row_2_search := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_SEARCH) }
+ }
+ row_2_threads := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_DOUBLE_CHAT) }
+ }
+ row_2_members := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_ADD_USER) }
+ }
+ row_2_info := mod.widgets.RoomActionBarInlineButton {
+ draw_icon +: { svg: (mod.widgets.ICON_INFO) }
+ }
+ }
+
+ animator: Animator {
+ expansion: {
+ default: @collapsed
+ collapsed: AnimatorState {
+ redraw: true
+ from: { all: Forward { duration: (mod.widgets.ROOM_ACTION_BAR_ANIMATION_DURATION_SECS) } }
+ apply: { height: (mod.widgets.ROOM_ACTION_BAR_ROW_HEIGHT) }
+ }
+ expanded: AnimatorState {
+ redraw: true
+ from: { all: Forward { duration: (mod.widgets.ROOM_ACTION_BAR_ANIMATION_DURATION_SECS) } }
+ apply: { height: (mod.widgets.ROOM_ACTION_BAR_EXPANDED_HEIGHT) }
+ }
+ }
+ }
+ }
+}
+
+/// The four row-1 action-button slot ids, paired with their action id.
+const ROW_1_ACTIONS: [(LiveId, LiveId); 4] = [
+ (live_id!(row_1_search), ACTION_ID_SEARCH),
+ (live_id!(row_1_threads), ACTION_ID_THREADS),
+ (live_id!(row_1_members), ACTION_ID_MEMBERS),
+ (live_id!(row_1_info), ACTION_ID_INFO),
+];
+
+/// Row-2 analogs, in matching order.
+const ROW_2_ACTIONS: [(LiveId, LiveId); 4] = [
+ (live_id!(row_2_search), ACTION_ID_SEARCH),
+ (live_id!(row_2_threads), ACTION_ID_THREADS),
+ (live_id!(row_2_members), ACTION_ID_MEMBERS),
+ (live_id!(row_2_info), ACTION_ID_INFO),
+];
+
+/// Actions emitted by a [`RoomActionBar`].
+#[derive(Clone, Debug, Default)]
+pub enum RoomActionBarAction {
+ /// One of the configured action buttons was tapped (either row).
+ ButtonClicked { id: LiveId },
+ /// The expand/collapse state flipped. Coordinators (e.g. mobile
+ /// `HomeScreen`) use this to keep dependent layout (e.g. the stack
+ /// nav body's top margin) in sync with the bar's new height.
+ ExpansionToggled { is_expanded: bool },
+ #[default]
+ None,
+}
+
+/// Which of the three layout states the bar is currently in.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum LayoutState {
+ /// Back + full title + all four action buttons fit on row 1.
+ /// Row 2 / chevron are hidden.
+ AllInline,
+ /// Back + full title + one chevron fit on row 1, but all four buttons
+ /// don't. Row 2 reveals them when expanded. Title is NOT abbreviated.
+ ExpandOnly,
+ /// Even back + chevron + the full title don't fit on row 1; the title
+ /// is ellipsized. Row 2 still reveals the buttons when expanded.
+ Ellipsized,
+}
+
+#[derive(Script, ScriptHook, Widget, Animator)]
+pub struct RoomActionBar {
+ #[source] source: ScriptObjectRef,
+ #[deref] view: View,
+ #[apply_default] animator: Animator,
+
+ /// Current layout state (drives slot visibility).
+ #[rust(LayoutState::AllInline)] state: LayoutState,
+ /// Whether the bar is animated into its expanded (two-row) state.
+ /// Meaningless in `LayoutState::AllInline`.
+ #[rust] is_expanded: bool,
+ /// Whether the back-button region is rendered in row 1 (mobile).
+ #[rust(true)] back_button_visible: bool,
+ /// Whether the action buttons (search / threads / members / info) and
+ /// their expand/collapse chevron should be rendered at all. Set to
+ /// `false` for non-room views (invites, space lobbies) where these
+ /// actions don't apply; the bar then shows just back + title.
+ #[rust(true)] show_action_buttons: bool,
+ /// The current room-name text, cached for the layout heuristic.
+ #[rust] room_name: String,
+ /// Width we last computed a layout for. -1.0 forces a recompute.
+ #[rust(-1.0)] last_laid_out_width: f64,
+}
+
+impl Widget for RoomActionBar {
+ fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
+ let rect = cx.peek_walk_turtle(walk);
+ let allocated_width = rect.size.x.max(0.0);
+
+ if (allocated_width - self.last_laid_out_width).abs() > 0.5 {
+ self.last_laid_out_width = allocated_width;
+ self.recompute_layout(cx, allocated_width);
+ }
+
+ self.view.draw_walk(cx, scope, walk)
+ }
+
+ fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
+ if self.animator_handle_event(cx, event).must_redraw() {
+ self.redraw(cx);
+ }
+ self.view.handle_event(cx, event, scope);
+ self.widget_match_event(cx, event, scope);
+ }
+}
+
+impl WidgetMatchEvent for RoomActionBar {
+ fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) {
+ let self_uid = self.widget_uid();
+
+ // Back button → pop the enclosing `StackNavigationView`.
+ //
+ // Makepad's `StackNavigationView::handle_stack_view_closure_request`
+ // tries to detect the click itself via
+ // `self.button(cx, ids!(left_button)).clicked(&actions)`, but that
+ // path-scan does not reliably descend into custom widgets like this
+ // one. So we emit the `Pop` action directly — `StackNavigation`
+ // (the parent) handles any `Pop` it observes in its action stream.
+ if self.back_button_visible
+ && self.view.button(cx, ids!(left_button)).clicked(actions)
+ {
+ cx.widget_action(self_uid, StackNavigationAction::Pop);
+ return;
+ }
+
+ // Row-1 action buttons. Only live in `AllInline`.
+ if self.state == LayoutState::AllInline {
+ for (slot_id, action_id) in ROW_1_ACTIONS {
+ if self.view.button(cx, &[slot_id]).clicked(actions) {
+ cx.widget_action(
+ self_uid,
+ RoomActionBarAction::ButtonClicked { id: action_id },
+ );
+ return;
+ }
+ }
+ }
+
+ // Expand / collapse chevron. Only live in the other states.
+ if self.state != LayoutState::AllInline {
+ if !self.is_expanded
+ && self.view.button(cx, ids!(expand_button)).clicked(actions)
+ {
+ self.set_expanded(cx, true);
+ return;
+ }
+ if self.is_expanded
+ && self.view.button(cx, ids!(collapse_button)).clicked(actions)
+ {
+ self.set_expanded(cx, false);
+ return;
+ }
+ }
+
+ // Row-2 action buttons. Only live while expanded.
+ if self.state != LayoutState::AllInline && self.is_expanded {
+ for (slot_id, action_id) in ROW_2_ACTIONS {
+ if self.view.button(cx, &[slot_id]).clicked(actions) {
+ cx.widget_action(
+ self_uid,
+ RoomActionBarAction::ButtonClicked { id: action_id },
+ );
+ // Fall through: keep the bar expanded; the user might
+ // want to tap another action in the same reveal. They
+ // can collapse via the chevron when done.
+ return;
+ }
+ }
+ }
+ }
+}
+
+impl RoomActionBar {
+ /// Sets the room-name label text. Cheap; triggers a relayout on the
+ /// next draw.
+ pub fn set_room_name(&mut self, cx: &mut Cx, name: &str) {
+ if self.room_name != name {
+ self.room_name = name.to_string();
+ self.view
+ .label(cx, ids!(title_container.title))
+ .set_text(cx, name);
+ self.last_laid_out_width = -1.0;
+ self.redraw(cx);
+ }
+ }
+
+ /// Toggles whether the back-button region on the left of row 1 is
+ /// rendered. Desktop sets this to `false`; mobile to `true`.
+ pub fn set_back_button_visible(&mut self, cx: &mut Cx, visible: bool) {
+ if self.back_button_visible != visible {
+ self.back_button_visible = visible;
+ self.view
+ .view(cx, ids!(back_button_container))
+ .set_visible(cx, visible);
+ self.last_laid_out_width = -1.0;
+ self.redraw(cx);
+ }
+ }
+
+ /// Toggles whether the four action buttons (and the expand/collapse
+ /// chevron) are rendered.
+ ///
+ /// Set to `false` for non-room stack views (invites, space lobbies)
+ /// where "search messages", "threads", "members", and "room info"
+ /// are not applicable — the bar then shows only back + title, and
+ /// the chevron is suppressed (so no row-2 reveal is possible).
+ pub fn set_action_buttons_visible(&mut self, cx: &mut Cx, visible: bool) {
+ if self.show_action_buttons == visible {
+ return;
+ }
+ self.show_action_buttons = visible;
+ // If we're turning buttons off while expanded, snap back to the
+ // one-row state — there's nothing meaningful to reveal any more.
+ if !visible && self.is_expanded {
+ self.set_expanded(cx, false);
+ }
+ self.last_laid_out_width = -1.0;
+ self.redraw(cx);
+ }
+
+ /// Flips the expand state to `expanded`, plays the height animation,
+ /// updates chevron / row-2 visibility, and emits
+ /// [`RoomActionBarAction::ExpansionToggled`].
+ fn set_expanded(&mut self, cx: &mut Cx, expanded: bool) {
+ if self.is_expanded == expanded { return; }
+ self.is_expanded = expanded;
+ self.animator_play(
+ cx,
+ if expanded { ids!(expansion.expanded) } else { ids!(expansion.collapsed) },
+ );
+ self.apply_chevron_visibility(cx);
+ self.apply_row_2_visibility(cx);
+ cx.widget_action(
+ self.widget_uid(),
+ RoomActionBarAction::ExpansionToggled { is_expanded: expanded },
+ );
+ }
+
+ /// Decides the current [`LayoutState`] from `available_width` and the
+ /// current room-name length, then applies the slot visibility to
+ /// match.
+ fn recompute_layout(&mut self, cx: &mut Cx, available_width: f64) {
+ let back_reserve = if self.back_button_visible { BACK_BUTTON_RESERVE } else { 0.0 };
+ // Title intrinsic width estimate (plus its internal padding: 12 + 8).
+ let title_padding = 12.0 + 8.0;
+ let title_natural = estimate_title_width(&self.room_name) + title_padding;
+
+ let all_buttons_width = 4.0 * BUTTON_PITCH - BUTTON_SPACING;
+ let single_button_width = BUTTON_SIZE;
+
+ let needed_for_all_inline = back_reserve + title_natural + all_buttons_width + BAR_EDGE_PADDING;
+ let needed_for_expand_only = back_reserve + title_natural + single_button_width + BAR_EDGE_PADDING;
+
+ let new_state = if available_width >= needed_for_all_inline {
+ LayoutState::AllInline
+ } else if available_width >= needed_for_expand_only {
+ LayoutState::ExpandOnly
+ } else {
+ LayoutState::Ellipsized
+ };
+
+ let state_changed = new_state != self.state;
+ self.state = new_state;
+
+ // Transitioning out of an expanded-capable state (to AllInline)
+ // collapses the bar; the expand-capable states preserve their
+ // current is_expanded.
+ if self.state == LayoutState::AllInline && self.is_expanded {
+ self.set_expanded(cx, false);
+ }
+
+ // Apply visibilities even when state didn't change (e.g. room
+ // name changed without crossing a state boundary — buttons stay,
+ // but the chevron up/down choice may still need refreshing).
+ self.apply_row_1_visibility(cx);
+ self.apply_chevron_visibility(cx);
+ self.apply_row_2_visibility(cx);
+ let _ = state_changed;
+ }
+
+ fn apply_row_1_visibility(&mut self, cx: &mut Cx) {
+ let show_inline = self.show_action_buttons && self.state == LayoutState::AllInline;
+ for (slot_id, _) in ROW_1_ACTIONS {
+ self.view.button(cx, &[slot_id]).set_visible(cx, show_inline);
+ }
+ }
+
+ fn apply_chevron_visibility(&mut self, cx: &mut Cx) {
+ let needs_chevron = self.show_action_buttons && self.state != LayoutState::AllInline;
+ let expand_visible = needs_chevron && !self.is_expanded;
+ let collapse_visible = needs_chevron && self.is_expanded;
+ self.view.button(cx, ids!(expand_button)).set_visible(cx, expand_visible);
+ self.view.button(cx, ids!(collapse_button)).set_visible(cx, collapse_visible);
+ }
+
+ fn apply_row_2_visibility(&mut self, cx: &mut Cx) {
+ let show = self.show_action_buttons
+ && self.state != LayoutState::AllInline
+ && self.is_expanded;
+ for (slot_id, _) in ROW_2_ACTIONS {
+ self.view.button(cx, &[slot_id]).set_visible(cx, show);
+ }
+ }
+}
+
+impl RoomActionBarRef {
+ /// See [`RoomActionBar::set_room_name`].
+ pub fn set_room_name(&self, cx: &mut Cx, name: &str) {
+ if let Some(mut inner) = self.borrow_mut() {
+ inner.set_room_name(cx, name);
+ }
+ }
+
+ /// See [`RoomActionBar::set_back_button_visible`].
+ pub fn set_back_button_visible(&self, cx: &mut Cx, visible: bool) {
+ if let Some(mut inner) = self.borrow_mut() {
+ inner.set_back_button_visible(cx, visible);
+ }
+ }
+
+ /// See [`RoomActionBar::set_action_buttons_visible`].
+ pub fn set_action_buttons_visible(&self, cx: &mut Cx, visible: bool) {
+ if let Some(mut inner) = self.borrow_mut() {
+ inner.set_action_buttons_visible(cx, visible);
+ }
+ }
+}
+
+/// Surfaces a placeholder popup for the default room action IDs.
+///
+/// Intended as a shared stub while the underlying features (search, threads
+/// list, members list, room info) are still unimplemented.
+pub fn handle_default_action_stub(id: LiveId) {
+ let message = match id {
+ ACTION_ID_SEARCH => "Room search is not yet implemented.",
+ ACTION_ID_THREADS => "Threads list is not yet implemented.",
+ ACTION_ID_MEMBERS => "Members list is not yet implemented.",
+ ACTION_ID_INFO => "Room info is not yet implemented.",
+ _ => return,
+ };
+ enqueue_popup_notification(message, PopupKind::Info, Some(3.0));
+}
+
+/// Rough pixel width of `text` rendered in the title font. Used by
+/// [`RoomActionBar::recompute_layout`] to decide how much room the action
+/// buttons have. A small over-estimate is preferable to an under-estimate
+/// (fewer inline buttons is the safer fallback).
+fn estimate_title_width(text: &str) -> f64 {
+ (text.chars().count() as f64) * TITLE_CHAR_WIDTH_PX
+}
diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs
index 34fb61fd3..4dae40a8c 100644
--- a/src/home/room_screen.rs
+++ b/src/home/room_screen.rs
@@ -38,6 +38,9 @@ use crate::{
sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime}
};
use crate::home::event_reaction_list::ReactionListWidgetRefExt;
+use crate::home::room_action_bar::{
+ RoomActionBarAction, RoomActionBarWidgetExt, handle_default_action_stub,
+};
use crate::home::room_read_receipt::AvatarRowWidgetRefExt;
use crate::room::room_input_bar::RoomInputBarWidgetExt;
use crate::shared::mentionable_text_input::MentionableTextInputAction;
@@ -556,6 +559,22 @@ script_mod! {
flow: Down,
spacing: 0.0
+ // Desktop-only top header. The `RoomActionBar` now owns the full
+ // header layout (title + action buttons + optional expand/collapse
+ // row), so this wrapper just gives it a background and `height: Fit`
+ // so it can grow from one row to two when the bar is expanded.
+ // Hidden on mobile, where the enclosing `StackNavigationView`
+ // header hosts its own `RoomActionBar` (with the back button shown).
+ room_top_header := View {
+ width: Fill
+ height: Fit
+ flow: Down
+ show_bg: true
+ draw_bg +: { color: (COLOR_PRIMARY) }
+
+ room_action_bar := mod.widgets.RoomActionBar {}
+ }
+
room_screen_wrapper := SolidView {
width: Fill, height: Fill,
flow: Overlay,
@@ -999,6 +1018,17 @@ impl Widget for RoomScreen {
return false;
}
+ // A button on this RoomScreen's own action bar was tapped
+ // (either inline or via the shared overflow menu). Only our
+ // own bar's clicks reach here because `capture_actions` scopes
+ // them to this widget.
+ if let Some(RoomActionBarAction::ButtonClicked { id })
+ = action.downcast_ref()
+ {
+ handle_default_action_stub(*id);
+ return false;
+ }
+
// Handle the action that requests to show the user profile sliding pane.
if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = action.as_widget_action().cast() {
self.show_user_profile(
@@ -1062,6 +1092,14 @@ impl Widget for RoomScreen {
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
+ // The top header (room name + action bar) is only shown on desktop.
+ // On mobile, the enclosing `StackNavigationView` header takes over;
+ // see `RobrixContentView` in `home_screen.rs`.
+ let show_top_header = cx.display_context.is_desktop();
+ self.view
+ .view(cx, ids!(room_top_header))
+ .set_visible(cx, show_top_header);
+
// If the room isn't loaded yet, we show the restore status label only.
if !self.is_loaded {
let Some(room_name) = &self.room_name_id else {
@@ -2471,6 +2509,16 @@ impl RoomScreen {
);
}
+ /// Updates the desktop top-header's `RoomActionBar` to display the
+ /// given room: hides the back-button region (desktop has no back
+ /// button) and sets the title. The widget owns its own action
+ /// buttons statically, so nothing else needs configuring.
+ fn update_top_header_for_room(&mut self, cx: &mut Cx, room_name_id: &RoomNameId) {
+ let action_bar = self.view.room_action_bar(cx, ids!(room_top_header.room_action_bar));
+ action_bar.set_back_button_visible(cx, false);
+ action_bar.set_room_name(cx, &room_name_id.to_string());
+ }
+
/// Sets this `RoomScreen` widget to display the timeline for the given room.
pub fn set_displayed_room(
&mut self,
@@ -2489,6 +2537,12 @@ impl RoomScreen {
}
};
+ // Keep the desktop top-header's label and the action bar in sync
+ // with the room being displayed. Safe to call regardless of the
+ // "already displayed" early-return below because the same string
+ // would be re-applied.
+ self.update_top_header_for_room(cx, room_name_id);
+
// If this timeline is already displayed, we don't need to do anything major,
// but we do need update the `room_name_id` in case it has changed, or it has been cleared.
if self.timeline_kind.as_ref().is_some_and(|kind| kind == &timeline_kind) {
diff --git a/src/profile/user_profile.rs b/src/profile/user_profile.rs
index cedbbeba3..0894e8466 100644
--- a/src/profile/user_profile.rs
+++ b/src/profile/user_profile.rs
@@ -66,8 +66,6 @@ script_mod! {
- mod.widgets.ICON_DOUBLE_CHAT = crate_resource("self://resources/icons/double_chat.svg")
-
let UserProfileView = ScrollXYView {
width: Fill,
height: Fill,
diff --git a/src/shared/styles.rs b/src/shared/styles.rs
index 612a905e7..ec6835503 100644
--- a/src/shared/styles.rs
+++ b/src/shared/styles.rs
@@ -16,7 +16,10 @@ script_mod! {
mod.widgets.ICON_CLOUD_OFFLINE = crate_resource("self://resources/icons/cloud_offline.svg")
mod.widgets.ICON_ROTATE_CW = crate_resource("self://resources/icons/rotate_right_fa.svg")
mod.widgets.ICON_ROTATE_CCW = crate_resource("self://resources/icons/rotate_left_fa.svg")
+ mod.widgets.ICON_CHEVRON_DOWN = crate_resource("self://resources/icons/chevron_down.svg")
+ mod.widgets.ICON_CHEVRON_UP = crate_resource("self://resources/icons/chevron_up.svg")
mod.widgets.ICON_COPY = crate_resource("self://resources/icons/copy.svg")
+ mod.widgets.ICON_DOUBLE_CHAT = crate_resource("self://resources/icons/double_chat.svg")
mod.widgets.ICON_EDIT = crate_resource("self://resources/icons/edit.svg")
mod.widgets.ICON_EXTERNAL_LINK = crate_resource("self://resources/icons/external_link.svg")
mod.widgets.ICON_IMPORT = crate_resource("self://resources/icons/import.svg")