diff --git a/crates/tinyclient/src/gui/layout.rs b/crates/tinyclient/src/gui/layout.rs index fb5a368..53b9e7b 100644 --- a/crates/tinyclient/src/gui/layout.rs +++ b/crates/tinyclient/src/gui/layout.rs @@ -164,6 +164,16 @@ pub fn plan(window: f32, sidebar: f32, hidden: bool, right: Option) -> Plan Plan { sidebar, right } } +/// Add the Discord parent column only when both lists and the conversation fit. +fn discord_overview_width(window: f32, sidebar: f32, hidden: bool, right: Option, server: bool) -> f32 { + const OVERVIEW: f32 = 280.; + if server && !hidden && window >= RAIL_WIDTH + sidebar + OVERVIEW + CONVERSATION_MIN + right.unwrap_or(0.) { + OVERVIEW + } else { + 0. + } +} + /// The right pane would replace the conversation here rather than sit beside it: Details /// hides then, and a thread or search stands in for the conversation (07 §4). pub fn narrow(window: f32, sidebar: f32, hidden: bool, right: f32) -> bool { @@ -337,9 +347,10 @@ impl Shell { return; } let x = f32::from(event.position.x); + let overview = discord_overview_width(f32::from(window.viewport_size().width), self.layout_state.sidebar_width, self.layout_state.sidebar_hidden, self.layout_state.right.as_ref().filter(|_| !self.pane_tucked()).map(|_| self.layout_state.right_width), matches!(&self.sidebar_state.mode, super::sidebar::Mode::Space(space) if space.network == Network::Discord)); let state = &mut self.layout_state; match edge { - Edge::Sidebar => state.sidebar_width = (x - RAIL_WIDTH).round().clamp(SIDEBAR_RANGE.0, SIDEBAR_RANGE.1), + Edge::Sidebar => state.sidebar_width = (x - RAIL_WIDTH - overview).round().clamp(SIDEBAR_RANGE.0, SIDEBAR_RANGE.1), Edge::Right => { let window = f32::from(window.viewport_size().width); state.right_width = (window - x).round().clamp(RIGHT_RANGE.0, RIGHT_RANGE.1); @@ -581,12 +592,14 @@ impl Shell { } /// The open menu, over everything, with a catcher behind it that closes it. - fn menu_layer(&self, snapshot: &Snapshot, sidebar: f32, window: &mut Window, cx: &mut Context) -> Option
{ + fn menu_layer(&self, snapshot: &Snapshot, sidebar: f32, overview: f32, window: &mut Window, cx: &mut Context) -> Option
{ let open = self.layout_state.menu.as_ref()?; let tall = f32::from(window.viewport_size().height); // The rail's add button and own avatar, and the sidebar strip's trailing button. let rail_button = |from_bottom: f32| place::anchor(16., tall - from_bottom - 40., 40., 40.); - let strip_button = place::anchor(RAIL_WIDTH + sidebar - 12. - 28., 12., 28., 28.); + let strip_left = RAIL_WIDTH + if matches!(open, Menu::Space(_)) { overview } else { 0. }; + let strip_width = if overview > 0. && matches!(open, Menu::Network(_)) { overview } else { sidebar }; + let strip_button = place::anchor(strip_left + strip_width - 12. - 28., 12., 28., 28.); let placed: AnyElement = match open { Menu::Add => { let content = fade_in("menu-add", 120, self.add_menu(snapshot, cx)); @@ -825,8 +838,9 @@ impl Render for Shell { // Too narrow for the pane beside the conversation: sticky Details hides (07 §4). self.note_room(narrow(width, state.sidebar_width, state.sidebar_hidden, state.right_width), None); let right_width = state.right.as_ref().filter(|_| !self.pane_tucked()).map(|_| state.right_width); - let plan = plan(width, state.sidebar_width, state.sidebar_hidden, right_width); - self.note_room(self.pane_narrow(), Some(conversation_width(width, &plan))); + let overview = discord_overview_width(width, state.sidebar_width, state.sidebar_hidden, right_width, matches!(&self.sidebar_state.mode, super::sidebar::Mode::Space(space) if space.network == Network::Discord)); + let plan = plan(width - overview, state.sidebar_width, state.sidebar_hidden, right_width); + self.note_room(self.pane_narrow(), Some(conversation_width(width - overview, &plan))); let floating = plan.sidebar.is_none() && state.floating; let replaced = plan.right.is_some_and(|(_, replaces)| replaces); // With nothing open in a window too narrow for the sidebar, the list is the main view: @@ -835,6 +849,7 @@ impl Render for Shell { let _ = tall; root.child(self.rail(snapshot, window, cx)) + .when(overview > 0., |root| root.child(self.discord_overview(snapshot, overview, window, cx))) .children(plan.sidebar.map(|width| { self.sidebar(snapshot, width, window, cx).child(self.resize_handle(Edge::Sidebar, cx)) })) @@ -874,7 +889,7 @@ impl Render for Shell { ) }) .children(self.switcher(window, cx)) - .children(self.menu_layer(snapshot, plan.sidebar.unwrap_or(state.sidebar_width), window, cx)) + .children(self.menu_layer(snapshot, plan.sidebar.unwrap_or(state.sidebar_width), overview, window, cx)) .children(self.toast_layer(width, cx)) } } @@ -911,6 +926,21 @@ impl Shell { mod tests { use super::*; + #[test] + fn discord_columns_keep_a_readable_conversation_and_yield_to_narrow_windows() { + let minimum = RAIL_WIDTH + SIDEBAR_WIDTH + 280. + CONVERSATION_MIN; + assert_eq!(discord_overview_width(minimum, SIDEBAR_WIDTH, false, None, true), 280.); + assert_eq!(discord_overview_width(minimum - 1., SIDEBAR_WIDTH, false, None, true), 0.); + assert_eq!(discord_overview_width(1800., SIDEBAR_WIDTH, true, None, true), 0.); + assert_eq!(discord_overview_width(1800., SIDEBAR_WIDTH, false, None, false), 0.); + assert_eq!(discord_overview_width(minimum, SIDEBAR_WIDTH, false, Some(RIGHT_WIDTH), true), 0.); + let window = minimum + RIGHT_WIDTH; + let overview = discord_overview_width(window, SIDEBAR_WIDTH, false, Some(RIGHT_WIDTH), true); + let planned = plan(window - overview, SIDEBAR_WIDTH, false, Some(RIGHT_WIDTH)); + assert_eq!(planned.right, Some((RIGHT_WIDTH, false))); + assert_eq!(conversation_width(window - overview, &planned), CONVERSATION_MIN); + } + #[test] fn the_conversation_gives_up_width_then_the_right_pane_replaces_it_then_the_sidebar_leaves() { // Room for everything. diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index 49376ff..33090a7 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -302,7 +302,9 @@ impl Shell { } let state = &self.sidebar_state; state.inbox_list.scroll_to(gpui::ListOffset::default()); - state.home_list.scroll_to(gpui::ListOffset::default()); + if !matches!(&state.mode, Mode::Space(space) if space.network == Network::Discord) { + state.home_list.scroll_to(gpui::ListOffset::default()); + } state.tree_scroll.scroll_to_item(0, ScrollStrategy::Top); cx.notify(); return; @@ -320,13 +322,20 @@ impl Shell { state.inbox.fill(epoch, fresh, total); } } - let home_network = mode.network().filter(|_| matches!(mode, Mode::Network(_))); + let keep_discord = state.mode.network() == Some(Network::Discord) && mode.network() == Some(Network::Discord); + let home_network = match &mode { + Mode::Space(space) if space.network == Network::Discord => Some(Network::Discord), + Mode::Network(network) => Some(*network), + _ => None, + }; state.home.switch_network(home_network, &mut state.saved_homes); state.mode = mode; state.cursor = None; state.drill = None; state.slack_menu = false; - state.home_unread = false; + if !keep_discord { + state.home_unread = false; + } state.apps_open = false; state.apps_all = false; state.pill = 0; @@ -680,6 +689,9 @@ impl Shell { /// Opens a row the user chose by hand: the unread walk starts over from there. fn pick_row(&mut self, row: &Conversation, cx: &mut Context) { self.sidebar_state.walked.clear(); + if row.key.network == Network::Discord && row.kind.is_direct() && matches!(&self.sidebar_state.mode, Mode::Space(space) if space.network == Network::Discord) { + self.go(Mode::Network(Network::Discord), cx); + } if row.kind == ConversationKind::Forum && row.key.network == Network::Telegram { self.drill(home::Drill::Topics(Rc::new(row.clone())), cx); return; @@ -842,6 +854,10 @@ impl Shell { /// The people whose presence these rows draw, and the rows themselves for their previews. fn watch_rows<'a>(&self, rows: impl Iterator> + Clone, cx: &mut Context) { let hub = self.people_state.hub(); + let home = &self.sidebar_state.home_items; + let visible = on_screen(&self.sidebar_state.home_list, home.len(), |ix| home[ix].height()); + let extra = home[visible].iter().filter_map(home::Item::conversation).filter(|_| matches!(&self.sidebar_state.mode, Mode::Space(space) if space.network == Network::Discord)); + let rows = rows.cloned().chain(extra.cloned()); let peers = rows.clone().filter_map(|c| Some(Key { id: c.peer.clone()?, ..c.key.clone() })); hub.watch(WATCH, peers, cx); hub.watch_rows(WATCH, rows.map(|c| c.key.clone()), cx); @@ -1048,7 +1064,7 @@ impl Shell { out } - fn title_strip(&self, cx: &mut Context) -> Div { + fn title_strip(&self, mode: &Mode, cx: &mut Context) -> Div { let strip = drags_window(div()).h(px(theme::STRIP)).flex_none().pl(px(theme::PANE_PAD)).pr(px(12.)).flex().items_center().gap(px(8.)); let quiet = |button: Stateful
| button.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()); let back = |id: &'static str, words: String, cx: &mut Context| { @@ -1073,7 +1089,7 @@ impl Shell { .child(pane_title(title)) .children(place.map(|place| div().flex_none().max_w(px(120.)).truncate().type_small().text_color(theme::text_faint()).child(place))); } - match &self.sidebar_state.mode { + match mode { Mode::Inbox => { let filter = self.sidebar_state.filter; let counts = self.sidebar_state.counts; @@ -1333,9 +1349,22 @@ impl Shell { } } + /// The persistent Discord parent column, using the same home list and scroll state. + pub(super) fn discord_overview(&self, snapshot: &Snapshot, width: f32, window: &mut Window, cx: &mut Context) -> Div { + self.sidebar_state.width.set(width); + div().w(px(width)).h_full().flex_none().bg(theme::sidebar()).border_r_1().border_color(theme::hairline()).child( + div().id("discord-overview").size_full().flex().flex_col() + .child(self.title_strip(&Mode::Network(Network::Discord), cx)) + .child(self.jump_strip(cx)) + .child(div().flex_1().min_h_0().child(self.home_list(snapshot, window, cx))) + ) + } + pub(super) fn sidebar(&self, snapshot: &Snapshot, width: f32, window: &mut Window, cx: &mut Context) -> Div { let people = self.sidebar_state.mode == Mode::People; - self.sidebar_state.width.set(width); + if !matches!(&self.sidebar_state.mode, Mode::Space(space) if space.network == Network::Discord) { + self.sidebar_state.width.set(width); + } let body = if people { let drawn = self.slots.people.and_then(|draw| draw(self, window, cx)); drawn.unwrap_or_else(|| empty_state("icons/users.svg", "People", "Friends and contacts show up here.").px(px(24.)).into_any_element()) @@ -1354,7 +1383,7 @@ impl Shell { .bg(theme::sidebar()) .border_r_1() .border_color(theme::hairline()) - .child(self.title_strip(cx)) + .child(self.title_strip(&self.sidebar_state.mode, cx)) .when(!people, |sidebar| sidebar.child(self.jump_strip(cx))) .children(tabs) .when(!banners.is_empty(), |sidebar| sidebar.child(div().flex_none().px(px(theme::INSET)).pb(px(8.)).flex().flex_col().gap(px(4.)).children(banners))) diff --git a/crates/tinyclient/src/gui/sidebar/home_view.rs b/crates/tinyclient/src/gui/sidebar/home_view.rs index 02f21b0..1e303e1 100644 --- a/crates/tinyclient/src/gui/sidebar/home_view.rs +++ b/crates/tinyclient/src/gui/sidebar/home_view.rs @@ -52,8 +52,10 @@ fn whole(network: Network, folder: Option, shelf: Shelf) -> std::io::Res impl Shell { /// Reads what the home on show draws. Asking is also what makes chatd bring it up to date. pub(in crate::gui) fn load_home(&mut self, cx: &mut Context) { - let Mode::Network(network) = self.sidebar_state.mode else { - return; + let network = match &self.sidebar_state.mode { + Mode::Network(network) => *network, + Mode::Space(space) if space.network == Network::Discord => Network::Discord, + _ => return, }; // Keys only: the read runs off this thread. let drill = self.sidebar_state.drill.as_ref().map(|drill| match drill { @@ -156,9 +158,12 @@ impl Shell { let state = &self.sidebar_state; let items: Vec = match (&state.mode, &state.drill) { (Mode::Network(_), Some(_)) => state.home.drilled.iter().cloned().map(Item::Chat).collect(), - (Mode::Network(Network::Discord), None) => { + (Mode::Network(Network::Discord), None) | (Mode::Space(Key { network: Network::Discord, .. }), None) => { let spaces: Vec = self.spaces.iter().filter(|s| s.key.network == Network::Discord).cloned().collect(); - let open_space = self.snapshot.as_ref().and_then(|s| s.open.as_ref()).and_then(|open| open.space.clone()); + let open_space = match &state.mode { + Mode::Space(space) => Some(space.id.clone()), + _ => self.snapshot.as_ref().and_then(|s| s.open.as_ref()).and_then(|open| open.space.clone()), + }; let items = home::discord(&state.home, &spaces, open_space.as_deref(), state.home_unread); // The servers' indexes are into the shell's whole list of Spaces. items @@ -182,7 +187,7 @@ impl Shell { } _ => Vec::new(), }; - let news = if matches!(self.sidebar_state.mode, Mode::Network(Network::Discord)) && self.sidebar_state.drill.is_none() { + let news = if self.sidebar_state.mode.network() == Some(Network::Discord) && self.sidebar_state.drill.is_none() { let state = &self.sidebar_state; let spaces: Vec = self.spaces.iter().filter(|s| s.key.network == Network::Discord).cloned().collect(); home::discord(&state.home, &spaces, None, true).iter().filter(|item| matches!(item, Item::Chat(_) | Item::Space { .. } | Item::Folder { .. })).count() as u32 @@ -212,7 +217,12 @@ impl Shell { self.people_scope(Some(Network::Discord), cx); return; } - Act::Requests => return self.drill(Drill::Requests, cx), + Act::Requests => { + if matches!(self.sidebar_state.mode, Mode::Space(_)) { + self.go(Mode::Network(Network::Discord), cx); + } + return self.drill(Drill::Requests, cx); + }, Act::Archive => return self.drill(Drill::Archive, cx), Act::MoreDms => home.dms_all = true, Act::MoreVoice => home.voice_all = true, @@ -249,7 +259,7 @@ impl Shell { let Some(item) = self.sidebar_state.home_items.get(ix).cloned() else { return div().into_any_element(); }; - let focused = self.sidebar_state.cursor == Some(ix) && (self.sidebar_state.focus.is_focused(window) || self.focus.is_focused(window)); + let focused = self.sidebar_state.listed() && self.sidebar_state.cursor == Some(ix) && (self.sidebar_state.focus.is_focused(window) || self.focus.is_focused(window)); let network = self.sidebar_state.home.network; let click = move |row: Stateful
, cx: &mut Context| row.on_mouse_down(MouseButton::Left, cx.listener(move |this, _, _, cx| this.activate_home(ix, cx))); match item { @@ -306,7 +316,7 @@ impl Shell { return list_row(("space-gone", ix), theme::row_height(theme::ROW_ONE), false, false, div()); }; let lit = space.attention > 0 || space.unread; - let here = self.open.as_ref().is_some_and(|open| self.snapshot.as_ref().and_then(|s| s.open.as_ref()).is_some_and(|row| row.key == *open && row.space.as_deref() == Some(space.key.id.as_str()))); + let here = if let Mode::Space(selected) = &self.sidebar_state.mode { selected == &space.key } else { self.open.as_ref().is_some_and(|open| self.snapshot.as_ref().and_then(|s| s.open.as_ref()).is_some_and(|row| row.key == *open && row.space.as_deref() == Some(space.key.id.as_str()))) }; let icon = self.media_path(&space.key, space.icon.as_ref()); let color = if space.muted { theme::text_faint() } else if lit { theme::text() } else { theme::text_soft() }; // A team's chevron sits in the tree's 16 slot, so its name lines up with its channels'. diff --git a/crates/tinyclient/src/gui/state.rs b/crates/tinyclient/src/gui/state.rs index 46deabe..668deb7 100644 --- a/crates/tinyclient/src/gui/state.rs +++ b/crates/tinyclient/src/gui/state.rs @@ -426,7 +426,7 @@ impl Shell { let mut directs = false; for key in dirty.rows { let in_tree = self.trees.values().any(|tree| tree.conversations.iter().any(|c| c.key == key)); - directs |= !in_tree && matches!(self.sidebar_state.mode, Mode::Network(network) if network == key.network); + directs |= !in_tree && self.sidebar_state.home.network == Some(key.network); if in_tree || self.open.as_ref() == Some(&key) { self.load_row(key, cx); } diff --git a/docs/design/polish/02-discord-slack.md b/docs/design/polish/02-discord-slack.md index 2ef3440..7295bfe 100644 --- a/docs/design/polish/02-discord-slack.md +++ b/docs/design/polish/02-discord-slack.md @@ -93,5 +93,23 @@ For reconciliation with `polish/01-system.md`: `LEAD 20` (leading box of every 2 - **Hover swaps** happen inside a trailing slot of fixed width so the name never re-truncates. Presence and the group's front face take `theme::over(fill, sidebar)` as their ring per state (#121315, #202123, #2E2F31). - The private-channel lock and the spine are child divs; no masks, no blur. Typing dots are the only timer, in their own entity. + +### Discord server columns + +At wide window sizes, selecting a Discord server keeps the Discord overview +visible in a 280 pt column and opens its channels in the regular sidebar beside +it. The overview retains DMs, folders, expansion state and scroll position; +selecting another server replaces only the channels column and highlights that +server immediately. Choosing a DM returns to the Discord home and hides the +channels column. Message requests likewise return to the home before drilling +into their list. Home data continues refreshing while a server is selected. + +The extra column appears only when the rail, both lists, the 392 pt minimum +conversation and any visible right pane fit. Otherwise the original single +sidebar and back navigation apply. The existing sidebar resize handle changes +the channels width; the overview width is fixed. Other networks keep their +existing layout. + + ## Deliberately absent A server-icon column; chevrons and inline trees on server rows; banners, boosts, member and online counts; role colours; "@" in mention badges; a grey muted-mention look; mute bells on rows; elapsed timers and rich-presence art; time on Discord DM rows at rest; a read-only mark; unread-first sorting; prefix grouping; Slack's Unreads, Activity, Later, Huddles and Canvases views (Open in Slack); total reply count in a Threads row (in the pane header); any animation of rows filling in.