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()))