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