Style assistant panel (#3711)
[[PR Description]] Styles most of the assistant panel. A few notes: - We now cut off the title if it gets to long so the assistant tools don't get cut off - I wasn't able to get to the "no api key" state, so that hasn't been style checked yet. - A few of icons were updated in this PR I also added a new tooltip that teaches you a bit about role cycling:  🐜 Known issues 🐜 - There is a bug where zooming the panel makes it shift 1px (@maxdeviant I think this has to do with panel borders) - We are showing a timestamp for new conversations before you have sent a message/launched an assist action. I wasn't sure how to case this out. Before:   After:   Release Notes: - N/A
|
@ -1,4 +1 @@
|
|||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.5 1.5H13.5M13.5 1.5V5.5M13.5 1.5C12.1332 2.86683 10.3668 4.63317 9 6" stroke="white" stroke-linecap="round"/>
|
||||
<path d="M1.5 9.5V13.5M1.5 13.5L6 9M1.5 13.5H5.5" stroke="white" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize-2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/></svg>
|
||||
|
|
Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 367 B |
|
@ -1,3 +1 @@
|
|||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 3C1.22386 3 1 3.22386 1 3.5C1 3.77614 1.22386 4 1.5 4H13.5C13.7761 4 14 3.77614 14 3.5C14 3.22386 13.7761 3 13.5 3H1.5ZM1 7.5C1 7.22386 1.22386 7 1.5 7H13.5C13.7761 7 14 7.22386 14 7.5C14 7.77614 13.7761 8 13.5 8H1.5C1.22386 8 1 7.77614 1 7.5ZM1 11.5C1 11.2239 1.22386 11 1.5 11H13.5C13.7761 11 14 11.2239 14 11.5C14 11.7761 13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z" fill="#CCCAC2"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
|
||||
|
|
Before Width: | Height: | Size: 552 B After Width: | Height: | Size: 327 B |
|
@ -1,4 +1 @@
|
|||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 6L9 6M9 6L9 2M9 6C10.3668 4.63316 12.1332 2.86683 13.5 1.5" stroke="white" stroke-linecap="round"/>
|
||||
<path d="M6 13L6 9M6 9L1.5 13.5M6 9L2 9" stroke="white" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minimize-2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/></svg>
|
||||
|
|
Before Width: | Height: | Size: 297 B After Width: | Height: | Size: 371 B |
|
@ -1,8 +1 @@
|
|||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M9.42503 3.44136C10.0561 3.23654 10.7837 3.2402 11.3792 3.54623C12.7532 4.25224 13.3477 6.07191 12.7946 8C12.5465 8.8649 12.1102 9.70472 11.1861 10.5524C10.262 11.4 8.98034 11.9 8.38571 11.9C8.17269 11.9 8 11.7321 8 11.525C8 11.3179 8.17644 11.15 8.38571 11.15C9.06497 11.15 9.67189 10.7804 10.3906 10.236C10.9406 9.8193 11.3701 9.28633 11.608 8.82191C12.0628 7.93367 12.0782 6.68174 11.3433 6.34901C10.9904 6.73455 10.5295 6.95946 9.97725 6.95946C8.7773 6.95946 8.0701 5.99412 8.10051 5.12009C8.12957 4.28474 8.66032 3.68954 9.42503 3.44136ZM3.42503 3.44136C4.05614 3.23654 4.78366 3.2402 5.37923 3.54623C6.7532 4.25224 7.34766 6.07191 6.79462 8C6.54654 8.8649 6.11019 9.70472 5.1861 10.5524C4.26201 11.4 2.98034 11.9 2.38571 11.9C2.17269 11.9 2 11.7321 2 11.525C2 11.3179 2.17644 11.15 2.38571 11.15C3.06497 11.15 3.67189 10.7804 4.39058 10.236C4.94065 9.8193 5.37014 9.28633 5.60797 8.82191C6.06282 7.93367 6.07821 6.68174 5.3433 6.34901C4.99037 6.73455 4.52948 6.95946 3.97725 6.95946C2.7773 6.95946 2.0701 5.99412 2.10051 5.12009C2.12957 4.28474 2.66032 3.68954 3.42503 3.44136Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-text-quote"><path d="M17 6H3"/><path d="M21 12H8"/><path d="M21 18H8"/><path d="M3 12v6"/></svg>
|
||||
|
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 299 B |
1
assets/icons/snip.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scissors"><circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/></svg>
|
After Width: | Height: | Size: 362 B |
|
@ -1 +0,0 @@
|
|||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.81832 0.68179C7.64258 0.506054 7.35766 0.506054 7.18192 0.68179L5.18192 2.68179C5.00619 2.85753 5.00619 3.14245 5.18192 3.31819C5.35766 3.49392 5.64258 3.49392 5.81832 3.31819L7.05012 2.08638L7.05012 5.50023C7.05012 5.74876 7.25159 5.95023 7.50012 5.95023C7.74865 5.95023 7.95012 5.74876 7.95012 5.50023L7.95012 2.08638L9.18192 3.31819C9.35766 3.49392 9.64258 3.49392 9.81832 3.31819C9.99406 3.14245 9.99406 2.85753 9.81832 2.68179L7.81832 0.68179ZM7.95012 12.9136V9.50023C7.95012 9.2517 7.74865 9.05023 7.50012 9.05023C7.25159 9.05023 7.05012 9.2517 7.05012 9.50023V12.9136L5.81832 11.6818C5.64258 11.5061 5.35766 11.5061 5.18192 11.6818C5.00619 11.8575 5.00619 12.1424 5.18192 12.3182L7.18192 14.3182C7.26632 14.4026 7.38077 14.45 7.50012 14.45C7.61947 14.45 7.73393 14.4026 7.81832 14.3182L9.81832 12.3182C9.99406 12.1424 9.99406 11.8575 9.81832 11.6818C9.64258 11.5061 9.35766 11.5061 9.18192 11.6818L7.95012 12.9136ZM1.49994 7.00017C1.2238 7.00017 0.999939 7.22403 0.999939 7.50017C0.999939 7.77631 1.2238 8.00017 1.49994 8.00017L13.4999 8.00017C13.7761 8.00017 13.9999 7.77631 13.9999 7.50017C13.9999 7.22403 13.7761 7.00017 13.4999 7.00017L1.49994 7.00017Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
|
Before Width: | Height: | Size: 1.3 KiB |
|
@ -54,7 +54,9 @@ use std::{
|
|||
};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
h_stack, prelude::*, v_stack, Button, ButtonLike, Icon, IconButton, IconElement, Label, Tooltip,
|
||||
prelude::*,
|
||||
utils::{DateTimeType, FormatDistance},
|
||||
ButtonLike, Tab, TabBar, Tooltip,
|
||||
};
|
||||
use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt};
|
||||
use uuid::Uuid;
|
||||
|
@ -939,7 +941,7 @@ impl AssistantPanel {
|
|||
this.set_active_editor_index(this.prev_active_editor_index, cx);
|
||||
}
|
||||
}))
|
||||
.tooltip(|cx| Tooltip::text("History", cx))
|
||||
.tooltip(|cx| Tooltip::text("Conversation History", cx))
|
||||
}
|
||||
|
||||
fn render_editor_tools(&self, cx: &mut ViewContext<Self>) -> Vec<AnyElement> {
|
||||
|
@ -955,12 +957,13 @@ impl AssistantPanel {
|
|||
}
|
||||
|
||||
fn render_split_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
IconButton::new("split_button", Icon::SplitMessage)
|
||||
IconButton::new("split_button", Icon::Snip)
|
||||
.on_click(cx.listener(|this, _event, cx| {
|
||||
if let Some(active_editor) = this.active_editor() {
|
||||
active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx));
|
||||
}
|
||||
}))
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(|cx| Tooltip::for_action("Split Message", &Split, cx))
|
||||
}
|
||||
|
||||
|
@ -971,6 +974,7 @@ impl AssistantPanel {
|
|||
active_editor.update(cx, |editor, cx| editor.assist(&Default::default(), cx));
|
||||
}
|
||||
}))
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(|cx| Tooltip::for_action("Assist", &Assist, cx))
|
||||
}
|
||||
|
||||
|
@ -985,6 +989,7 @@ impl AssistantPanel {
|
|||
});
|
||||
}
|
||||
}))
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(|cx| Tooltip::for_action("Quote Seleciton", &QuoteSelection, cx))
|
||||
}
|
||||
|
||||
|
@ -993,15 +998,19 @@ impl AssistantPanel {
|
|||
.on_click(cx.listener(|this, _event, cx| {
|
||||
this.new_conversation(cx);
|
||||
}))
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(|cx| Tooltip::for_action("New Conversation", &NewConversation, cx))
|
||||
}
|
||||
|
||||
fn render_zoom_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let zoomed = self.zoomed;
|
||||
IconButton::new("zoom_button", Icon::MagnifyingGlass)
|
||||
IconButton::new("zoom_button", Icon::Maximize)
|
||||
.on_click(cx.listener(|this, _event, cx| {
|
||||
this.toggle_zoom(&ToggleZoom, cx);
|
||||
}))
|
||||
.selected(zoomed)
|
||||
.selected_icon(Icon::Minimize)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::for_action(if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, cx)
|
||||
})
|
||||
|
@ -1020,10 +1029,19 @@ impl AssistantPanel {
|
|||
this.open_conversation(path.clone(), cx)
|
||||
.detach_and_log_err(cx)
|
||||
}))
|
||||
.child(Label::new(
|
||||
conversation.mtime.format("%F %I:%M%p").to_string(),
|
||||
))
|
||||
.child(Label::new(conversation.title.clone()))
|
||||
.full_width()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
Label::new(conversation.mtime.format("%F %I:%M%p").to_string())
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
.child(Label::new(conversation.title.clone()).size(LabelSize::Small)),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_conversation(&mut self, path: PathBuf, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
|
||||
|
@ -1112,20 +1130,35 @@ impl Render for AssistantPanel {
|
|||
.border()
|
||||
.border_color(gpui::red())
|
||||
} else {
|
||||
let title = self
|
||||
.active_editor()
|
||||
.map(|editor| Label::new(editor.read(cx).title(cx)));
|
||||
|
||||
let mut header = h_stack()
|
||||
.child(Self::render_hamburger_button(cx))
|
||||
.children(title);
|
||||
|
||||
if self.focus_handle.contains_focused(cx) {
|
||||
header = header
|
||||
.children(self.render_editor_tools(cx))
|
||||
.child(Self::render_plus_button(cx))
|
||||
.child(self.render_zoom_button(cx));
|
||||
}
|
||||
let header = TabBar::new("assistant_header")
|
||||
.start_child(
|
||||
h_stack().gap_1().child(Self::render_hamburger_button(cx)), // .children(title),
|
||||
)
|
||||
.children(self.active_editor().map(|editor| {
|
||||
h_stack()
|
||||
.h(rems(Tab::HEIGHT_IN_REMS))
|
||||
.flex_1()
|
||||
.px_2()
|
||||
.child(Label::new(editor.read(cx).title(cx)).into_element())
|
||||
}))
|
||||
.end_child(if self.focus_handle.contains_focused(cx) {
|
||||
h_stack()
|
||||
.gap_2()
|
||||
.child(h_stack().gap_1().children(self.render_editor_tools(cx)))
|
||||
.child(
|
||||
ui::Divider::vertical()
|
||||
.inset()
|
||||
.color(ui::DividerColor::Border),
|
||||
)
|
||||
.child(
|
||||
h_stack()
|
||||
.gap_1()
|
||||
.child(Self::render_plus_button(cx))
|
||||
.child(self.render_zoom_button(cx)),
|
||||
)
|
||||
} else {
|
||||
div()
|
||||
});
|
||||
|
||||
v_stack()
|
||||
.size_full()
|
||||
|
@ -1165,8 +1198,6 @@ impl Render for AssistantPanel {
|
|||
.into_any_element()
|
||||
}),
|
||||
)
|
||||
.border()
|
||||
.border_color(gpui::red())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2251,6 +2282,14 @@ impl ConversationEditor {
|
|||
}
|
||||
Role::System => Label::new("System").color(Color::Warning),
|
||||
})
|
||||
.tooltip(|cx| {
|
||||
Tooltip::with_meta(
|
||||
"Toggle message role",
|
||||
None,
|
||||
"Available roles: You (User), Assistant, System",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click({
|
||||
let conversation = conversation.clone();
|
||||
move |_, cx| {
|
||||
|
@ -2265,10 +2304,22 @@ impl ConversationEditor {
|
|||
|
||||
h_stack()
|
||||
.id(("message_header", message_id.0))
|
||||
.border()
|
||||
.border_color(gpui::red())
|
||||
.h_11()
|
||||
.gap_1()
|
||||
.p_1()
|
||||
.child(sender)
|
||||
.child(Label::new(message.sent_at.format("%I:%M%P").to_string()))
|
||||
// TODO: Only show this if the message if the message has been sent
|
||||
.child(
|
||||
Label::new(
|
||||
FormatDistance::from_now(DateTimeType::Local(
|
||||
message.sent_at,
|
||||
))
|
||||
.hide_prefix(true)
|
||||
.add_suffix(true)
|
||||
.to_string(),
|
||||
)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.children(
|
||||
if let MessageStatus::Error(error) = message.status.clone() {
|
||||
Some(
|
||||
|
@ -2429,6 +2480,7 @@ impl ConversationEditor {
|
|||
"current_model",
|
||||
self.conversation.read(cx).model.short_name(),
|
||||
)
|
||||
.style(ButtonStyle::Filled)
|
||||
.tooltip(move |cx| Tooltip::text("Change Model", cx))
|
||||
.on_click(cx.listener(|this, _, cx| this.cycle_model(cx)))
|
||||
}
|
||||
|
@ -2442,12 +2494,7 @@ impl ConversationEditor {
|
|||
} else {
|
||||
Color::Default
|
||||
};
|
||||
Some(
|
||||
div()
|
||||
.border()
|
||||
.border_color(gpui::red())
|
||||
.child(Label::new(remaining_tokens.to_string()).color(remaining_tokens_color)),
|
||||
)
|
||||
Some(Label::new(remaining_tokens.to_string()).color(remaining_tokens_color))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2459,15 +2506,21 @@ impl Render for ConversationEditor {
|
|||
fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
|
||||
div()
|
||||
.key_context("ConversationEditor")
|
||||
.size_full()
|
||||
.relative()
|
||||
.capture_action(cx.listener(ConversationEditor::cancel_last_assist))
|
||||
.capture_action(cx.listener(ConversationEditor::save))
|
||||
.capture_action(cx.listener(ConversationEditor::copy))
|
||||
.capture_action(cx.listener(ConversationEditor::cycle_message_role))
|
||||
.on_action(cx.listener(ConversationEditor::assist))
|
||||
.on_action(cx.listener(ConversationEditor::split))
|
||||
.child(self.editor.clone())
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.pl_2()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(self.editor.clone()),
|
||||
)
|
||||
.child(
|
||||
h_stack()
|
||||
.absolute()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use gpui::{Div, IntoElement};
|
||||
use gpui::{Div, Hsla, IntoElement};
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
|
@ -7,9 +7,26 @@ enum DividerDirection {
|
|||
Vertical,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub enum DividerColor {
|
||||
Border,
|
||||
#[default]
|
||||
BorderVariant,
|
||||
}
|
||||
|
||||
impl DividerColor {
|
||||
pub fn hsla(self, cx: &WindowContext) -> Hsla {
|
||||
match self {
|
||||
DividerColor::Border => cx.theme().colors().border,
|
||||
DividerColor::BorderVariant => cx.theme().colors().border_variant,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Divider {
|
||||
direction: DividerDirection,
|
||||
color: DividerColor,
|
||||
inset: bool,
|
||||
}
|
||||
|
||||
|
@ -26,7 +43,7 @@ impl RenderOnce for Divider {
|
|||
this.w_px().h_full().when(self.inset, |this| this.my_1p5())
|
||||
}
|
||||
})
|
||||
.bg(cx.theme().colors().border_variant)
|
||||
.bg(self.color.hsla(cx))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -34,6 +51,7 @@ impl Divider {
|
|||
pub fn horizontal() -> Self {
|
||||
Self {
|
||||
direction: DividerDirection::Horizontal,
|
||||
color: DividerColor::default(),
|
||||
inset: false,
|
||||
}
|
||||
}
|
||||
|
@ -41,6 +59,7 @@ impl Divider {
|
|||
pub fn vertical() -> Self {
|
||||
Self {
|
||||
direction: DividerDirection::Vertical,
|
||||
color: DividerColor::default(),
|
||||
inset: false,
|
||||
}
|
||||
}
|
||||
|
@ -49,4 +68,9 @@ impl Divider {
|
|||
self.inset = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: DividerColor) -> Self {
|
||||
self.color = color;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
|
@ -75,6 +75,7 @@ pub enum Icon {
|
|||
MagnifyingGlass,
|
||||
MailOpen,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Menu,
|
||||
MessageBubbles,
|
||||
Mic,
|
||||
|
@ -88,7 +89,7 @@ pub enum Icon {
|
|||
Screen,
|
||||
SelectAll,
|
||||
Split,
|
||||
SplitMessage,
|
||||
Snip,
|
||||
Terminal,
|
||||
WholeWord,
|
||||
XCircle,
|
||||
|
@ -156,6 +157,7 @@ impl Icon {
|
|||
Icon::MagnifyingGlass => "icons/magnifying_glass.svg",
|
||||
Icon::MailOpen => "icons/mail-open.svg",
|
||||
Icon::Maximize => "icons/maximize.svg",
|
||||
Icon::Minimize => "icons/minimize.svg",
|
||||
Icon::Menu => "icons/menu.svg",
|
||||
Icon::MessageBubbles => "icons/conversations.svg",
|
||||
Icon::Mic => "icons/mic.svg",
|
||||
|
@ -169,7 +171,7 @@ impl Icon {
|
|||
Icon::Screen => "icons/desktop.svg",
|
||||
Icon::SelectAll => "icons/select-all.svg",
|
||||
Icon::Split => "icons/split.svg",
|
||||
Icon::SplitMessage => "icons/split_message.svg",
|
||||
Icon::Snip => "icons/snip.svg",
|
||||
Icon::Terminal => "icons/terminal.svg",
|
||||
Icon::WholeWord => "icons/word_search.svg",
|
||||
Icon::XCircle => "icons/error.svg",
|
||||
|
|
|
@ -48,6 +48,8 @@ impl Tab {
|
|||
}
|
||||
}
|
||||
|
||||
pub const HEIGHT_IN_REMS: f32 = 30. / 16.;
|
||||
|
||||
pub fn position(mut self, position: TabPosition) -> Self {
|
||||
self.position = position;
|
||||
self
|
||||
|
@ -94,8 +96,6 @@ impl RenderOnce for Tab {
|
|||
type Rendered = Stateful<Div>;
|
||||
|
||||
fn render(self, cx: &mut WindowContext) -> Self::Rendered {
|
||||
const HEIGHT_IN_REMS: f32 = 30. / 16.;
|
||||
|
||||
let (text_color, tab_bg, _tab_hover_bg, _tab_active_bg) = match self.selected {
|
||||
false => (
|
||||
cx.theme().colors().text_muted,
|
||||
|
@ -112,7 +112,7 @@ impl RenderOnce for Tab {
|
|||
};
|
||||
|
||||
self.div
|
||||
.h(rems(HEIGHT_IN_REMS))
|
||||
.h(rems(Self::HEIGHT_IN_REMS))
|
||||
.bg(tab_bg)
|
||||
.border_color(cx.theme().colors().border)
|
||||
.map(|this| match self.position {
|
||||
|
|
|
@ -1,4 +1,72 @@
|
|||
use chrono::NaiveDateTime;
|
||||
use chrono::{DateTime, Local, NaiveDateTime};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DateTimeType {
|
||||
Naive(NaiveDateTime),
|
||||
Local(DateTime<Local>),
|
||||
}
|
||||
|
||||
impl DateTimeType {
|
||||
/// Converts the DateTimeType to a NaiveDateTime.
|
||||
///
|
||||
/// If the DateTimeType is already a NaiveDateTime, it will be returned as is.
|
||||
/// If the DateTimeType is a DateTime<Local>, it will be converted to a NaiveDateTime.
|
||||
pub fn to_naive(&self) -> NaiveDateTime {
|
||||
match self {
|
||||
DateTimeType::Naive(naive) => *naive,
|
||||
DateTimeType::Local(local) => local.naive_local(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FormatDistance {
|
||||
date: DateTimeType,
|
||||
base_date: DateTimeType,
|
||||
include_seconds: bool,
|
||||
add_suffix: bool,
|
||||
hide_prefix: bool,
|
||||
}
|
||||
|
||||
impl FormatDistance {
|
||||
pub fn new(date: DateTimeType, base_date: DateTimeType) -> Self {
|
||||
Self {
|
||||
date,
|
||||
base_date,
|
||||
include_seconds: false,
|
||||
add_suffix: false,
|
||||
hide_prefix: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_now(date: DateTimeType) -> Self {
|
||||
Self::new(date, DateTimeType::Local(Local::now()))
|
||||
}
|
||||
|
||||
pub fn to_string(self) -> String {
|
||||
format_distance(
|
||||
self.date,
|
||||
self.base_date.to_naive(),
|
||||
self.include_seconds,
|
||||
self.add_suffix,
|
||||
self.hide_prefix,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn include_seconds(mut self, include_seconds: bool) -> Self {
|
||||
self.include_seconds = include_seconds;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_suffix(mut self, add_suffix: bool) -> Self {
|
||||
self.add_suffix = add_suffix;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn hide_prefix(mut self, hide_prefix: bool) -> Self {
|
||||
self.hide_prefix = hide_prefix;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the distance in seconds between two NaiveDateTime objects.
|
||||
/// It returns a signed integer denoting the difference. If `date` is earlier than `base_date`, the returned value will be negative.
|
||||
|
@ -13,7 +81,12 @@ fn distance_in_seconds(date: NaiveDateTime, base_date: NaiveDateTime) -> i64 {
|
|||
}
|
||||
|
||||
/// Generates a string describing the time distance between two dates in a human-readable way.
|
||||
fn distance_string(distance: i64, include_seconds: bool, add_suffix: bool) -> String {
|
||||
fn distance_string(
|
||||
distance: i64,
|
||||
include_seconds: bool,
|
||||
add_suffix: bool,
|
||||
hide_prefix: bool,
|
||||
) -> String {
|
||||
let suffix = if distance < 0 { " from now" } else { " ago" };
|
||||
|
||||
let distance = distance.abs();
|
||||
|
@ -24,53 +97,128 @@ fn distance_string(distance: i64, include_seconds: bool, add_suffix: bool) -> St
|
|||
let months = distance / 2_592_000;
|
||||
|
||||
let string = if distance < 5 && include_seconds {
|
||||
"less than 5 seconds".to_string()
|
||||
if hide_prefix {
|
||||
"5 seconds"
|
||||
} else {
|
||||
"less than 5 seconds"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 10 && include_seconds {
|
||||
"less than 10 seconds".to_string()
|
||||
if hide_prefix {
|
||||
"10 seconds"
|
||||
} else {
|
||||
"less than 10 seconds"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 20 && include_seconds {
|
||||
"less than 20 seconds".to_string()
|
||||
if hide_prefix {
|
||||
"20 seconds"
|
||||
} else {
|
||||
"less than 20 seconds"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 40 && include_seconds {
|
||||
"half a minute".to_string()
|
||||
if hide_prefix {
|
||||
"half a minute"
|
||||
} else {
|
||||
"half a minute"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 60 && include_seconds {
|
||||
"less than a minute".to_string()
|
||||
if hide_prefix {
|
||||
"a minute"
|
||||
} else {
|
||||
"less than a minute"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 90 && include_seconds {
|
||||
"1 minute".to_string()
|
||||
} else if distance < 30 {
|
||||
"less than a minute".to_string()
|
||||
if hide_prefix {
|
||||
"a minute"
|
||||
} else {
|
||||
"less than a minute"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 90 {
|
||||
"1 minute".to_string()
|
||||
} else if distance < 2_700 {
|
||||
format!("{} minutes", minutes)
|
||||
} else if distance < 5_400 {
|
||||
"about 1 hour".to_string()
|
||||
if hide_prefix {
|
||||
"1 hour"
|
||||
} else {
|
||||
"about 1 hour"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 86_400 {
|
||||
format!("about {} hours", hours)
|
||||
if hide_prefix {
|
||||
format!("{} hours", hours)
|
||||
} else {
|
||||
format!("about {} hours", hours)
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 172_800 {
|
||||
"1 day".to_string()
|
||||
} else if distance < 2_592_000 {
|
||||
format!("{} days", days)
|
||||
} else if distance < 5_184_000 {
|
||||
"about 1 month".to_string()
|
||||
if hide_prefix {
|
||||
"1 month"
|
||||
} else {
|
||||
"about 1 month"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 7_776_000 {
|
||||
"about 2 months".to_string()
|
||||
if hide_prefix {
|
||||
"2 months"
|
||||
} else {
|
||||
"about 2 months"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 31_540_000 {
|
||||
format!("{} months", months)
|
||||
} else if distance < 39_425_000 {
|
||||
"about 1 year".to_string()
|
||||
if hide_prefix {
|
||||
"1 year"
|
||||
} else {
|
||||
"about 1 year"
|
||||
}
|
||||
.to_string()
|
||||
} else if distance < 55_195_000 {
|
||||
"over 1 year".to_string()
|
||||
if hide_prefix { "1 year" } else { "over 1 year" }.to_string()
|
||||
} else if distance < 63_080_000 {
|
||||
"almost 2 years".to_string()
|
||||
if hide_prefix {
|
||||
"2 years"
|
||||
} else {
|
||||
"almost 2 years"
|
||||
}
|
||||
.to_string()
|
||||
} else {
|
||||
let years = distance / 31_536_000;
|
||||
let remaining_months = (distance % 31_536_000) / 2_592_000;
|
||||
|
||||
if remaining_months < 3 {
|
||||
format!("about {} years", years)
|
||||
if hide_prefix {
|
||||
format!("{} years", years)
|
||||
} else {
|
||||
format!("about {} years", years)
|
||||
}
|
||||
.to_string()
|
||||
} else if remaining_months < 9 {
|
||||
format!("over {} years", years)
|
||||
if hide_prefix {
|
||||
format!("{} years", years)
|
||||
} else {
|
||||
format!("over {} years", years)
|
||||
}
|
||||
.to_string()
|
||||
} else {
|
||||
format!("almost {} years", years + 1)
|
||||
if hide_prefix {
|
||||
format!("{} years", years + 1)
|
||||
} else {
|
||||
format!("almost {} years", years + 1)
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -108,15 +256,16 @@ fn distance_string(distance: i64, include_seconds: bool, add_suffix: bool) -> St
|
|||
/// ```
|
||||
///
|
||||
/// Output: `"There was about 3 years between the first and last crewed moon landings."`
|
||||
pub fn naive_format_distance(
|
||||
date: NaiveDateTime,
|
||||
pub fn format_distance(
|
||||
date: DateTimeType,
|
||||
base_date: NaiveDateTime,
|
||||
include_seconds: bool,
|
||||
add_suffix: bool,
|
||||
hide_prefix: bool,
|
||||
) -> String {
|
||||
let distance = distance_in_seconds(date, base_date);
|
||||
let distance = distance_in_seconds(date.to_naive(), base_date);
|
||||
|
||||
distance_string(distance, include_seconds, add_suffix)
|
||||
distance_string(distance, include_seconds, add_suffix, hide_prefix)
|
||||
}
|
||||
|
||||
/// Get the time difference between a date and now as relative human readable string.
|
||||
|
@ -142,14 +291,15 @@ pub fn naive_format_distance(
|
|||
/// ```
|
||||
///
|
||||
/// Output: `It's been over 54 years since Apollo 11 first landed on the moon.`
|
||||
pub fn naive_format_distance_from_now(
|
||||
datetime: NaiveDateTime,
|
||||
pub fn format_distance_from_now(
|
||||
datetime: DateTimeType,
|
||||
include_seconds: bool,
|
||||
add_suffix: bool,
|
||||
hide_prefix: bool,
|
||||
) -> String {
|
||||
let now = chrono::offset::Local::now().naive_local();
|
||||
|
||||
naive_format_distance(datetime, now, include_seconds, add_suffix)
|
||||
format_distance(datetime, now, include_seconds, add_suffix, hide_prefix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -158,73 +308,127 @@ mod tests {
|
|||
use chrono::NaiveDateTime;
|
||||
|
||||
#[test]
|
||||
fn test_naive_format_distance() {
|
||||
let date =
|
||||
NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date");
|
||||
let base_date =
|
||||
NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date");
|
||||
fn test_format_distance() {
|
||||
let date = DateTimeType::Naive(
|
||||
NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
|
||||
);
|
||||
let base_date = DateTimeType::Naive(
|
||||
NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
"about 2 hours",
|
||||
naive_format_distance(date, base_date, false, false)
|
||||
format_distance(date, base_date.to_naive(), false, false, false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_naive_format_distance_with_suffix() {
|
||||
let date =
|
||||
NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date");
|
||||
let base_date =
|
||||
NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date");
|
||||
fn test_format_distance_with_suffix() {
|
||||
let date = DateTimeType::Naive(
|
||||
NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
|
||||
);
|
||||
let base_date = DateTimeType::Naive(
|
||||
NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
"about 2 hours from now",
|
||||
naive_format_distance(date, base_date, false, true)
|
||||
format_distance(date, base_date.to_naive(), false, true, false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_naive_format_distance_from_now() {
|
||||
let date = NaiveDateTime::parse_from_str("1969-07-20T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
|
||||
.expect("Invalid NaiveDateTime for date");
|
||||
fn test_format_distance_from_now() {
|
||||
let date = DateTimeType::Naive(
|
||||
NaiveDateTime::parse_from_str("1969-07-20T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
|
||||
.expect("Invalid NaiveDateTime for date"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
"over 54 years ago",
|
||||
naive_format_distance_from_now(date, false, true)
|
||||
format_distance_from_now(date, false, true, false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_naive_format_distance_string() {
|
||||
assert_eq!(distance_string(3, false, false), "less than a minute");
|
||||
assert_eq!(distance_string(7, false, false), "less than a minute");
|
||||
assert_eq!(distance_string(13, false, false), "less than a minute");
|
||||
assert_eq!(distance_string(21, false, false), "less than a minute");
|
||||
assert_eq!(distance_string(45, false, false), "1 minute");
|
||||
assert_eq!(distance_string(61, false, false), "1 minute");
|
||||
assert_eq!(distance_string(1920, false, false), "32 minutes");
|
||||
assert_eq!(distance_string(3902, false, false), "about 1 hour");
|
||||
assert_eq!(distance_string(18002, false, false), "about 5 hours");
|
||||
assert_eq!(distance_string(86470, false, false), "1 day");
|
||||
assert_eq!(distance_string(345880, false, false), "4 days");
|
||||
assert_eq!(distance_string(2764800, false, false), "about 1 month");
|
||||
assert_eq!(distance_string(5184000, false, false), "about 2 months");
|
||||
assert_eq!(distance_string(10368000, false, false), "4 months");
|
||||
assert_eq!(distance_string(34694000, false, false), "about 1 year");
|
||||
assert_eq!(distance_string(47310000, false, false), "over 1 year");
|
||||
assert_eq!(distance_string(61503000, false, false), "almost 2 years");
|
||||
assert_eq!(distance_string(160854000, false, false), "about 5 years");
|
||||
assert_eq!(distance_string(236550000, false, false), "over 7 years");
|
||||
assert_eq!(distance_string(249166000, false, false), "almost 8 years");
|
||||
fn test_format_distance_string() {
|
||||
assert_eq!(
|
||||
distance_string(3, false, false, false),
|
||||
"less than a minute"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(7, false, false, false),
|
||||
"less than a minute"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(13, false, false, false),
|
||||
"less than a minute"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(21, false, false, false),
|
||||
"less than a minute"
|
||||
);
|
||||
assert_eq!(distance_string(45, false, false, false), "1 minute");
|
||||
assert_eq!(distance_string(61, false, false, false), "1 minute");
|
||||
assert_eq!(distance_string(1920, false, false, false), "32 minutes");
|
||||
assert_eq!(distance_string(3902, false, false, false), "about 1 hour");
|
||||
assert_eq!(distance_string(18002, false, false, false), "about 5 hours");
|
||||
assert_eq!(distance_string(86470, false, false, false), "1 day");
|
||||
assert_eq!(distance_string(345880, false, false, false), "4 days");
|
||||
assert_eq!(
|
||||
distance_string(2764800, false, false, false),
|
||||
"about 1 month"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(5184000, false, false, false),
|
||||
"about 2 months"
|
||||
);
|
||||
assert_eq!(distance_string(10368000, false, false, false), "4 months");
|
||||
assert_eq!(
|
||||
distance_string(34694000, false, false, false),
|
||||
"about 1 year"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(47310000, false, false, false),
|
||||
"over 1 year"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(61503000, false, false, false),
|
||||
"almost 2 years"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(160854000, false, false, false),
|
||||
"about 5 years"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(236550000, false, false, false),
|
||||
"over 7 years"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(249166000, false, false, false),
|
||||
"almost 8 years"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_naive_format_distance_string_include_seconds() {
|
||||
assert_eq!(distance_string(3, true, false), "less than 5 seconds");
|
||||
assert_eq!(distance_string(7, true, false), "less than 10 seconds");
|
||||
assert_eq!(distance_string(13, true, false), "less than 20 seconds");
|
||||
assert_eq!(distance_string(21, true, false), "half a minute");
|
||||
assert_eq!(distance_string(45, true, false), "less than a minute");
|
||||
assert_eq!(distance_string(61, true, false), "1 minute");
|
||||
fn test_format_distance_string_include_seconds() {
|
||||
assert_eq!(
|
||||
distance_string(3, true, false, false),
|
||||
"less than 5 seconds"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(7, true, false, false),
|
||||
"less than 10 seconds"
|
||||
);
|
||||
assert_eq!(
|
||||
distance_string(13, true, false, false),
|
||||
"less than 20 seconds"
|
||||
);
|
||||
assert_eq!(distance_string(21, true, false, false), "half a minute");
|
||||
assert_eq!(
|
||||
distance_string(45, true, false, false),
|
||||
"less than a minute"
|
||||
);
|
||||
assert_eq!(distance_string(61, true, false, false), "1 minute");
|
||||
}
|
||||
}
|
||||
|
|