From 48282aade3b9c9c03e937d2d6595a1b17c7e7919 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:57:03 +0000 Subject: [PATCH 1/2] feat(desktop-gpui): glass library and target tiles, native device and format lists --- apps/desktop-gpui/src/main_window.rs | 790 ++++++++++++++------- apps/desktop-gpui/src/target_thumbnails.rs | 31 +- 2 files changed, 534 insertions(+), 287 deletions(-) diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index c2d663122d6..49ee93326fc 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -210,6 +210,32 @@ fn glass_tint(theme: Theme, light: f32, dark: f32) -> Hsla { } } +/// A grouped list, the way macOS lays out a choice: one panel, rows split by +/// hairlines that start `inset` in, where the text does. +fn grouped_list(theme: Theme, rows: Vec, inset: f32) -> gpui::Div { + let count = rows.len(); + let mut group = div() + .flex() + .flex_col() + .w_full() + .p(px(4.)) + .rounded(px(10.)) + .map(|this| glass_button(this, theme, false)); + for (index, row) in rows.into_iter().enumerate() { + group = group.child(row); + if index + 1 < count { + group = group.child( + div() + .h(px(1.)) + .ml(px(inset)) + .mr(px(8.)) + .bg(glass_tint(theme, 0.08, 0.1)), + ); + } + } + group +} + fn glass_hover(theme: Theme) -> Hsla { glass_tint(theme, 0.06, 0.07) } @@ -218,10 +244,6 @@ fn glass_press(theme: Theme) -> Hsla { glass_tint(theme, 0.1, 0.11) } -fn glass_selected(theme: Theme) -> Hsla { - glass_tint(theme, 0.08, 0.1) -} - fn shadow(color: Hsla, y: f32, blur: f32, spread: f32, inset: bool) -> gpui::BoxShadow { gpui::BoxShadow { color, @@ -253,6 +275,116 @@ fn glass_button(element: E, theme: Theme, selected: bool) -> E { element.bg(fill).border_1().border_color(edge) } +const LIBRARY_TILE_RADIUS: f32 = 12.; +const LIBRARY_THUMB_RADIUS: f32 = 8.; +const LIBRARY_THUMB_HEIGHT: f32 = 84.; + +/// A recording or screenshot tile, in the capture tiles' glass: the same +/// fill and hairline, with its preview inset in it like a framed picture. +fn library_tile(tile: gpui::Stateful, theme: Theme) -> gpui::Stateful { + tile.flex() + .flex_col() + .flex_1() + .min_w_0() + .gap(px(2.)) + .p(px(4.)) + .rounded(px(LIBRARY_TILE_RADIUS)) + .map(|this| glass_button(this, theme, false)) + .hover(move |style| style.bg(library_tile_hover(theme))) +} + +fn library_tile_hover(theme: Theme) -> Hsla { + glass_tint(theme, 0.08, 0.12) +} + +fn library_thumb_frame(theme: Theme) -> gpui::Div { + div() + .relative() + .w_full() + .h(px(LIBRARY_THUMB_HEIGHT)) + .rounded(px(LIBRARY_THUMB_RADIUS)) + .overflow_hidden() + .bg(glass_tint(theme, 0.04, 0.08)) +} + +/// A hairline over the preview's edge, drawn after it so a light or dark +/// picture still reads as its own panel. +fn library_thumb_rim(theme: Theme) -> gpui::Div { + div() + .absolute() + .inset_0() + .rounded(px(LIBRARY_THUMB_RADIUS)) + .border_1() + .border_color(glass_tint(theme, 0.08, 0.1)) +} + +fn library_placeholder(theme: Theme, icon: &'static str) -> gpui::Div { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .bg(gpui::linear_gradient( + 160., + gpui::linear_color_stop(glass_tint(theme, 0.02, 0.04), 0.), + gpui::linear_color_stop(glass_tint(theme, 0.07, 0.11), 1.), + )) + .child( + div() + .size(px(34.)) + .rounded_full() + .flex() + .items_center() + .justify_center() + .bg(glass_tint(theme, 0.05, 0.1)) + .child(svg().path(icon).size(px(16.)).text_color(theme.gray_10)), + ) +} + +fn library_badge(label: impl Into) -> gpui::Div { + div() + .absolute() + .left(px(6.)) + .top(px(6.)) + .rounded_full() + .bg(black_alpha(0.5)) + .px(px(7.)) + .py(px(2.)) + .text_size(px(10.)) + .font_weight(FontWeight::MEDIUM) + .text_color(gpui::white()) + .child(label.into()) +} + +fn library_caption(theme: Theme, label: String, subtitle: Option) -> gpui::Div { + div() + .flex() + .flex_col() + .w_full() + .min_w_0() + .gap(px(1.)) + .px(px(4.)) + .pt(px(6.)) + .pb(px(2.)) + .child( + div() + .w_full() + .truncate() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray_12) + .child(label), + ) + .children(subtitle.map(|subtitle| { + div() + .w_full() + .truncate() + .text_size(px(11.)) + .text_color(theme.gray_10) + .child(subtitle) + })) +} + fn capture_hover_fill(theme: Theme, selected: bool) -> Hsla { if selected { Theme::with_alpha(theme.blue_9, 0.06) @@ -534,6 +666,32 @@ enum DeviceFormat { } impl DeviceFormat { + /// The label split for a list row: what it is, then its rate. + fn parts(self) -> (String, Option) { + match self { + Self::Camera(settings) => { + match (settings.width, settings.height, settings.frame_rate) { + (Some(width), Some(height), Some(rate)) => ( + format!("{width} × {height}"), + Some(format!("{rate:.0} fps")), + ), + _ => (tr::main::device::format::default().into(), None), + } + } + Self::Microphone(settings) => match (settings.sample_rate, settings.channels) { + (Some(rate), Some(channels)) => { + let channels: String = match channels { + 1 => tr::main::device::format::mono().into(), + 2 => tr::main::device::format::stereo().into(), + count => tr::main::device::format::channels(usize::from(count)), + }; + (format!("{} kHz", rate as f32 / 1000.), Some(channels)) + } + _ => (tr::main::device::format::default().into(), None), + }, + } + } + fn label(self) -> String { match self { Self::Camera(settings) => { @@ -1209,8 +1367,8 @@ impl MainWindow { this.set_microphone_selection(Some(microphone), cx); } } - // `CAP_GPUI_AUTO_PANEL=display|window`: open that target - // picker panel the way the chevron click does. Same reason as + // `CAP_GPUI_AUTO_PANEL=display|window|camera|microphone| + // recordings|screenshots`: open that panel the way a click does. Same reason as // `CAP_GPUI_AUTO_CAMERA` above -- the chrome screenshots need // the card grid on screen and synthetic clicks are dropped. match std::env::var("CAP_GPUI_AUTO_PANEL").as_deref() { @@ -1222,6 +1380,12 @@ impl MainWindow { Ok("microphone") => { this.open_panel(Panel::Device(DeviceMenu::Microphone), window, cx) } + Ok("recordings") => { + this.open_panel(Panel::Library(LibraryKind::Recordings), window, cx) + } + Ok("screenshots") => { + this.open_panel(Panel::Library(LibraryKind::Screenshots), window, cx) + } _ => {} } // `if (!targetMode) scheduleTargetListPrewarm()` on @@ -4547,20 +4711,36 @@ impl MainWindow { div() .id("library-view-all") .flex() + .flex_col() .flex_1() .min_w_0() - .h(px(76.)) + .min_h(px(LIBRARY_THUMB_HEIGHT)) .items_center() .justify_center() - .rounded(px(8.)) - .border_1() - .border_color(theme.body_border(5)) - .bg(theme.body_fill(3)) + .gap(px(8.)) + .rounded(px(LIBRARY_TILE_RADIUS)) + .map(|this| glass_button(this, theme, false)) .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .text_color(theme.gray_12) .cursor_pointer() - .hover(|style| style.bg(theme.body_hover_fill(4))) + .hover(move |style| style.bg(library_tile_hover(theme))) + .active(move |style| style.bg(glass_press(theme))) + .child( + div() + .size(px(30.)) + .rounded_full() + .flex() + .items_center() + .justify_center() + .bg(glass_tint(theme, 0.05, 0.1)) + .child( + svg() + .path("icons/chevron-right.svg") + .size(px(14.)) + .text_color(theme.gray_11), + ), + ) .child(view_all) .on_click(cx.listener(move |_, _, _window, cx| { cx.defer(move |cx| app_windows::open_settings(page, cx)); @@ -4653,68 +4833,22 @@ impl MainWindow { } }; - div() - .id(("library-importing", index)) - .flex() - .flex_col() - .flex_1() - .min_w_0() - .overflow_hidden() - .rounded(px(8.)) - .bg(theme.body_fill(3)) + library_tile(div().id(("library-importing", index)), theme) .child( - div() - .relative() - .w_full() - .h(px(76.)) - .rounded_t(px(8.)) - .overflow_hidden() - .bg(theme.body_fill(4)) + library_thumb_frame(theme) .flex() .items_center() .justify_center() + .bg(glass_tint(theme, 0.03, 0.06)) .child(ring) - .child( - div() - .absolute() - .left(px(4.)) - .top(px(4.)) - .rounded_full() - .bg(black_alpha(0.55)) - .px(px(6.)) - .py(px(2.)) - .text_size(px(10.)) - .font_weight(FontWeight::MEDIUM) - .text_color(gpui::white()) - .child(tr::main::library::importing()), - ), - ) - .child( - div() - .flex() - .flex_col() - .w_full() - .min_w_0() - .px(px(8.)) - .py(px(6.)) - .pb(px(10.)) - .text_size(px(11.)) - .child( - div() - .w_full() - .truncate() - .font_weight(FontWeight::MEDIUM) - .text_color(theme.gray_12) - .child(entry.pretty_name.clone()), - ) - .child( - div() - .w_full() - .truncate() - .text_color(theme.gray_11) - .child(entry.message.clone()), - ), + .child(library_thumb_rim(theme)) + .child(library_badge(tr::main::library::importing())), ) + .child(library_caption( + theme, + entry.pretty_name.clone(), + Some(entry.message.clone()), + )) } fn render_recording_card( @@ -4775,14 +4909,13 @@ impl MainWindow { .flex_row() .items_center() .justify_between() - .px(px(8.)) - .pb(px(6.)) - .gap(px(4.)); + .gap(px(2.)); if item.opens_editor() { let editor = path.clone(); row = row.child(self.library_action( "lib-rec-edit", "icons/edit.svg", + tr::settings::recordings::edit(), cx.listener(move |_, _, _window, cx| { let editor = editor.clone(); cx.defer(move |cx| app_windows::open_editor(editor, cx)); @@ -4793,6 +4926,7 @@ impl MainWindow { row = row.child(self.library_action( "lib-rec-link", "icons/link.svg", + tr::settings::recordings::open_link(), move |_, _, cx| cx.open_url(&url), )); } @@ -4805,6 +4939,7 @@ impl MainWindow { row = row.child(self.library_action( "lib-rec-retry", "icons/rotate-ccw.svg", + tr::settings::recordings::retry_upload(), cx.listener(move |_, _, window, cx| { let path = retry_path.clone(); cx.spawn_in(window, async move |this, cx| { @@ -4830,11 +4965,13 @@ impl MainWindow { row = row.child(self.library_action( "lib-rec-folder", "icons/folder.svg", + tr::settings::screenshots::open_folder(), move |_, _, cx| library::open_recording_folder(&folder, mode, cx), )); row.child(self.library_action( "lib-rec-delete", "icons/trash.svg", + tr::settings::recordings::delete(), cx.listener(move |this, _, window, cx| { this.delete_library_recording(path.clone(), window, cx); }), @@ -4879,39 +5016,49 @@ impl MainWindow { .flex_row() .items_center() .justify_between() - .px(px(8.)) - .pb(px(6.)) - .gap(px(4.)) - .child(self.library_action("lib-ss-copy", "icons/copy.svg", { - let png = png.clone(); - move |_, _, cx| { - let copied = crate::platform::copy_image_to_clipboard(png.clone(), cx); - cx.spawn(async move |_| { - if let Err(error) = copied.await { - tracing::warn!("copying screenshot failed: {error}"); - crate::platform::alert_dialog( - tr::main::library::copy_screenshot_failed(), - &error, - ); - } - }) - .detach(); - } - })) + .gap(px(2.)) + .child(self.library_action( + "lib-ss-copy", + "icons/copy.svg", + tr::settings::screenshots::copy(), + { + let png = png.clone(); + move |_, _, cx| { + let copied = crate::platform::copy_image_to_clipboard(png.clone(), cx); + cx.spawn(async move |_| { + if let Err(error) = copied.await { + tracing::warn!("copying screenshot failed: {error}"); + crate::platform::alert_dialog( + tr::main::library::copy_screenshot_failed(), + &error, + ); + } + }) + .detach(); + } + }, + )) .child(self.library_action( "lib-ss-save", "icons/download.svg", + tr::settings::screenshots::save_as(), cx.listener(move |this, _, window, cx| { this.save_screenshot(png.clone(), name.clone(), window, cx); }), )) - .child(self.library_action("lib-ss-open", "icons/folder.svg", { - let bundle = bundle.clone(); - move |_, _, cx| library::reveal_path(&bundle, cx) - })) + .child(self.library_action( + "lib-ss-open", + "icons/folder.svg", + tr::settings::screenshots::open_folder(), + { + let bundle = bundle.clone(); + move |_, _, cx| library::reveal_path(&bundle, cx) + }, + )) .child(self.library_action( "lib-ss-delete", "icons/trash.svg", + tr::settings::screenshots::delete(), cx.listener(move |this, _, window, cx| { this.delete_library_screenshot(bundle.clone(), window, cx); }), @@ -4922,20 +5069,25 @@ impl MainWindow { &self, id: impl Into, icon: &'static str, + label: &'static str, on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, ) -> impl IntoElement { let theme = self.theme; div() .id(id) + .role(gpui::Role::Button) + .aria_label(label) + .tooltip(move |_, cx| ui::Tooltip::new(&theme, label).view(cx)) + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) .flex_1() + .h(px(26.)) .flex() .items_center() .justify_center() - .p(px(4.)) - .rounded(px(4.)) - .text_color(theme.gray_11) + .rounded(px(8.)) .cursor_pointer() - .hover(|style| style.bg(theme.body_hover_fill(5)).text_color(theme.gray_12)) + .hover(move |style| style.bg(glass_hover(theme))) + .active(move |style| style.bg(glass_press(theme))) .child(svg().path(icon).size(px(14.)).text_color(theme.gray_11)) .on_click(on_click) } @@ -4958,86 +5110,31 @@ impl MainWindow { use gpui::StyledImage as _; img(image) .size_full() - .rounded_t(px(8.)) + .rounded(px(LIBRARY_THUMB_RADIUS)) .object_fit(gpui::ObjectFit::Cover) .into_any_element() } - None => div() - .rounded_t(px(8.)) - .flex() - .size_full() - .items_center() - .justify_center() - .bg(theme.body_fill(4)) - .child(svg().path(fallback).size(px(24.)).text_color(theme.gray_9)) - .into_any_element(), + None => library_placeholder(theme, fallback).into_any_element(), }; - div() - .id(id) - .flex() - .flex_col() - .flex_1() - .min_w_0() - .overflow_hidden() - .rounded(px(8.)) - .bg(theme.body_fill(3)) - .hover(|style| style.bg(theme.body_hover_fill(4))) + library_tile(div().id(id), theme) .child( div() .id("library-card-open") .cursor_pointer() + .flex() + .flex_col() .child( - div() - .relative() - .w_full() - .h(px(76.)) - .rounded_t(px(8.)) - .overflow_hidden() - .bg(theme.body_fill(4)) + library_thumb_frame(theme) .child(thumb) + .child(library_thumb_rim(theme)) .when(clip_count > 1, |this| { - this.child( - div() - .absolute() - .left(px(4.)) - .top(px(4.)) - .rounded_full() - .bg(black_alpha(0.55)) - .px(px(6.)) - .py(px(2.)) - .text_size(px(10.)) - .font_weight(FontWeight::MEDIUM) - .text_color(gpui::white()) - .child(tr::main::library::clips(clip_count as usize)), - ) + this.child(library_badge(tr::main::library::clips( + clip_count as usize, + ))) }), ) - .child( - div() - .flex() - .flex_col() - .w_full() - .min_w_0() - .px(px(8.)) - .py(px(6.)) - .text_size(px(11.)) - .child( - div() - .w_full() - .truncate() - .font_weight(FontWeight::MEDIUM) - .text_color(theme.gray_12) - .child(label), - ) - .children(subtitle.map(|subtitle| { - div() - .w_full() - .truncate() - .text_color(theme.gray_11) - .child(subtitle) - })), - ) + .child(library_caption(theme, label, subtitle)) .on_click(on_click), ) .child(actions) @@ -5250,17 +5347,28 @@ impl MainWindow { SharedString::from(format!("camera-{}", camera.device_id)), menu.icon(), camera.label.clone(), - if selected { - Feeds::global(cx) - .read(cx) - .applied_device_settings() - .camera - .map(|settings| DeviceFormat::Camera(settings).label()) - } else { - camera.best_format.map(|format| { - tr::main::device::format::available(format.describe()) + // The applied format lands a moment after + // the camera opens; until then the row + // keeps its available line, so it never + // changes height. + selected + .then(|| { + Feeds::global(cx) + .read(cx) + .applied_device_settings() + .camera + .map(|settings| { + DeviceFormat::Camera(settings).label() + }) }) - }, + .flatten() + .or_else(|| { + camera.best_format.map(|format| { + tr::main::device::format::available( + format.describe(), + ) + }) + }), selected, None, cx.listener(move |this, _, _window, cx| { @@ -5308,17 +5416,20 @@ impl MainWindow { SharedString::from(format!("mic-{}", mic.name)), menu.icon(), mic.name.clone(), - if selected { - Feeds::global(cx) - .read(cx) - .applied_device_settings() - .microphone - .map(|settings| { - DeviceFormat::Microphone(settings).label() - }) - } else { - mic.describe().map(tr::main::device::format::available) - }, + selected + .then(|| { + Feeds::global(cx) + .read(cx) + .applied_device_settings() + .microphone + .map(|settings| { + DeviceFormat::Microphone(settings).label() + }) + }) + .flatten() + .or_else(|| { + mic.describe().map(tr::main::device::format::available) + }), selected, selected.then(|| self.device_microphone_level.clone()), cx.listener(move |this, _, _window, cx| { @@ -5343,11 +5454,12 @@ impl MainWindow { } } + let list = list.child(grouped_list(theme, rows, 34.)); if matched == 0 { - rows.push(self.render_empty_state(menu.empty_message(!self.search.is_empty()))); + list.child(self.render_empty_state(menu.empty_message(!self.search.is_empty()))) + } else { + list } - - list.children(rows) } fn open_device_formats(&mut self, target: DeviceFormatTarget, cx: &mut Context) { @@ -5561,51 +5673,200 @@ impl MainWindow { } } }); - let list = div() + let device = self + .device_format_target + .as_ref() + .map(|target| match target { + DeviceFormatTarget::Camera(camera) => camera_label(&camera.label), + DeviceFormatTarget::Microphone(name) => name.clone(), + }); + let caption = |text: SharedString, color: Hsla| { + div() + .px(px(4.)) + .text_size(px(12.)) + .line_height(px(16.)) + .text_color(color) + .child(text) + }; + + let rows: Vec = match &self.device_formats { + // Placeholders the height of real rows, so the list does not jump + // when the formats arrive. + None => (0..3usize) + .map(|index| { + div() + .flex() + .items_center() + .justify_between() + .px(px(8.)) + .min_h(px(36.)) + .child( + div() + .w(px(88. - index as f32 * 12.)) + .h(px(10.)) + .rounded_full() + .bg(glass_tint(theme, 0.08, 0.12)), + ) + .child( + div() + .w(px(36.)) + .h(px(10.)) + .rounded_full() + .bg(glass_tint(theme, 0.05, 0.08)), + ) + .into_any_element() + }) + .collect(), + Some(Err(_)) => Vec::new(), + Some(Ok(formats)) => formats + .iter() + .enumerate() + .map(|(index, format)| { + let format = *format; + self.render_device_format_row( + index, + format, + self.device_format_value == Some(format), + applied == Some(format), + cx, + ) + .into_any_element() + }) + .collect(), + }; + + div() .flex() .flex_col() - .gap(px(4.)) + .gap(px(8.)) .w_full() .child( div() - .text_size(px(12.)) - .text_color(theme.gray_11) - .pb(px(8.)) - .child(tr::main::device::format::preferred()), + .flex() + .flex_row() + .items_center() + .gap(px(10.)) + .px(px(4.)) + .child( + div() + .size(px(32.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .rounded(px(8.)) + .map(|this| glass_button(this, theme, false)) + .child( + svg() + .path(menu.icon()) + .size(px(16.)) + .text_color(theme.gray_11), + ), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap(px(1.)) + .children(device.map(|device| { + div() + .truncate() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray_12) + .child(device) + })) + .child( + div() + .truncate() + .text_size(px(11.)) + .text_color(theme.gray_10) + .child(match applied { + Some(format) => { + tr::main::device::format::current(format.label()) + } + None => tr::main::device::format::preferred().to_string(), + }), + ), + ), ) - .when_some(applied, |list, format| { - list.child( - div() - .text_size(px(12.)) - .text_color(theme.gray_11) - .pb(px(8.)) - .child(tr::main::device::format::current(format.label())), + .when(!rows.is_empty(), |this| { + this.child(grouped_list(theme, rows, 8.)) + }) + .when_some( + self.device_formats + .as_ref() + .and_then(|formats| formats.as_ref().err()), + |this, error| this.child(caption(error.clone().into(), theme.amber_11.into())), + ) + .when(self.device_format_pending.is_some(), |this| { + this.child(caption( + tr::main::device::format::applying().into(), + theme.gray_10.into(), + )) + }) + .when_some(self.device_format_notice.as_ref(), |this, notice| { + this.child(caption(notice.clone().into(), theme.amber_11.into())) + }) + } + + /// One format in the grouped list: its size or rate, the rest muted on + /// the right, and a check on the one chosen. + fn render_device_format_row( + &self, + index: usize, + format: DeviceFormat, + chosen: bool, + applied: bool, + cx: &mut Context, + ) -> gpui::Stateful { + let theme = self.theme; + let (primary, secondary) = format.parts(); + div() + .id(("device-format", index)) + .when(self.keyboard_nav, ui::keyboard_nav::stop) + .role(gpui::Role::Button) + .aria_label(format.label()) + .aria_selected(chosen) + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .px(px(8.)) + .min_h(px(36.)) + .w_full() + .rounded(px(6.)) + .cursor_pointer() + .hover(move |style| style.bg(glass_hover(theme))) + .active(move |style| style.bg(glass_press(theme))) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(13.)) + .text_color(theme.gray_12) + .when(applied, |this| this.font_weight(FontWeight::MEDIUM)) + .child(primary), + ) + .children(secondary.map(|secondary| { + div() + .flex_shrink_0() + .text_size(px(12.)) + .text_color(theme.gray_10) + .child(secondary) + })) + .child(div().size(px(14.)).flex_shrink_0().when(chosen, |this| { + this.child( + svg() + .path("icons/check.svg") + .size(px(14.)) + .text_color(theme.blue_500), ) - }); - let list = match &self.device_formats { - None => list.child(self.render_empty_state(tr::main::device::format::loading())), - Some(Err(error)) => list.child(self.render_empty_state(error.clone())), - Some(Ok(formats)) => { - list.children(formats.iter().enumerate().map(|(index, format)| { - let format = *format; - self.render_device_list_row( - SharedString::from(format!("device-format-{index}")), - menu.icon(), - format.label(), - None, - self.device_format_value == Some(format), - None, - cx.listener(move |this, _, _, cx| this.choose_device_format(format, cx)), - ) - })) - } - }; - list.when(self.device_format_pending.is_some(), |list| { - list.child(self.render_empty_state(tr::main::device::format::applying())) - }) - .when_some(self.device_format_notice.as_ref(), |list, notice| { - list.child(self.render_empty_state(notice.clone())) - }) + })) + .on_click(cx.listener(move |this, _, _, cx| this.choose_device_format(format, cx))) } fn render_device_format_button( @@ -5626,7 +5887,7 @@ impl MainWindow { .justify_center() .w(px(32.)) .flex_shrink_0() - .rounded(px(10.)) + .rounded(px(9.)) .when(enabled, |button| { button .cursor_pointer() @@ -5808,49 +6069,31 @@ impl MainWindow { .w(px(self.target_card_width())) .flex_none() .min_w_0() - .overflow_hidden() - .rounded(px(8.)) - .border_1() - .border_color(if selected { - Hsla::from(theme.blue_8) - } else { - gpui::transparent_black() + .gap(px(2.)) + .p(px(4.)) + .cursor_pointer() + .rounded(px(LIBRARY_TILE_RADIUS)) + .map(|this| glass_button(this, theme, selected)) + .when(selected, |this| this.border_color(theme.blue_8)) + .hover(move |style| { + style.bg(if selected { + Theme::with_alpha(theme.blue_9, 0.2) + } else { + library_tile_hover(theme) + }) }) - .bg(theme.body_fill(3)) + .active(move |style| style.bg(capture_press_fill(theme, selected))) .child(target_thumbnails::render_thumbnail_slot(thumb, icon, theme)) .child( - div() - .flex() - .flex_col() - .w_full() - .min_w_0() - .px(px(8.)) - .py(px(6.)) - .text_size(px(11.)) - .child( - div() - .w_full() - .truncate() - .font_weight(FontWeight::MEDIUM) - .text_color(theme.gray_12) - .child(label), - ) - .children(subtitle.map(|subtitle| { - div() - .w_full() - .truncate() - .text_color(theme.gray_11) - .child(subtitle) - })) - .children(metadata.map(|metadata| { - div() - .w_full() - .truncate() - .text_color(theme.gray_10) - .child(metadata) - })), + library_caption(theme, label, subtitle).children(metadata.map(|metadata| { + div() + .w_full() + .truncate() + .text_size(px(11.)) + .text_color(theme.gray_10) + .child(metadata) + })), ) - .hover(|style| style.bg(theme.body_hover_fill(4))) .on_click(on_click) } @@ -6056,17 +6299,14 @@ impl MainWindow { .flex_row() .items_center() .gap(px(10.)) - .px(px(10.)) - .py(px(7.)) - .min_h(px(40.)) + .px(px(8.)) + .py(px(6.)) + .min_h(px(36.)) .w_full() - .rounded(px(10.)) + .rounded(px(6.)) .cursor_pointer() - .when(selected, |this| this.bg(glass_selected(theme))) - .when(!selected, |this| { - this.hover(move |style| style.bg(glass_hover(theme))) - .active(move |style| style.bg(glass_press(theme))) - }) + .hover(move |style| style.bg(glass_hover(theme))) + .active(move |style| style.bg(glass_press(theme))) .child( svg() .path(icon) diff --git a/apps/desktop-gpui/src/target_thumbnails.rs b/apps/desktop-gpui/src/target_thumbnails.rs index 91c3a3a5c8c..a806ee27c64 100644 --- a/apps/desktop-gpui/src/target_thumbnails.rs +++ b/apps/desktop-gpui/src/target_thumbnails.rs @@ -388,29 +388,36 @@ pub fn render_thumbnail_slot( }; let black = |alpha: f32| hsla(0., 0., 0., alpha); - // The inner corner radius: the card is `rounded(8.)` with a 1px border. - const TOP_RADIUS: f32 = 7.; + // Inset in its glass card like the library's previews. + const RADIUS: f32 = 8.; + let glass = |light: f32, dark: f32| { + if theme.is_dark() { + hsla(0., 0., 1., dark) + } else { + hsla(0., 0., 0., light) + } + }; div() .relative() .w_full() - .h(px(76.)) + .h(px(84.)) .overflow_hidden() - .rounded_t(px(TOP_RADIUS)) - .bg(theme.body_fill(4)) + .rounded(px(RADIUS)) + .bg(glass(0.04, 0.08)) .child(match thumb.image { Some(image) => img(image) .size_full() .object_fit(gpui::ObjectFit::Cover) - .rounded_t(px(TOP_RADIUS)) + .rounded(px(RADIUS)) .into_any_element(), None => div() .flex() .size_full() .items_center() .justify_center() - .rounded_t(px(TOP_RADIUS)) - .bg(theme.body_fill(4)) + .rounded(px(RADIUS)) + .bg(glass(0.04, 0.08)) .child( // `svg()` does not inherit `text_color`; the fallback icon // has to set its own or it paints black. @@ -429,7 +436,7 @@ pub fn render_thumbnail_slot( .flex() .items_center() .justify_center() - .rounded_t(px(TOP_RADIUS)) + .rounded(px(RADIUS)) .bg(black(0.45)) .child( img(app_icon) @@ -448,10 +455,9 @@ pub fn render_thumbnail_slot( div() .absolute() .inset_0() - .rounded_t(px(TOP_RADIUS)) + .rounded(px(RADIUS)) .border_1() - // `border-black/5` under `opacity-60`. - .border_color(black(0.03)), + .border_color(glass(0.08, 0.1)), ) .child( div() @@ -460,6 +466,7 @@ pub fn render_thumbnail_slot( .left_0() .right_0() .h(px(40.)) + .rounded_b(px(RADIUS)) .bg(linear_gradient( 0., linear_color_stop(black(0.4), 0.), From 0e3d27baae936612d640239f467893f7c2041b0b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:05:09 +0000 Subject: [PATCH 2/2] fix(desktop-gpui): show Loading formats in the format header, keep only the comments that explain why --- apps/desktop-gpui/src/main_window.rs | 25 +++++++++++----------- apps/desktop-gpui/src/target_thumbnails.rs | 1 - 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 49ee93326fc..8dd5e6d50c1 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -210,8 +210,6 @@ fn glass_tint(theme: Theme, light: f32, dark: f32) -> Hsla { } } -/// A grouped list, the way macOS lays out a choice: one panel, rows split by -/// hairlines that start `inset` in, where the text does. fn grouped_list(theme: Theme, rows: Vec, inset: f32) -> gpui::Div { let count = rows.len(); let mut group = div() @@ -279,8 +277,6 @@ const LIBRARY_TILE_RADIUS: f32 = 12.; const LIBRARY_THUMB_RADIUS: f32 = 8.; const LIBRARY_THUMB_HEIGHT: f32 = 84.; -/// A recording or screenshot tile, in the capture tiles' glass: the same -/// fill and hairline, with its preview inset in it like a framed picture. fn library_tile(tile: gpui::Stateful, theme: Theme) -> gpui::Stateful { tile.flex() .flex_col() @@ -666,7 +662,6 @@ enum DeviceFormat { } impl DeviceFormat { - /// The label split for a list row: what it is, then its rate. fn parts(self) -> (String, Option) { match self { Self::Camera(settings) => { @@ -1368,9 +1363,10 @@ impl MainWindow { } } // `CAP_GPUI_AUTO_PANEL=display|window|camera|microphone| - // recordings|screenshots`: open that panel the way a click does. Same reason as - // `CAP_GPUI_AUTO_CAMERA` above -- the chrome screenshots need - // the card grid on screen and synthetic clicks are dropped. + // recordings|screenshots`: open that panel the way a click + // does. Same reason as `CAP_GPUI_AUTO_CAMERA` above -- the + // chrome screenshots need the panel on screen and synthetic + // clicks are dropped. match std::env::var("CAP_GPUI_AUTO_PANEL").as_deref() { Ok("display") => { this.open_panel(Panel::Target(TargetType::Display), window, cx) @@ -5783,11 +5779,16 @@ impl MainWindow { .truncate() .text_size(px(11.)) .text_color(theme.gray_10) - .child(match applied { - Some(format) => { + .child(match (applied, &self.device_formats) { + (Some(format), _) => { tr::main::device::format::current(format.label()) } - None => tr::main::device::format::preferred().to_string(), + (None, None) => { + tr::main::device::format::loading().to_string() + } + (None, Some(_)) => { + tr::main::device::format::preferred().to_string() + } }), ), ), @@ -5812,8 +5813,6 @@ impl MainWindow { }) } - /// One format in the grouped list: its size or rate, the rest muted on - /// the right, and a check on the one chosen. fn render_device_format_row( &self, index: usize, diff --git a/apps/desktop-gpui/src/target_thumbnails.rs b/apps/desktop-gpui/src/target_thumbnails.rs index a806ee27c64..285c12feec2 100644 --- a/apps/desktop-gpui/src/target_thumbnails.rs +++ b/apps/desktop-gpui/src/target_thumbnails.rs @@ -388,7 +388,6 @@ pub fn render_thumbnail_slot( }; let black = |alpha: f32| hsla(0., 0., 0., alpha); - // Inset in its glass card like the library's previews. const RADIUS: f32 = 8.; let glass = |light: f32, dark: f32| { if theme.is_dark() {