Project · Tasks: priorities, issues, statuses, assignees, task rows
A new chapter, src/project. PriorityIndicator shows how urgent a task is in three bars, urgent in red, its words in a tip or beside it. IssueIdBadge names an issue by its team's key and number, as ENG-123. StatusSelect picks a status from the workflow, each with its mark, and AssigneePicker picks a person or no one; DueDatePicker is the forms DatePicker. TaskItem is a task in a row: its box, priority and issue, its title, then its due day, red when past and still open, and who has it. TaskList puts them in a SelectableList: arrows move, Enter opens, and a box's press or keys stay at the box. SubtaskList is the documents Checklist, which gains a field that adds a step on Enter. Along the way: forms::Choice::avatar leads a person's row with their initials, in lists and in a Select's trigger; ListItem::struck strikes a closed task's title; typography::Ellipsis now shapes with the text style's run, so a strike or underline on its box shows; two Lucide icons, circle-dashed and circle-dot-dashed.
This commit is contained in:
1 parent
c99465153d
commit
1633bc9293
20 files changed
+1417
-30
No files matched your search
+36
-5
@@ -1,12 +1,12 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div, prelude::*,
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::{Checkbox, OnFlag},
|
||||
forms::{Checkbox, Enter, Input, OnFlag, OnValue, TextInput},
|
||||
motion::ProgressBar,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::tabular,
|
||||
@@ -59,13 +59,14 @@ impl RenderOnce for TodoItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// To-dos under a title, with how many are done as a count and a bar.
|
||||
/// To-dos under a title, with how many are done as a count and a bar. With `on_add`, a field under them adds one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Checklist {
|
||||
id: ElementId,
|
||||
title: SharedString,
|
||||
items: Vec<(SharedString, bool)>,
|
||||
on_toggle: Option<OnItem>,
|
||||
adding: Option<(SharedString, OnValue)>,
|
||||
}
|
||||
|
||||
impl Checklist {
|
||||
@@ -82,9 +83,20 @@ impl Checklist {
|
||||
.map(|(label, done)| (label.into(), done))
|
||||
.collect(),
|
||||
on_toggle: None,
|
||||
adding: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field under the items: Enter hands on its words, trimmed, and clears it.
|
||||
pub fn on_add(
|
||||
mut self,
|
||||
placeholder: impl Into<SharedString>,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.adding = Some((placeholder.into(), Rc::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Tells which item changed, by index, and whether it is now done.
|
||||
pub fn on_toggle(
|
||||
mut self,
|
||||
@@ -96,7 +108,25 @@ impl Checklist {
|
||||
}
|
||||
|
||||
impl RenderOnce for Checklist {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let add = self.adding.map(|(placeholder, on_add)| {
|
||||
let field = window.use_keyed_state((self.id.clone(), "add"), cx, |window, cx| {
|
||||
TextInput::new(window, cx).placeholder(placeholder)
|
||||
});
|
||||
let (id, typed) = (self.id.clone(), field.clone());
|
||||
div()
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
let words = typed.read(cx).text().trim().to_string();
|
||||
if words.is_empty() {
|
||||
return;
|
||||
}
|
||||
cx.stop_propagation();
|
||||
log::info!("checklist {id:?}: add {words}");
|
||||
typed.update(cx, |input, cx| input.set_text(String::new(), cx));
|
||||
on_add(&words.into(), window, cx);
|
||||
})
|
||||
.child(Input::new(&field))
|
||||
});
|
||||
let theme = cx.theme();
|
||||
let done = self.items.iter().filter(|(_, done)| *done).count();
|
||||
let total = self.items.len();
|
||||
@@ -142,5 +172,6 @@ impl RenderOnce for Checklist {
|
||||
}
|
||||
}),
|
||||
)
|
||||
.children(add)
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -8,9 +8,10 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
data_display::Avatar,
|
||||
motion,
|
||||
primitives::{Icon, IconName, raise},
|
||||
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
|
||||
theme::{ActiveTheme, AvatarSize, ControlSize, Elevation, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
@@ -20,6 +21,7 @@ pub struct Choice {
|
||||
pub value: SharedString,
|
||||
pub label: SharedString,
|
||||
pub icon: Option<IconName>,
|
||||
pub avatar: Option<SharedString>,
|
||||
pub note: Option<SharedString>,
|
||||
pub disabled: bool,
|
||||
pub depth: usize,
|
||||
@@ -31,6 +33,7 @@ impl Choice {
|
||||
value: value.into(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
avatar: None,
|
||||
note: None,
|
||||
disabled: false,
|
||||
depth: 0,
|
||||
@@ -48,6 +51,12 @@ impl Choice {
|
||||
self
|
||||
}
|
||||
|
||||
/// Leads with the initials of `name` on its tone, as a person's row does.
|
||||
pub fn avatar(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Quiet text at the row's end, such as a count or a shortcut.
|
||||
pub fn note(mut self, note: impl Into<SharedString>) -> Self {
|
||||
self.note = Some(note.into());
|
||||
@@ -100,6 +109,7 @@ pub(crate) fn marked_row(
|
||||
checked: Option<bool>,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let id = id.into();
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let fg = if choice.disabled {
|
||||
@@ -108,7 +118,7 @@ pub(crate) fn marked_row(
|
||||
colors.fg
|
||||
};
|
||||
div()
|
||||
.id(id)
|
||||
.id(id.clone())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
@@ -140,6 +150,9 @@ pub(crate) fn marked_row(
|
||||
.when_some(choice.icon, |row, icon| {
|
||||
row.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
|
||||
})
|
||||
.when_some(choice.avatar.clone(), |row, name| {
|
||||
row.child(Avatar::new((id, "avatar"), name).size(AvatarSize::Xs))
|
||||
})
|
||||
.children(mark)
|
||||
.child(
|
||||
div()
|
||||
|
||||
+9
-1
@@ -12,8 +12,9 @@ use super::{
|
||||
options::{OnValue, Pick, Popup, Run, reveal, step},
|
||||
};
|
||||
use crate::{
|
||||
data_display::Avatar,
|
||||
primitives::{Icon, IconName, tab_stop},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius},
|
||||
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
@@ -330,6 +331,13 @@ impl RenderOnce for Select {
|
||||
trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
|
||||
},
|
||||
)
|
||||
.when_some(
|
||||
shown.as_ref().and_then(|choice| choice.avatar.clone()),
|
||||
|trigger, name| {
|
||||
let id = (self.id.clone(), "avatar");
|
||||
trigger.child(Avatar::new(id, name).size(AvatarSize::Xs))
|
||||
},
|
||||
)
|
||||
.child(field_text(
|
||||
shown.map(|choice| choice.label),
|
||||
self.placeholder,
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod motion;
|
||||
pub mod navigation;
|
||||
pub mod overlays;
|
||||
pub mod primitives;
|
||||
pub mod project;
|
||||
pub mod shell;
|
||||
pub mod tables;
|
||||
pub mod terminal;
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct ListItem {
|
||||
disabled: bool,
|
||||
strong: bool,
|
||||
quiet: bool,
|
||||
struck: bool,
|
||||
on_click: Option<OnClick>,
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ impl ListItem {
|
||||
disabled: false,
|
||||
strong: false,
|
||||
quiet: false,
|
||||
struck: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
@@ -61,6 +63,12 @@ impl ListItem {
|
||||
self
|
||||
}
|
||||
|
||||
/// Strikes the title through, as a finished task has it.
|
||||
pub fn struck(mut self, struck: bool) -> Self {
|
||||
self.struck = struck;
|
||||
self
|
||||
}
|
||||
|
||||
/// A second line, quieter than the title.
|
||||
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.description = Some(text.into());
|
||||
@@ -162,6 +170,7 @@ impl RenderOnce for ListItem {
|
||||
line(self.title)
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.when(self.strong, |title| title.font_weight(FontWeight::SEMIBOLD))
|
||||
.when(self.struck, |title| title.line_through())
|
||||
.text_color(if self.quiet {
|
||||
colors.fg_muted
|
||||
} else {
|
||||
|
||||
@@ -93,7 +93,9 @@ icons! {
|
||||
ChevronsUpDown => "chevrons-up-down",
|
||||
CircleAlert => "circle-alert",
|
||||
CircleCheck => "circle-check",
|
||||
CircleDashed => "circle-dashed",
|
||||
CircleDot => "circle-dot",
|
||||
CircleDotDashed => "circle-dot-dashed",
|
||||
CircleHelp => "circle-help",
|
||||
CirclePause => "circle-pause",
|
||||
CircleStop => "circle-stop",
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window, div, prelude::*, relative,
|
||||
};
|
||||
|
||||
use super::work::{IssueId, Person, Priority, Status};
|
||||
use crate::{
|
||||
forms::{Choice, Select},
|
||||
primitives::{Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
|
||||
typography::tabular,
|
||||
};
|
||||
|
||||
type OnStatus = Rc<dyn Fn(Status, &mut Window, &mut App)>;
|
||||
type OnAssignee = Rc<dyn Fn(Option<&Person>, &mut Window, &mut App)>;
|
||||
|
||||
/// A priority at a glance: three bars, as many lit as it is high, or a red mark for urgent. Its words show in a tip, or beside it when labeled.
|
||||
#[derive(IntoElement)]
|
||||
pub struct PriorityIndicator {
|
||||
id: ElementId,
|
||||
priority: Priority,
|
||||
labeled: bool,
|
||||
}
|
||||
|
||||
impl PriorityIndicator {
|
||||
pub fn new(id: impl Into<ElementId>, priority: Priority) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
priority,
|
||||
labeled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Its words beside it.
|
||||
pub fn labeled(mut self) -> Self {
|
||||
self.labeled = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PriorityIndicator {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let mark = match self.priority {
|
||||
Priority::Urgent => Icon::new(IconName::CircleAlert)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.danger)
|
||||
.into_any_element(),
|
||||
priority => div()
|
||||
.size(theme.icon_size(IconSize::Sm))
|
||||
.py_0p5()
|
||||
.flex()
|
||||
.items_end()
|
||||
.justify_center()
|
||||
.gap_0p5()
|
||||
.children([0.5, 0.75, 1.0].into_iter().enumerate().map(|(ix, tall)| {
|
||||
let lit = ix < priority.bars();
|
||||
div().w_0p5().h(relative(tall)).rounded_full().bg(if lit {
|
||||
colors.fg_muted
|
||||
} else {
|
||||
colors.border_strong
|
||||
})
|
||||
}))
|
||||
.into_any_element(),
|
||||
};
|
||||
let words = self.priority.words();
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(mark)
|
||||
.map(|row| match self.labeled {
|
||||
true => row.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(words),
|
||||
),
|
||||
false => row.tooltip(Tooltip::text(words)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An issue's name, as ENG-123, quiet and in even figures.
|
||||
#[derive(IntoElement)]
|
||||
pub struct IssueIdBadge {
|
||||
issue: IssueId,
|
||||
}
|
||||
|
||||
impl IssueIdBadge {
|
||||
pub fn new(issue: IssueId) -> Self {
|
||||
Self { issue }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for IssueIdBadge {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
tabular(div())
|
||||
.flex_none()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(self.issue.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a task stands, picked from its workflow, each status with its mark.
|
||||
#[derive(IntoElement)]
|
||||
pub struct StatusSelect {
|
||||
id: ElementId,
|
||||
status: Status,
|
||||
size: ControlSize,
|
||||
on_change: Option<OnStatus>,
|
||||
}
|
||||
|
||||
impl StatusSelect {
|
||||
pub fn new(id: impl Into<ElementId>, status: Status) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status,
|
||||
size: ControlSize::default(),
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: ControlSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_change(mut self, handler: impl Fn(Status, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for StatusSelect {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let choices =
|
||||
Status::ALL.map(|status| Choice::new(status.key(), status.words()).icon(status.icon()));
|
||||
let (id, on_change) = (self.id.clone(), self.on_change);
|
||||
Select::new(self.id, choices)
|
||||
.selected(self.status.key())
|
||||
.size(self.size)
|
||||
.on_change(move |key: &SharedString, window, cx| {
|
||||
let status = Status::of_key(key);
|
||||
log::info!("status select {id:?}: {}", status.words());
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(status, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Who has a task, picked from people by name, or no one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AssigneePicker {
|
||||
id: ElementId,
|
||||
people: Vec<Person>,
|
||||
assignee: Option<SharedString>,
|
||||
size: ControlSize,
|
||||
on_change: Option<OnAssignee>,
|
||||
}
|
||||
|
||||
impl AssigneePicker {
|
||||
pub fn new(id: impl Into<ElementId>, people: impl IntoIterator<Item = Person>) -> Self {
|
||||
let people: Vec<Person> = people.into_iter().collect();
|
||||
for (ix, person) in people.iter().enumerate() {
|
||||
let twice = people[..ix].iter().any(|other| other.key == person.key);
|
||||
assert!(!twice, "person {} twice", person.key);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
people,
|
||||
assignee: None,
|
||||
size: ControlSize::default(),
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Who has it now, by key.
|
||||
pub fn assignee(mut self, key: impl Into<SharedString>) -> Self {
|
||||
self.assignee = Some(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: ControlSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the person picked, or none for no one.
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(Option<&Person>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AssigneePicker {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
if let Some(key) = &self.assignee {
|
||||
let known = self.people.iter().any(|person| person.key == *key);
|
||||
assert!(known, "assignee picker {:?}: no person {key}", self.id);
|
||||
}
|
||||
let nobody = Choice::new("", "No assignee").icon(IconName::User);
|
||||
let choices: Vec<Choice> = std::iter::once(nobody)
|
||||
.chain(self.people.iter().map(|person| {
|
||||
Choice::new(person.key.clone(), person.name.clone()).avatar(person.name.clone())
|
||||
}))
|
||||
.collect();
|
||||
let (id, people, on_change) = (self.id.clone(), self.people, self.on_change);
|
||||
Select::new(self.id, choices)
|
||||
.selected(self.assignee.unwrap_or_default())
|
||||
.size(self.size)
|
||||
.on_change(move |key: &SharedString, window, cx| {
|
||||
let person = people.iter().find(|person| person.key == *key);
|
||||
log::info!(
|
||||
"assignee picker {id:?}: {}",
|
||||
person.map_or("no one", |person| person.name.as_ref())
|
||||
);
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(person, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod marks;
|
||||
mod tasks;
|
||||
mod work;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use marks::{AssigneePicker, IssueIdBadge, PriorityIndicator, StatusSelect};
|
||||
pub use tasks::{TaskItem, TaskList};
|
||||
pub use work::{IssueId, Person, Priority, Status, Task};
|
||||
@@ -0,0 +1,253 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
use jiff::{Timestamp, civil::Date};
|
||||
|
||||
use super::{
|
||||
marks::{IssueIdBadge, PriorityIndicator},
|
||||
work::{Status, Task, due_words},
|
||||
};
|
||||
use crate::{
|
||||
data_display::Avatar,
|
||||
forms::{Checkbox, OnFlag, OnValue},
|
||||
lists::{ListItem, SelectableList},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, AvatarSize, IconSize, TextSize},
|
||||
typography::{format, fresh},
|
||||
};
|
||||
|
||||
type OnTaskToggle = Rc<dyn Fn(&SharedString, bool, &mut Window, &mut App)>;
|
||||
|
||||
/// Today by the clock, in the system's zone.
|
||||
fn clock_today(user: &str) -> Date {
|
||||
Timestamp::now().to_zoned(format::system_zone(user)).date()
|
||||
}
|
||||
|
||||
/// A task in a row: its box, its priority and its issue's name, its title, then when it is due and who has it. Closed, the title goes quiet and struck through; past due and still open, the day reads in red.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TaskItem {
|
||||
id: ElementId,
|
||||
task: Task,
|
||||
today: Option<Date>,
|
||||
on_toggle: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl TaskItem {
|
||||
pub fn new(id: impl Into<ElementId>, task: Task) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
task,
|
||||
today: None,
|
||||
on_toggle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The day due dates count from; the clock's otherwise.
|
||||
pub fn today(mut self, day: Date) -> Self {
|
||||
self.today = Some(day);
|
||||
self
|
||||
}
|
||||
|
||||
/// A box that ticks the task done; it gets whether the box is now ticked. Without it, the status mark leads.
|
||||
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_toggle = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
fn item(self, today: Date, cx: &App) -> ListItem {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let task = self.task;
|
||||
let lead: AnyElement = match self.on_toggle {
|
||||
Some(on_toggle) => {
|
||||
let (key, named) = (task.key.clone(), task.key.clone());
|
||||
div()
|
||||
.debug_selector(move || format!("task-box {named}"))
|
||||
.flex_none()
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.on_key_down(|event, _, cx| {
|
||||
let key = event.keystroke.key.as_str();
|
||||
if matches!(key, "space" | "enter") && !event.keystroke.modifiers.modified()
|
||||
{
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Checkbox::new((self.id.clone(), "done"), task.status == Status::Done)
|
||||
.on_change(move |done, window, cx| {
|
||||
log::info!("task {key}: {}", if done { "done" } else { "open" });
|
||||
on_toggle(done, window, cx)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
None => Icon::new(task.status.icon())
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted)
|
||||
.into_any_element(),
|
||||
};
|
||||
let due = task.due.map(|due| {
|
||||
let late = task.overdue(today);
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(if late { colors.danger } else { colors.fg_muted })
|
||||
.child(due_words(due, today))
|
||||
});
|
||||
let assignee = match task.assignee {
|
||||
Some(person) => Avatar::new((self.id.clone(), "assignee"), person.name)
|
||||
.size(AvatarSize::Xs)
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
.size(theme.avatar_size(AvatarSize::Xs))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
Icon::new(IconName::User)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.into_any_element(),
|
||||
};
|
||||
let lead = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(lead)
|
||||
.child(PriorityIndicator::new(
|
||||
(self.id.clone(), "priority"),
|
||||
task.priority,
|
||||
))
|
||||
.children(task.issue.map(IssueIdBadge::new));
|
||||
let trail = div()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.children(due)
|
||||
.child(assignee);
|
||||
let closed = task.status.closed();
|
||||
ListItem::new(self.id, task.title)
|
||||
.leading(lead)
|
||||
.struck(closed)
|
||||
.quiet(closed)
|
||||
.trailing(trail)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TaskItem {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let today = match self.today {
|
||||
Some(day) => day,
|
||||
None => {
|
||||
fresh((self.id.clone(), "clock"), window, cx);
|
||||
clock_today("task item")
|
||||
}
|
||||
};
|
||||
self.item(today, cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tasks in rows: arrows move between them, Enter or a double press opens one, and each box ticks its task done.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TaskList {
|
||||
id: ElementId,
|
||||
tasks: Vec<Task>,
|
||||
today: Option<Date>,
|
||||
on_open: Option<OnValue>,
|
||||
on_toggle: Option<OnTaskToggle>,
|
||||
}
|
||||
|
||||
impl TaskList {
|
||||
pub fn new(id: impl Into<ElementId>, tasks: impl IntoIterator<Item = Task>) -> Self {
|
||||
let tasks: Vec<Task> = tasks.into_iter().collect();
|
||||
for (ix, task) in tasks.iter().enumerate() {
|
||||
let twice = tasks[..ix].iter().any(|other| other.key == task.key);
|
||||
assert!(!twice, "task {} twice", task.key);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
tasks,
|
||||
today: None,
|
||||
on_open: None,
|
||||
on_toggle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The day due dates count from; the clock's otherwise.
|
||||
pub fn today(mut self, day: Date) -> Self {
|
||||
self.today = Some(day);
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the key of the task opened.
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets a task's key and whether its box is now ticked.
|
||||
pub fn on_toggle(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_toggle = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TaskList {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
if self.tasks.is_empty() {
|
||||
return div()
|
||||
.debug_selector(|| "task-none".into())
|
||||
.min_w_full()
|
||||
.px_3()
|
||||
.py_4()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child("No tasks.")
|
||||
.into_any_element();
|
||||
}
|
||||
let today = match self.today {
|
||||
Some(day) => day,
|
||||
None => {
|
||||
fresh((self.id.clone(), "clock"), window, cx);
|
||||
clock_today("task list")
|
||||
}
|
||||
};
|
||||
let list = self.tasks.into_iter().fold(
|
||||
SelectableList::new((self.id.clone(), "tasks")),
|
||||
|list, task| {
|
||||
let key = task.key.clone();
|
||||
let item = TaskItem::new((self.id.clone(), format!("task-{key}")), task);
|
||||
let item = match self.on_toggle.clone() {
|
||||
Some(toggle) => {
|
||||
let ticked = key.clone();
|
||||
item.on_toggle(move |done, window, cx| toggle(&ticked, done, window, cx))
|
||||
}
|
||||
None => item,
|
||||
};
|
||||
list.row(key, item.item(today, cx))
|
||||
},
|
||||
);
|
||||
let (id, on_open) = (self.id.clone(), self.on_open);
|
||||
div()
|
||||
.debug_selector(|| "task-list".into())
|
||||
.min_w_full()
|
||||
.child(list.on_activate(move |key, window, cx| {
|
||||
log::info!("task list {id:?}: open {key}");
|
||||
if let Some(on_open) = &on_open {
|
||||
on_open(key, window, cx);
|
||||
}
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers,
|
||||
ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px,
|
||||
};
|
||||
use jiff::civil::date;
|
||||
|
||||
use super::{AssigneePicker, IssueId, Person, Priority, Status, StatusSelect, Task, TaskList};
|
||||
use crate::{documents::Checklist, forms::bind_keys, primitives::FocusNext, theme::Theme};
|
||||
|
||||
/// A view that shows one project part and keeps what it heard.
|
||||
struct Desk {
|
||||
part: fn(Entity<Desk>) -> AnyElement,
|
||||
heard: Vec<String>,
|
||||
}
|
||||
|
||||
impl Render for Desk {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().w(px(640.0)).child((self.part)(cx.entity()))
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn desk(
|
||||
part: fn(Entity<Desk>) -> AnyElement,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Desk>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
bind_keys(cx);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|_, _| Desk {
|
||||
part,
|
||||
heard: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn note(owner: &Entity<Desk>, what: String, cx: &mut gpui::App) {
|
||||
owner.update(cx, |desk, cx| {
|
||||
desk.heard.push(what);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn heard(host: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||
host.read_with(cx, |desk, _| desk.heard.clone())
|
||||
}
|
||||
|
||||
/// Moves focus to the `nth` Tab stop from none.
|
||||
fn tab_to(nth: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
for _ in 0..nth {
|
||||
window.focus_next();
|
||||
}
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
settle(cx);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
keystroke: Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn tasks() -> Vec<Task> {
|
||||
let ana = Person::new("ana", "Ana Lima");
|
||||
vec![
|
||||
Task::new("brief", "Write the brief")
|
||||
.issue(IssueId::new("ENG", 12))
|
||||
.priority(Priority::High)
|
||||
.due(date(2026, 9, 25))
|
||||
.assignee(ana),
|
||||
Task::new("specs", "Check the specs").status(Status::Done),
|
||||
]
|
||||
}
|
||||
|
||||
fn list(owner: Entity<Desk>) -> AnyElement {
|
||||
let (opened, ticked) = (owner.clone(), owner);
|
||||
TaskList::new("tasks", tasks())
|
||||
.today(date(2026, 9, 27))
|
||||
.on_open(move |key, _, cx| note(&opened, format!("open {key}"), cx))
|
||||
.on_toggle(move |key, done, _, cx| note(&ticked, format!("tick {key} {done}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_box_ticks_its_task_and_leaves_the_row_alone(cx: &mut TestAppContext) {
|
||||
let (host, cx) = desk(list, cx);
|
||||
let tick = cx.debug_bounds("task-box specs").expect("the box draws");
|
||||
cx.simulate_click(tick.center(), Modifiers::none());
|
||||
settle(cx);
|
||||
tab_to(2, cx);
|
||||
press("enter", cx);
|
||||
tab_to(1, cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
heard(&host, cx),
|
||||
["tick specs false", "tick brief true", "open brief"],
|
||||
"a press on a box leaves the cursor, and Enter on a box does not open its row"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_opens_the_task_under_the_cursor(cx: &mut TestAppContext) {
|
||||
let (host, cx) = desk(list, cx);
|
||||
tab_to(1, cx);
|
||||
press("enter", cx);
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(heard(&host, cx), ["open brief", "open specs"]);
|
||||
}
|
||||
|
||||
fn status(owner: Entity<Desk>) -> AnyElement {
|
||||
StatusSelect::new("status", Status::Todo)
|
||||
.on_change(move |status, _, cx| note(&owner, status.words().into(), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_status_picked_by_key_hands_on_its_status(cx: &mut TestAppContext) {
|
||||
let (host, cx) = desk(status, cx);
|
||||
tab_to(1, cx);
|
||||
cx.simulate_keystrokes("down down enter");
|
||||
settle(cx);
|
||||
assert_eq!(heard(&host, cx), ["In progress"]);
|
||||
}
|
||||
|
||||
fn assignee(owner: Entity<Desk>) -> AnyElement {
|
||||
let people = [
|
||||
Person::new("ana", "Ana Lima"),
|
||||
Person::new("ben", "Ben Ito"),
|
||||
];
|
||||
AssigneePicker::new("assignee", people)
|
||||
.assignee("ana")
|
||||
.on_change(move |person, _, cx| {
|
||||
let name = person.map_or("no one".into(), |person| person.name.to_string());
|
||||
note(&owner, name, cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_picker_hands_on_a_person_or_no_one(cx: &mut TestAppContext) {
|
||||
let (host, cx) = desk(assignee, cx);
|
||||
tab_to(1, cx);
|
||||
cx.simulate_keystrokes("down down enter");
|
||||
settle(cx);
|
||||
tab_to(1, cx);
|
||||
cx.simulate_keystrokes("down up enter");
|
||||
settle(cx);
|
||||
assert_eq!(heard(&host, cx), ["Ben Ito", "no one"]);
|
||||
}
|
||||
|
||||
fn checklist(owner: Entity<Desk>) -> AnyElement {
|
||||
Checklist::new(
|
||||
"subtasks",
|
||||
"Subtasks",
|
||||
[("Outline", true), ("Draft", false)],
|
||||
)
|
||||
.on_add("Add a subtask", move |words, _, cx| {
|
||||
note(&owner, format!("add {words}"), cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_checklist_adds_what_is_typed_and_clears(cx: &mut TestAppContext) {
|
||||
let (host, cx) = desk(checklist, cx);
|
||||
tab_to(3, cx);
|
||||
cx.simulate_input(" Review ");
|
||||
press("enter", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
heard(&host, cx),
|
||||
["add Review"],
|
||||
"the field clears, and empty adds nothing"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
use std::fmt;
|
||||
|
||||
use gpui::SharedString;
|
||||
use jiff::civil::Date;
|
||||
|
||||
use crate::primitives::IconName;
|
||||
|
||||
/// Where a task stands in its workflow.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Backlog,
|
||||
Todo,
|
||||
InProgress,
|
||||
InReview,
|
||||
Done,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub const ALL: [Status; 6] = [
|
||||
Status::Backlog,
|
||||
Status::Todo,
|
||||
Status::InProgress,
|
||||
Status::InReview,
|
||||
Status::Done,
|
||||
Status::Canceled,
|
||||
];
|
||||
|
||||
pub fn words(self) -> &'static str {
|
||||
match self {
|
||||
Status::Backlog => "Backlog",
|
||||
Status::Todo => "Todo",
|
||||
Status::InProgress => "In progress",
|
||||
Status::InReview => "In review",
|
||||
Status::Done => "Done",
|
||||
Status::Canceled => "Canceled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(self) -> IconName {
|
||||
match self {
|
||||
Status::Backlog => IconName::CircleDashed,
|
||||
Status::Todo => IconName::Circle,
|
||||
Status::InProgress => IconName::CircleDot,
|
||||
Status::InReview => IconName::CircleDotDashed,
|
||||
Status::Done => IconName::CircleCheck,
|
||||
Status::Canceled => IconName::CircleX,
|
||||
}
|
||||
}
|
||||
|
||||
/// Done or canceled: nothing more is owed.
|
||||
pub fn closed(self) -> bool {
|
||||
matches!(self, Status::Done | Status::Canceled)
|
||||
}
|
||||
|
||||
pub(crate) fn key(self) -> &'static str {
|
||||
match self {
|
||||
Status::Backlog => "backlog",
|
||||
Status::Todo => "todo",
|
||||
Status::InProgress => "in-progress",
|
||||
Status::InReview => "in-review",
|
||||
Status::Done => "done",
|
||||
Status::Canceled => "canceled",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn of_key(key: &str) -> Status {
|
||||
Status::ALL
|
||||
.into_iter()
|
||||
.find(|status| status.key() == key)
|
||||
.unwrap_or_else(|| panic!("no status {key}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// How urgent a task is, from none to urgent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Priority {
|
||||
None,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Urgent,
|
||||
}
|
||||
|
||||
impl Priority {
|
||||
pub fn words(self) -> &'static str {
|
||||
match self {
|
||||
Priority::None => "No priority",
|
||||
Priority::Low => "Low",
|
||||
Priority::Medium => "Medium",
|
||||
Priority::High => "High",
|
||||
Priority::Urgent => "Urgent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Bars lit of three; urgent has a mark of its own.
|
||||
pub(crate) fn bars(self) -> usize {
|
||||
match self {
|
||||
Priority::None => 0,
|
||||
Priority::Low => 1,
|
||||
Priority::Medium => 2,
|
||||
Priority::High | Priority::Urgent => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An issue's name within its team: the team's key and a number, as ENG-123.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IssueId {
|
||||
team: SharedString,
|
||||
number: u32,
|
||||
}
|
||||
|
||||
impl IssueId {
|
||||
/// The key is one to five capital letters, and numbers start at one.
|
||||
pub fn new(team: impl Into<SharedString>, number: u32) -> Self {
|
||||
let team = team.into();
|
||||
let letters = (1..=5).contains(&team.len()) && team.chars().all(|c| c.is_ascii_uppercase());
|
||||
assert!(letters, "issue team key {team} is not one to five capitals");
|
||||
assert!(number > 0, "issue {team} numbers start at one");
|
||||
Self { team, number }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for IssueId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}-{}", self.team, self.number)
|
||||
}
|
||||
}
|
||||
|
||||
/// Someone work can go to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Person {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(key: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
|
||||
let key = key.into();
|
||||
assert!(!key.is_empty(), "a person needs a key");
|
||||
Self {
|
||||
key,
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of work: its key, its issue's name if it has one, what it is, where it stands, how urgent, when it is due, and who has it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Task {
|
||||
pub key: SharedString,
|
||||
pub issue: Option<IssueId>,
|
||||
pub title: SharedString,
|
||||
pub status: Status,
|
||||
pub priority: Priority,
|
||||
pub due: Option<Date>,
|
||||
pub assignee: Option<Person>,
|
||||
}
|
||||
|
||||
impl Task {
|
||||
pub fn new(key: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
issue: None,
|
||||
title: title.into(),
|
||||
status: Status::Todo,
|
||||
priority: Priority::None,
|
||||
due: None,
|
||||
assignee: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn issue(mut self, id: IssueId) -> Self {
|
||||
self.issue = Some(id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn status(mut self, status: Status) -> Self {
|
||||
self.status = status;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn priority(mut self, priority: Priority) -> Self {
|
||||
self.priority = priority;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn due(mut self, day: Date) -> Self {
|
||||
self.due = Some(day);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn assignee(mut self, person: Person) -> Self {
|
||||
self.assignee = Some(person);
|
||||
self
|
||||
}
|
||||
|
||||
/// Due before `today` and still open.
|
||||
pub fn overdue(&self, today: Date) -> bool {
|
||||
!self.status.closed() && self.due.is_some_and(|due| due < today)
|
||||
}
|
||||
}
|
||||
|
||||
/// A due day in few words from `today`: Today, Tomorrow, Yesterday, a weekday within the week ahead, else the date, with its year when not this one.
|
||||
pub(crate) fn due_words(due: Date, today: Date) -> String {
|
||||
let days = due.since(today).expect("days between two dates").get_days();
|
||||
match days {
|
||||
0 => "Today".into(),
|
||||
1 => "Tomorrow".into(),
|
||||
-1 => "Yesterday".into(),
|
||||
2..=6 => due.strftime("%a").to_string(),
|
||||
_ if due.year() == today.year() => due.strftime("%b %-d").to_string(),
|
||||
_ => due.strftime("%b %-d, %Y").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::civil::date;
|
||||
|
||||
use super::{IssueId, Priority, Status, Task, due_words};
|
||||
|
||||
#[test]
|
||||
fn due_days_read_near_then_far() {
|
||||
let today = date(2026, 9, 27);
|
||||
let words: Vec<String> = [27, 28, 26, 30]
|
||||
.map(|day| due_words(date(2026, 9, day), today))
|
||||
.into();
|
||||
assert_eq!(words, ["Today", "Tomorrow", "Yesterday", "Wed"]);
|
||||
assert_eq!(due_words(date(2026, 10, 4), today), "Oct 4");
|
||||
assert_eq!(due_words(date(2026, 9, 20), today), "Sep 20");
|
||||
assert_eq!(due_words(date(2027, 1, 8), today), "Jan 8, 2027");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statuses_go_by_their_keys() {
|
||||
for status in Status::ALL {
|
||||
assert_eq!(Status::of_key(status.key()), status);
|
||||
}
|
||||
assert!(Status::Canceled.closed() && !Status::InReview.closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priorities_rise_and_light_their_bars() {
|
||||
assert!(Priority::Urgent > Priority::High && Priority::Low > Priority::None);
|
||||
let bars = [
|
||||
Priority::None,
|
||||
Priority::Low,
|
||||
Priority::Medium,
|
||||
Priority::High,
|
||||
]
|
||||
.map(Priority::bars);
|
||||
assert_eq!(bars, [0, 1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_open_task_past_its_day_is_overdue() {
|
||||
let today = date(2026, 9, 27);
|
||||
let late = Task::new("a", "A").due(date(2026, 9, 26));
|
||||
assert!(late.overdue(today));
|
||||
assert!(!late.clone().status(Status::Canceled).overdue(today));
|
||||
assert!(!late.due(today).overdue(today));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_issue_reads_as_its_team_and_number() {
|
||||
assert_eq!(IssueId::new("ENG", 123).to_string(), "ENG-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "issue team key eng is not one to five capitals")]
|
||||
fn a_team_key_is_in_capitals() {
|
||||
IssueId::new("eng", 1);
|
||||
}
|
||||
}
|
||||
@@ -40,14 +40,7 @@ pub(crate) fn text_width(text: &str, size: Pixels, window: &Window) -> Pixels {
|
||||
|
||||
fn shape(text: &str, window: &mut Window) -> ShapedLine {
|
||||
let style = window.text_style();
|
||||
let run = TextRun {
|
||||
len: text.len(),
|
||||
font: style.font(),
|
||||
color: style.color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
let run = style.to_run(text.len());
|
||||
let size = style.font_size.to_pixels(window.rem_size());
|
||||
window
|
||||
.text_system()
|
||||
|
||||
Reference in new issue
Block a user