From a486253cb2b064f06c2a40e6ae2bb154b5afde25 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 11 Nov 2026 22:26:15 +0000 Subject: [PATCH] feat(zbugs-rune): follow zbugs-swift's macOS layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar is one card of labeled rows (Status, Sort, Order, the Created by Me and Assigned to Me switches, Creator, Assignee, Labels, Project). The connection and the account (Log Out or Log In, Servers) move to the list header, which shares the titlebar's band with the detail's toolbar: close or reopen, edit and delete as icons, then the search. Rows lead with the open or closed circle and read "#id · creator · time"; the issue's pickers sit in one row under its status line. Amp-Thread-ID: https://ampcode.com/threads/T-01a1c85e-fd3c-72ba-98dd-f4767a00e73b Co-authored-by: tmm --- apps/zbugs-rune/README.md | 9 +- apps/zbugs-rune/src/icons.rs | 67 ++++ apps/zbugs-rune/src/main.rs | 13 +- apps/zbugs-rune/src/view.rs | 700 ++++++++++++++++++++--------------- 4 files changed, 492 insertions(+), 297 deletions(-) create mode 100644 apps/zbugs-rune/src/icons.rs diff --git a/apps/zbugs-rune/README.md b/apps/zbugs-rune/README.md index 37792b80e4..2efcddecee 100644 --- a/apps/zbugs-rune/README.md +++ b/apps/zbugs-rune/README.md @@ -69,10 +69,11 @@ cd apps/zbugs-rune cargo run --release ``` -Log in from the sidebar with the printed token, or through GitHub when the -API server has a GitHub app (zbugs' own backend); Servers… points the app -elsewhere. `ZBUGS_TOKEN`, `ZBUGS_CACHE_URL`, and `ZBUGS_API_URL` in the -environment override the saved ones for that launch, and `ZBUGS_RUNE_DIR` +Log in from the account menu over the issue list with the printed token, or +through GitHub when the API server has a GitHub app (zbugs' own backend); +Servers…, in the same menu, points the app elsewhere. `ZBUGS_TOKEN`, +`ZBUGS_CACHE_URL`, and `ZBUGS_API_URL` in the environment override the +saved ones for that launch, and `ZBUGS_RUNE_DIR` moves the settings, the replica, and the token (as a file, out of the Keychain), so a test leaves the real login alone. diff --git a/apps/zbugs-rune/src/icons.rs b/apps/zbugs-rune/src/icons.rs new file mode 100644 index 0000000000..6afb8720b5 --- /dev/null +++ b/apps/zbugs-rune/src/icons.rs @@ -0,0 +1,67 @@ +//! The toolbar and list icons zbugs-swift takes from SF Symbols, drawn on +//! a 16-point grid. + +use rune::Icon; + +/// `circle`: an open issue. +pub static CIRCLE: Icon = Icon { + name: "zbugs-circle", + view: 16.0, + body: r#""#, +}; + +/// `checkmark.circle.fill`: a closed issue. +pub static CHECK_CIRCLE_FILL: Icon = Icon { + name: "zbugs-check-circle-fill", + view: 16.0, + body: r#""#, +}; + +/// `checkmark.circle`: close the issue. +pub static CHECK_CIRCLE: Icon = Icon { + name: "zbugs-check-circle", + view: 16.0, + body: r#""#, +}; + +/// `arrow.uturn.backward.circle`: reopen the issue. +pub static REOPEN: Icon = Icon { + name: "zbugs-reopen", + view: 16.0, + body: r#""#, +}; + +/// `pencil`: edit the issue. +pub static PENCIL: Icon = Icon { + name: "zbugs-pencil", + view: 16.0, + body: r#""#, +}; + +/// `trash`: delete the issue. +pub static TRASH: Icon = Icon { + name: "zbugs-trash", + view: 16.0, + body: r#""#, +}; + +/// `square.and.pencil`: a new issue. +pub static COMPOSE: Icon = Icon { + name: "zbugs-compose", + view: 16.0, + body: r#""#, +}; + +/// `person.crop.circle`: the account menu. +pub static PERSON: Icon = Icon { + name: "zbugs-person", + view: 16.0, + body: r#""#, +}; + +/// `checklist`: pick issues to delete. +pub static SELECT: Icon = Icon { + name: "zbugs-select", + view: 16.0, + body: r#""#, +}; diff --git a/apps/zbugs-rune/src/main.rs b/apps/zbugs-rune/src/main.rs index 2d59fd4b24..70b04c494e 100644 --- a/apps/zbugs-rune/src/main.rs +++ b/apps/zbugs-rune/src/main.rs @@ -5,6 +5,7 @@ //! it; their handlers change it and ask the sync thread for other rows or //! to run a mutator. +mod icons; mod images; mod login; mod settings; @@ -82,15 +83,21 @@ pub struct Filter { pub labels: Vec, } -/// A dropdown on the issue page. +/// An open dropdown or menu. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Picker { + /// The issue page's. Labels, Visibility, Notifications, /// The sidebar's. Project, + Status, + Sort, + Order, LabelFilter, + /// The list header's account menu. + Account, /// Reactions to the issue (its ID) or to one of its comments. Emoji(String), } @@ -1149,7 +1156,9 @@ fn main() { poll: Some(|s, _, _| s.images.poll()), ..AppSpec::new("Zero Bugs", (1180.0, 760.0), placeholder, view::view) }; - let app = rune::App::new(spec); + // The headers and the toolbar share the titlebar's band, as a Mac + // app's unified toolbar does. + let app = rune::App::new(spec).titlebar(rune::Titlebar::new(view::BAR)); let handle = app.handle(); app.init(move || App::new(handle)).on_close(on_close).run(); } diff --git a/apps/zbugs-rune/src/view.rs b/apps/zbugs-rune/src/view.rs index 6a66cc6bff..8fd25e469a 100644 --- a/apps/zbugs-rune/src/view.rs +++ b/apps/zbugs-rune/src/view.rs @@ -6,16 +6,17 @@ use std::sync::OnceLock; use rune::{Cx, El, GrowthSpec, ScrollSpec, Tx, col, el, row, text}; use rune_kit::{ - Button, ButtonVariant, Checkbox, Combobox, ComboboxOption, ComboboxState, Dialog, Kit, - Markdown, Menu, MenuItem, Popover, SegmentedControl, Select, TextArea, TextInput, + Button, ButtonVariant, Checkbox, Combobox, ComboboxOption, ComboboxState, Dialog, IconButton, + IconButtonVariant, Kit, Markdown, Menu, MenuItem, Placement, Popover, SegmentedControl, Select, + Switch, TextArea, TextInput, }; use zbugs_model::args::{SortDirection, SortField}; use zbugs_model::schema::{Comment, Emoji, Issue, IssueVisibility, Label, User}; use zero_client::client::ConnectionState; use zero_client::ivm::ResultType; -use crate::images; use crate::{App, Confirm, NOTIFICATIONS, Picker, Sheet, Status, VISIBILITY}; +use crate::{icons, images}; /// The rows' estimated height, for the virtual list. const ROW: f32 = 58.0; @@ -30,6 +31,15 @@ fn kit(dark: bool) -> &'static Kit { } } +/// The titlebar's height: each column's header shares the band, as the +/// toolbar of zbugs-swift's window does. +pub const BAR: f32 = 52.0; + +/// Closed issues' colour, as zbugs-swift's purple. +fn closed_color() -> rune::Color { + rune::oklch(0.62, 0.19, 300.0) +} + pub fn view(s: &App, cx: &mut Cx) -> El { let k = kit(cx.system_dark()); let mut layer = Vec::new(); @@ -39,15 +49,31 @@ pub fn view(s: &App, cx: &mut Cx) -> El { .w_full() .h_full() .fill(k.c.page) - .child(sidebar(s, k, &mut layer).pt(cx.titlebar_inset())) + .child(sidebar(s, k, &mut layer, cx.chrome_insets().leading)) .child(divider()) - .child(issue_list(s, k).pt(cx.titlebar_inset())) + .child(issue_list(s, k, &mut layer)) .child(divider()) - .child(detail(s, k, &mut layer).pt(cx.titlebar_inset())); + .child(detail(s, k, &mut layer)); sheets(s, k, &mut layer); rune_kit::root(body, layer) } +/// A column's header in the titlebar's band: dragging it moves the window. +fn band(key: &str) -> El { + row() + .key(key) + .w_full() + .h(BAR) + .px(12.0) + .gap(6.0) + .center_y() + .window_drag() +} + +fn hairline(k: &Kit) -> El { + el().w_full().h(1.0).fill(k.c.border_muted) +} + /// Markdown with its images, as the web app shows them. fn markdown(s: &App, body: &str) -> Markdown { let images = s.images.clone(); @@ -68,10 +94,9 @@ fn label_text(k: &Kit, t: impl Into) -> El { text(t).font(k.ty.small_medium).color(k.c.text_faint) } -/// The label of a menu on the issue page, as wide as the others so the -/// menus line up. +/// The label of a menu on the issue page, before its value. fn field_label(k: &Kit, t: &str) -> El { - label_text(k, t).w(96.0) + label_text(k, t) } /// A dialog's explanation. `Dialog::description` doesn't wrap, so a long @@ -80,161 +105,181 @@ fn dialog_text(k: &Kit, t: impl Into) -> El { text(t).font(k.ty.ui.lh(20.0)).color(k.c.text_muted).wrap() } -fn sidebar(s: &App, k: &Kit, layer: &mut Vec) -> El { - let status = Status::ALL.iter().position(|x| *x == s.filter.status); +/// The filters, as zbugs-swift's sidebar: one card of labeled rows, the +/// label on the left and its value on the right. +fn sidebar(s: &App, k: &Kit, layer: &mut Vec, leading: f32) -> El { + let status = s.filter.status.title(); + let sort = if s.filter.sort_field == SortField::Created { + SORTS[1] + } else { + SORTS[0] + }; + let order = ORDERS[usize::from(s.filter.sort_direction == SortDirection::Asc)]; let titles: Vec<&str> = Status::ALL.iter().map(|x| x.title()).collect(); - let mut filters = col() - .w_full() - .gap(8.0) - .child(label_text(k, "Status")) - .child( - SegmentedControl::new(&titles, status) - .on_pick(|s: &mut App, i, _| { - s.filter.status = Status::ALL[i]; - s.list_changed(); - }) - .width(None) - .build(k) - .key("status"), - ) - .child(label_text(k, "Sort")) - .child( - SegmentedControl::new( - &["Modified", "Created"], - Some(usize::from(s.filter.sort_field == SortField::Created)), - ) - .on_pick(|s: &mut App, i, _| { - s.filter.sort_field = if i == 0 { - SortField::Modified - } else { + let mut rows = vec![ + filter_row( + k, + "Status", + pick( + s, + k, + layer, + ("status", Picker::Status), + &titles, + status, + |s, v| { + if let Some(x) = Status::ALL.iter().find(|x| x.title() == v) { + s.filter.status = *x; + s.list_changed(); + } + }, + ), + ), + filter_row( + k, + "Sort", + pick(s, k, layer, ("sort", Picker::Sort), &SORTS, sort, |s, v| { + s.filter.sort_field = if v == SORTS[1] { SortField::Created - }; - s.list_changed(); - }) - .width(None) - .build(k) - .key("sort"), - ) - .child(label_text(k, "Order")) - .child( - SegmentedControl::new( - &["Newest", "Oldest"], - Some(usize::from(s.filter.sort_direction == SortDirection::Asc)), - ) - .on_pick(|s: &mut App, i, _| { - s.filter.sort_direction = if i == 0 { - SortDirection::Desc } else { - SortDirection::Asc + SortField::Modified }; s.list_changed(); - }) - .width(None) - .build(k) - .key("order"), - ); + }), + ), + filter_row( + k, + "Order", + pick( + s, + k, + layer, + ("order", Picker::Order), + &ORDERS, + order, + |s, v| { + s.filter.sort_direction = if v == ORDERS[1] { + SortDirection::Asc + } else { + SortDirection::Desc + }; + s.list_changed(); + }, + ), + ), + ]; // Shortcuts for the creator and assignee filters, by the user's login, // so only once it has synced, as the web app's "Created by me" links. if let Some(me) = s.my_login() { let mine = |f: &Option| f.as_deref() == Some(me); - filters = filters - .child( - Checkbox::new(mine(&s.filter.creator)) - .label("Created by Me") - .on_toggle(|s: &mut App| { - let me = s.my_login().map(str::to_owned); - let on = s.filter.creator != me; - s.set_creator(me.filter(|_| on)); - }) - .build(k) - .key("created-by-me"), - ) - .child( - Checkbox::new(mine(&s.filter.assignee)) - .label("Assigned to Me") - .on_toggle(|s: &mut App| { - let me = s.my_login().map(str::to_owned); - let on = s.filter.assignee != me; - s.set_assignee(me.filter(|_| on)); - }) - .build(k) - .key("assigned-to-me"), - ); - } - filters = filters - .child(label_text(k, "Creator")) - .child(user_filter( - s, + rows.push(filter_row( k, - layer, - true, - &s.creators, - &s.filter.creator, - )) - .child(label_text(k, "Assignee")) - .child(user_filter( - s, + "Created by Me", + Switch::new(mine(&s.filter.creator)) + .label("Created by Me") + .hide_label() + .on_toggle(|s: &mut App| { + let me = s.my_login().map(str::to_owned); + let on = s.filter.creator != me; + s.set_creator(me.filter(|_| on)); + }) + .build(k) + .key("created-by-me"), + )); + rows.push(filter_row( k, - layer, - false, - &s.assignees, - &s.filter.assignee, - )) - .child(label_text(k, "Labels")) - .child(label_filter(s, k, layer)); - let account = match &s.auth { - Some(auth) => col() - .gap(6.0) - .child( - row() - .gap(8.0) - .center_y() - .child(avatar(s, s.me.as_ref(), 22.0, k)) - .child( - text(format!( - "{} ({})", - s.me.as_ref() - .map_or(auth.sub.as_str(), |u| u.login.as_str()), - auth.role.as_str() - )) - .font(k.ty.small) - .color(k.c.text_muted) - .truncate(), - ), - ) - .child( - Button::new("Log Out") - .variant(ButtonVariant::Ghost) - .on_press(|s: &mut App| s.log_out()) - .build(k) - .key("log-out"), - ), - None => col().child( - Button::new("Log In…") - .on_press(|s: &mut App| s.open_sheet(Sheet::Login)) + "Assigned to Me", + Switch::new(mine(&s.filter.assignee)) + .label("Assigned to Me") + .hide_label() + .on_toggle(|s: &mut App| { + let me = s.my_login().map(str::to_owned); + let on = s.filter.assignee != me; + s.set_assignee(me.filter(|_| on)); + }) .build(k) - .key("log-in"), - ), - }; + .key("assigned-to-me"), + )); + } + rows.push(filter_row( + k, + "Creator", + user_filter(s, k, layer, true, &s.creators, &s.filter.creator), + )); + rows.push(filter_row( + k, + "Assignee", + user_filter(s, k, layer, false, &s.assignees, &s.filter.assignee), + )); + rows.push(filter_row(k, "Labels", label_filter(s, k, layer))); + rows.push(filter_row(k, "Project", project_picker(s, k, layer))); + let mut card = col() + .key("filters") + .w_full() + .radius(10.0) + .fill(k.c.layer_01) + .border(1.0, k.c.border_muted); + for (i, r) in rows.into_iter().enumerate() { + if i > 0 { + card = card.child( + row() + .w_full() + .pl(12.0) + .child(el().grow(1.0).h(1.0).fill(k.c.border_muted)), + ); + } + card = card.child(r); + } col() .key("sidebar") - .w(230.0) + .w(250.0) .h_full() - .px(14.0) - .pb(14.0) - .gap(16.0) - .child(project_picker(s, k, layer)) - .child(filters) - .child(el().grow(1.0)) - .child(connection_badge(s, k)) - .child(account) + .child(band("sidebar-bar").pl(leading.max(12.0))) + .child(col().w_full().px(10.0).child(card)) +} + +const SORTS: [&str; 2] = ["Modified", "Created"]; +const ORDERS: [&str; 2] = ["Newest First", "Oldest First"]; + +/// One of the sidebar's rows: `label` on the left, its control on the right. +fn filter_row(k: &Kit, label: &str, control: El) -> El { + row() + .w_full() + .h(36.0) + .px(12.0) + .gap(8.0) + .center_y() .child( - Button::new("Servers…") - .variant(ButtonVariant::Ghost) - .on_press(|s: &mut App| s.open_sheet(Sheet::Servers)) - .build(k) - .key("servers"), + text(label) + .font(k.ty.ui) + .color(k.c.text_base) + .truncate() + .grow(1.0) + .min_w(0.0), ) + .child(control) +} + +/// A menu of fixed choices, borderless as a Mac's pop-up button in a form. +fn pick( + s: &App, + k: &Kit, + layer: &mut Vec, + (key, picker): (&'static str, Picker), + options: &[&str], + current: &str, + on_choose: fn(&mut App, &str), +) -> El { + Select::new(key, options) + .current(Some(current)) + .open(s.picker.as_ref() == Some(&picker)) + .on_toggle(move |s: &mut App| s.toggle_picker(picker.clone())) + .on_close(|s: &mut App| s.picker = None) + .on_choose(move |s: &mut App, v: &str, _| { + s.picker = None; + on_choose(s, v); + }) + .build(k, layer) } /// The project the list shows, as a menu when there's more than one. @@ -243,12 +288,11 @@ fn project_picker(s: &App, k: &Kit, layer: &mut Vec) -> El { if s.projects.len() < 2 { return text(name) .key("project") - .font(k.ty.body_medium) - .color(k.c.text_base); + .font(k.ty.ui) + .color(k.c.text_muted); } let names: Vec<&str> = s.projects.iter().map(|p| p.name.as_str()).collect(); Select::new("project", &names) - .width(202.0) .current(Some(name)) .open(s.picker == Some(Picker::Project)) .on_toggle(|s: &mut App| s.toggle_picker(Picker::Project)) @@ -282,7 +326,7 @@ fn user_filter( }) }; let current = current.as_deref().unwrap_or(ANYONE); - people(k, (key, state, access), &logins, current, 202.0) + people(k, (key, state, access), &logins, current, 120.0) .on_choose(move |s: &mut App, login: &str, _| { let login = (login != ANYONE).then(|| login.to_owned()); if creator { @@ -327,14 +371,14 @@ fn label_filter(s: &App, k: &Kit, layer: &mut Vec) -> El { }; let mut menu = Menu::new("label-filter") .trigger( - Button::new(title) - .variant(ButtonVariant::Outline) - .expanded(open) - .on_press(|s: &mut App| s.toggle_picker(Picker::LabelFilter)) - .build(k) - .w_full(), + Select::new("label-filter-value", &[] as &[&str]) + .label("Labels") + .current(Some(&title)) + .open(open) + .on_toggle(|s: &mut App| s.toggle_picker(Picker::LabelFilter)) + .trigger(k), ) - .min_width(202.0) + .min_width(180.0) .open(open) .on_close(|s: &mut App| s.picker = None); for l in &s.project_labels { @@ -348,7 +392,9 @@ fn label_filter(s: &App, k: &Kit, layer: &mut Vec) -> El { menu.build(k, layer) } -fn connection_badge(s: &App, k: &Kit) -> El { +/// The connection's state, as the dot and word in zbugs-swift's toolbar. +/// Pressing it reconnects now rather than at the next retry. +fn connection(s: &App, k: &Kit) -> El { let (color, label) = match &s.connection { ConnectionState::Connected => (k.c.success.icon, "Connected"), ConnectionState::Connecting { .. } | ConnectionState::Initializing => { @@ -366,36 +412,73 @@ fn connection_badge(s: &App, k: &Kit) -> El { } _ => s.cache_url().to_owned(), }; - col() + row() .key("connection") .role("button") .label(label) - .gap(2.0) + .help(detail) + .gap(5.0) + .px(4.0) + .center_y() .on_press(|s: &mut App| s.reconnect_now()) - .child( - row() - .gap(6.0) - .center_y() - .child(el().size(8.0, 8.0).radius(4.0).fill(color)) - .child(text(label).font(k.ty.small_medium).color(k.c.text_base)), - ) - .child( - text(detail) - .font(k.ty.small) - .color(k.c.text_faint) - .truncate(), + .child(el().size(8.0, 8.0).radius(4.0).fill(color)) + .child(text(label).font(k.ty.small_medium).color(k.c.text_muted)) +} + +/// Who is logged in, logging out or in, and the servers, as zbugs-swift's +/// account menu. +fn account_menu(s: &App, k: &Kit, layer: &mut Vec) -> El { + let open = s.picker == Some(Picker::Account); + let mut menu = Menu::new("account") + .trigger( + IconButton::new(&icons::PERSON) + .variant(IconButtonVariant::Ghost) + .label("Account") + .help("Account") + .expanded(open) + .on_press(|s: &mut App| s.toggle_picker(Picker::Account)) + .build(k) + .key("account-trigger"), ) + .placement(Placement::BottomEnd) + .min_width(280.0) + .open(open) + .on_close(|s: &mut App| s.picker = None); + menu = match &s.auth { + Some(auth) => menu + .item( + MenuItem::new(format!( + "Logged in as {} ({})", + s.me.as_ref() + .map_or(auth.sub.as_str(), |u| u.login.as_str()), + auth.role.as_str() + )) + .lead(avatar(s, s.me.as_ref(), 16.0, k)) + .disabled(true), + ) + .item(MenuItem::new("Log Out").on_select(|s: &mut App| { + s.picker = None; + s.log_out(); + })), + None => menu.item(MenuItem::new("Log In…").on_select(|s: &mut App| { + s.picker = None; + s.open_sheet(Sheet::Login); + })), + }; + menu.separator() + .item(MenuItem::new("Servers…").on_select(|s: &mut App| { + s.picker = None; + s.open_sheet(Sheet::Servers); + })) + .build(k, layer) } -fn issue_list(s: &App, k: &Kit) -> El { +fn issue_list(s: &App, k: &Kit, layer: &mut Vec) -> El { let count = s.count.map_or(String::new(), |n| n.to_string()); // Picking issues to delete, as the web list's select mode. - let toolbar = if s.select_mode && s.auth.is_some() { + let header = if s.select_mode && s.auth.is_some() { let n = s.checked.len(); - row() - .w_full() - .center_y() - .gap(8.0) + band("list-bar") .child( text(format!("{n} selected")) .key("checked-count") @@ -423,15 +506,15 @@ fn issue_list(s: &App, k: &Kit) -> El { .key("delete-checked"), ) } else { - row() - .w_full() - .center_y() - .gap(8.0) + band("list-bar") .child( text(format!("{} Issues", s.filter.status.title())) + .key("list-title") .font(k.ty.body_medium) .color(k.c.text_base) - .grow(1.0), + .truncate() + .grow(1.0) + .min_w(0.0), ) .child( text(count) @@ -439,50 +522,27 @@ fn issue_list(s: &App, k: &Kit) -> El { .font(k.ty.small) .color(k.c.text_muted), ) + .child(connection(s, k)) .child( - Button::new("Select") - .variant(ButtonVariant::Ghost) + IconButton::new(&icons::SELECT) + .variant(IconButtonVariant::Ghost) + .help("Select Issues") .disabled(s.auth.is_none()) .on_press(|s: &mut App| s.toggle_select_mode()) .build(k) .key("select-issues"), ) .child( - Button::new("New Issue") + IconButton::new(&icons::COMPOSE) + .variant(IconButtonVariant::Ghost) + .help("New Issue") .disabled(s.auth.is_none()) .on_press(|s: &mut App| s.open_sheet(Sheet::NewIssue)) .build(k) .key("new-issue"), ) + .child(account_menu(s, k, layer)) }; - let header = col() - .w_full() - .px(12.0) - .pb(10.0) - .gap(8.0) - .child(toolbar) - .child( - TextInput::new(&s.search) - .placeholder("Search issues") - .clear_button() - .on_edit(|s: &mut App| &mut s.search) - .on_change(|s: &mut App, _| s.list_changed()) - .on_action(|s: &mut App, _| { - s.search = rune::TextEdit::new(""); - s.list_changed(); - }) - // Escape clears the search, as on the web. - .on_key(|s: &mut App, event, _| { - if !matches!(event.key, rune::Key::Escape) || s.search.text.is_empty() { - return false; - } - s.search = rune::TextEdit::new(""); - s.list_changed(); - true - }) - .build(k) - .key("search"), - ); let body = if s.issues.is_empty() { let message = if s.issues_result == ResultType::Complete { "No issues" @@ -531,6 +591,7 @@ fn issue_list(s: &App, k: &Kit) -> El { .w(380.0) .h_full() .child(header) + .child(hairline(k)) .child(body) } @@ -551,20 +612,23 @@ fn labels(labels: &[Label], k: &Kit) -> El { fn issue_row(s: &App, issue: &Issue, k: &Kit) -> El { let selected = s.selected.as_deref() == Some(issue.id.as_str()); let id = issue.id.clone(); + // "#3005 · arv · 2 years ago", then the labels, as zbugs-swift's rows. + let subtitle = [ + short_id(issue), + issue + .creator + .as_ref() + .map_or_else(String::new, |u| u.login.clone()), + ago(issue.modified), + ] + .into_iter() + .filter(|x| !x.is_empty()) + .collect::>() + .join(" · "); let mut meta = row() .gap(6.0) .center_y() - .child(text(short_id(issue)).font(k.ty.small).color(k.c.text_faint)) - .child( - text(issue.creator.as_ref().map_or("", |u| u.login.as_str())) - .font(k.ty.small) - .color(k.c.text_muted), - ) - .child( - text(ago(issue.modified)) - .font(k.ty.small) - .color(k.c.text_faint), - ); + .child(text(subtitle).font(k.ty.small).color(k.c.text_faint)); if let Some(l) = issue.labels.as_deref().filter(|l| !l.is_empty()) { meta = meta.child(labels(l, k)); } @@ -592,7 +656,11 @@ fn issue_row(s: &App, issue: &Issue, k: &Kit) -> El { .fill(k.c.icon_accent), ); } - let text_col = col().min_w(0.0).gap(3.0).child(title).child(meta); + let text_col = row() + .min_w(0.0) + .gap(8.0) + .child(status_icon(issue.open, 14.0, k).mt(2.0)) + .child(col().min_w(0.0).grow(1.0).gap(3.0).child(title).child(meta)); if s.select_mode && s.auth.is_some() { // Pressing the row picks it, as the checkbox does; only what the // user can delete. @@ -641,6 +709,16 @@ fn issue_row(s: &App, issue: &Issue, k: &Kit) -> El { r } +/// An open issue's circle or a closed one's check, as zbugs-swift's. +fn status_icon(open: bool, size: f32, k: &Kit) -> El { + if open { + rune::icon(&icons::CIRCLE, size).color(k.c.success.icon) + } else { + rune::icon(&icons::CHECK_CIRCLE_FILL, size).color(closed_color()) + } + .a11y_hidden() +} + fn short_id(issue: &Issue) -> String { issue.short_id.map_or_else(String::new, |n| format!("#{n}")) } @@ -658,35 +736,105 @@ fn ago(ms: f64) -> String { } fn detail(s: &App, k: &Kit, layer: &mut Vec) -> El { - let frame = col().key("detail").grow(1.0).h_full().min_w(0.0); + // The issue's actions as icons and the search, in the band as + // zbugs-swift's toolbar has them. + let mut bar = band("detail-bar"); + if let Some(issue) = s.detail.as_ref().filter(|i| s.can_edit(&i.creator_id)) { + let id = issue.id.clone(); + let (icon, help) = if issue.open { + (&icons::CHECK_CIRCLE, "Close Issue") + } else { + (&icons::REOPEN, "Reopen Issue") + }; + bar = bar + .child( + IconButton::new(icon) + .variant(IconButtonVariant::Ghost) + .help(help) + .on_press(|s: &mut App| s.toggle_open()) + .build(k) + .key("toggle-open"), + ) + .child( + IconButton::new(&icons::PENCIL) + .variant(IconButtonVariant::Ghost) + .help("Edit Issue") + .on_press(|s: &mut App| s.open_sheet(Sheet::EditIssue)) + .build(k) + .key("edit"), + ) + .child( + IconButton::new(&icons::TRASH) + .variant(IconButtonVariant::Ghost) + .help("Delete Issue") + .on_press(move |s: &mut App| s.confirm = Some(Confirm::DeleteIssue(id.clone()))) + .build(k) + .key("delete-issue"), + ); + } + bar = bar.child(el().grow(1.0)).child( + TextInput::new(&s.search) + .placeholder("Search issues") + .width(Some(240.0)) + .clear_button() + .on_edit(|s: &mut App| &mut s.search) + .on_change(|s: &mut App, _| s.list_changed()) + .on_action(|s: &mut App, _| { + s.search = rune::TextEdit::new(""); + s.list_changed(); + }) + // Escape clears the search, as on the web. + .on_key(|s: &mut App, event, _| { + if !matches!(event.key, rune::Key::Escape) || s.search.text.is_empty() { + return false; + } + s.search = rune::TextEdit::new(""); + s.list_changed(); + true + }) + .build(k) + .key("search"), + ); + let frame = col() + .key("detail") + .grow(1.0) + .h_full() + .min_w(0.0) + .child(bar) + .child(hairline(k)); let Some(issue) = &s.detail else { let message = match (&s.selected, s.detail_result) { (None, _) => "No issue selected", (Some(_), ResultType::Complete) => "Issue not found", _ => "Loading…", }; - return frame - .center() - .child(text(message).font(k.ty.ui).color(k.c.text_faint)); + return frame.child( + col() + .w_full() + .grow(1.0) + .center() + .child(text(message).font(k.ty.ui).color(k.c.text_faint)), + ); }; - let mut status = row().gap(8.0).center_y().child( - text(if issue.open { "Open" } else { "Closed" }) - .font(k.ty.small_medium) - .color(if issue.open { - k.c.success.fg - } else { - k.c.text_muted - }), - ); - if let Some(u) = &issue.creator { - status = status.child( - row() - .gap(6.0) - .center_y() - .child(text("by").font(k.ty.small).color(k.c.text_muted)) - .child(avatar(s, Some(u), 18.0, k)) - .child(text(u.login.clone()).font(k.ty.small).color(k.c.text_muted)), + let mut status = row() + .key("issue-status") + .gap(6.0) + .center_y() + .child(status_icon(issue.open, 13.0, k)) + .child( + text(if issue.open { "Open" } else { "Closed" }) + .font(k.ty.small_medium) + .color(if issue.open { + k.c.success.fg + } else { + closed_color() + }), ); + if let Some(u) = &issue.creator { + status = status + .child(text("by").font(k.ty.small).color(k.c.text_muted)) + .child(avatar(s, Some(u), 16.0, k)) + .child(text(u.login.clone()).font(k.ty.small).color(k.c.text_muted)); } let editable = s.can_edit(&issue.creator_id); if let (Some(u), false) = (&issue.assignee, editable) { @@ -699,48 +847,13 @@ fn detail(s: &App, k: &Kit, layer: &mut Vec) -> El { if issue.visibility == IssueVisibility::Internal { status = status.child(text("· Internal").font(k.ty.small).color(k.c.warning.fg)); } - let mut actions = row().gap(8.0); - if editable { - let id = issue.id.clone(); - actions = actions - .child( - Button::new(if issue.open { "Close Issue" } else { "Reopen" }) - .variant(ButtonVariant::Outline) - .on_press(|s: &mut App| s.toggle_open()) - .build(k) - .key("toggle-open"), - ) - .child( - Button::new("Edit") - .variant(ButtonVariant::Ghost) - .on_press(|s: &mut App| s.open_sheet(Sheet::EditIssue)) - .build(k) - .key("edit"), - ) - .child( - Button::new("Delete") - .variant(ButtonVariant::Danger) - .on_press(move |s: &mut App| s.confirm = Some(Confirm::DeleteIssue(id.clone()))) - .build(k) - .key("delete-issue"), - ); - } let mut column = col() .key("issue-column") .w_full() .max_w(760.0) .px(24.0) .py(16.0) - .gap(14.0) - .child( - row() - .w_full() - .center_y() - .gap(12.0) - .child(text(short_id(issue)).font(k.ty.ui).color(k.c.text_faint)) - .child(el().grow(1.0)) - .child(actions), - ) + .gap(12.0) .child( text(issue.title.clone()) .key("issue-title") @@ -750,8 +863,10 @@ fn detail(s: &App, k: &Kit, layer: &mut Vec) -> El { .selectable(true), ) .child(status); + // The pickers in one row under the title, each with its label. + let mut pickers = row().key("issue-pickers").w_full().flex_wrap().gap(16.0); if editable { - column = column.child(assignee_picker(s, issue, k, layer)); + pickers = pickers.child(assignee_picker(s, issue, k, layer)); } // Visibility is the crew's, as on the web. if editable && s.is_crew() { @@ -759,7 +874,7 @@ fn detail(s: &App, k: &Kit, layer: &mut Vec) -> El { IssueVisibility::Public => VISIBILITY[0], IssueVisibility::Internal => VISIBILITY[1], }; - column = column.child(choice( + pickers = pickers.child(choice( k, layer, ("Visibility", "visibility"), @@ -771,7 +886,7 @@ fn detail(s: &App, k: &Kit, layer: &mut Vec) -> El { } if s.auth.is_some() { let current = NOTIFICATIONS[usize::from(!s.is_subscribed(issue))]; - column = column.child(choice( + pickers = pickers.child(choice( k, layer, ("Notifications", "notifications"), @@ -784,6 +899,9 @@ fn detail(s: &App, k: &Kit, layer: &mut Vec) -> El { |s, v| s.set_subscribed(v), )); } + if editable || s.auth.is_some() { + column = column.child(pickers); + } let issue_labels = issue.labels.as_deref().unwrap_or_default(); // Labels are the crew's, as on the web. let label_menu = editable && s.is_crew(); @@ -916,7 +1034,7 @@ fn assignee_picker(s: &App, issue: &Issue, k: &Kit, layer: &mut Vec) -> El { logins.extend(s.crew.iter().map(|u| u.login.as_str())); let current = issue.assignee.as_ref().map_or(NOBODY, |u| u.login.as_str()); row() - .gap(8.0) + .gap(4.0) .center_y() .child(field_label(k, "Assignee")) .child( @@ -925,7 +1043,7 @@ fn assignee_picker(s: &App, issue: &Issue, k: &Kit, layer: &mut Vec) -> El { ("assignee", &s.assignee_combo, |s| &mut s.assignee_combo), &logins, current, - 200.0, + 120.0, ) .on_choose(|s: &mut App, login: &str, _| s.assign(login)) .build(k, layer), @@ -943,12 +1061,12 @@ fn choice( on_choose: fn(&mut App, &str), ) -> El { row() - .gap(8.0) + .gap(4.0) .center_y() .child(field_label(k, title)) .child( Select::new(key, options) - .width(200.0) + .width(120.0) .current(Some(current)) .open(open) .on_toggle(move |s: &mut App| s.toggle_picker(picker.clone()))