Files
RuDS/crates/bds-ui/src/views/panel.rs

428 lines
15 KiB
Rust

use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, row, scrollable, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::engine::git::GitCommit;
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
use crate::state::tabs::{Tab, TabType};
use crate::views::post_editor::ResolvedPostLink;
use std::collections::HashSet;
fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Message> {
let progress = snapshot
.progress
.map(|value| format!(" ({:.0}%)", value * 100.0))
.unwrap_or_default();
let phase = snapshot
.message
.as_deref()
.map(|message| format!("{message}"))
.unwrap_or_default();
let label = text(format!(
"{}{}{}{}",
snapshot.label, snapshot.status, progress, phase
))
.size(11)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.70, 0.70, 0.75));
let mut content = row![label].align_y(Alignment::Center).spacing(8);
if snapshot.is_cancellable {
content = content.push(Space::with_width(Length::Fill)).push(
button(text(t(locale, "tasks.cancelTask")).size(11))
.on_press(Message::CancelTask(snapshot.id))
.padding([3, 8])
.style(inputs::secondary_button),
);
}
content.into()
}
/// Panel background style.
fn panel_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb(0.13, 0.13, 0.16))),
..container::Style::default()
}
}
/// Panel tab button — active.
fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.25))),
text_color: Color::WHITE,
border: Border {
color: Color::from_rgb(0.30, 0.55, 0.90),
width: 0.0,
radius: 6.0.into(),
},
..button::Style::default()
}
}
/// Panel tab button — inactive.
fn tab_inactive(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.18, 0.18, 0.22),
_ => Color::TRANSPARENT,
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.60, 0.60, 0.65),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 6.0.into(),
},
..button::Style::default()
}
}
/// Close button style.
fn close_btn_style(_theme: &Theme, status: button::Status) -> button::Style {
let color = match status {
button::Status::Hovered => Color::WHITE,
_ => Color::from_rgb(0.50, 0.50, 0.55),
};
button::Style {
background: Some(Background::Color(Color::TRANSPARENT)),
text_color: color,
border: Border::default(),
..button::Style::default()
}
}
#[expect(
clippy::too_many_arguments,
reason = "arguments are independent panel state slices"
)]
pub fn view(
panel_tab: PanelTab,
task_snapshots: &[TaskSnapshot],
collapsed_task_groups: &HashSet<String>,
output_entries: &[OutputEntry],
post_outlinks: &[ResolvedPostLink],
post_backlinks: &[ResolvedPostLink],
locale: UiLocale,
active_tab_is_post: bool,
active_tab_is_post_or_media: bool,
git_file_history: &[GitCommit],
) -> Element<'static, Message> {
let muted = Color::from_rgb(0.50, 0.50, 0.55);
// Tab header — per layout.allium: tasks, output, post_links (only when
// active editor tab is a post), git_log (only when active tab is post or
// media).
let tasks_btn = button(
text(t(locale, "common.tasks"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::Tasks))
.padding([4, 8])
.style(if panel_tab == PanelTab::Tasks {
tab_active
} else {
tab_inactive
});
let output_btn = button(
text(t(locale, "panel.output"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::Output))
.padding([4, 8])
.style(if panel_tab == PanelTab::Output {
tab_active
} else {
tab_inactive
});
let close_btn = button(text("\u{2715}").size(12).shaping(Shaping::Advanced))
.on_press(Message::TogglePanel)
.padding([4, 6])
.style(close_btn_style);
let mut tab_row: Vec<Element<'static, Message>> = vec![tasks_btn.into(), output_btn.into()];
if active_tab_is_post {
let post_links_btn = button(
text(t(locale, "panel.postLinks"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::PostLinks))
.padding([4, 8])
.style(if panel_tab == PanelTab::PostLinks {
tab_active
} else {
tab_inactive
});
tab_row.push(post_links_btn.into());
}
if active_tab_is_post_or_media {
let git_log_btn = button(
text(t(locale, "panel.gitLog"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press(Message::SetPanelTab(PanelTab::GitLog))
.padding([4, 8])
.style(if panel_tab == PanelTab::GitLog {
tab_active
} else {
tab_inactive
});
tab_row.push(git_log_btn.into());
}
tab_row.push(Space::with_width(Length::Fill).into());
tab_row.push(close_btn.into());
let tab_header = iced::widget::Row::with_children(tab_row)
.spacing(4)
.align_y(Alignment::Center)
.padding([4, 8]);
// Tab content
let content: Element<'static, Message> = match panel_tab {
PanelTab::Tasks => {
if task_snapshots.is_empty() {
container(
text(t(locale, "tasks.noActive"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else {
let visible = task_snapshots.iter().rev().take(10).collect::<Vec<_>>();
let mut rendered_groups = HashSet::new();
let mut items: Vec<Element<'static, Message>> = Vec::new();
for snapshot in &visible {
let Some(group_id) = snapshot.group_id.as_ref() else {
items.push(task_row(snapshot, locale));
continue;
};
if !rendered_groups.insert(group_id.clone()) {
continue;
}
let members = visible
.iter()
.copied()
.filter(|member| member.group_id.as_ref() == Some(group_id))
.collect::<Vec<_>>();
let progress_values = members
.iter()
.filter_map(|member| member.progress)
.collect::<Vec<_>>();
let progress = (!progress_values.is_empty()).then(|| {
format!(
" ({:.0}%)",
progress_values.iter().sum::<f32>() / progress_values.len() as f32
* 100.0
)
});
let collapsed = collapsed_task_groups.contains(group_id);
let group_name = snapshot.group_name.as_deref().unwrap_or(group_id);
items.push(
button(
row![
text(if collapsed { "\u{25b8}" } else { "\u{25be}" }).size(11),
text(format!("{}{}", group_name, progress.unwrap_or_default()))
.size(11)
]
.spacing(6),
)
.on_press(Message::ToggleTaskGroup(group_id.clone()))
.width(Length::Fill)
.padding([3, 6])
.style(inputs::disclosure_button)
.into(),
);
if !collapsed {
items.extend(members.into_iter().map(|member| task_row(member, locale)));
}
}
scrollable(
iced::widget::Column::with_children(items)
.spacing(4)
.padding(8),
)
.direction(scrollable::Direction::Vertical(inputs::compact_scrollbar()))
.style(inputs::scrollable_style)
.into()
}
}
PanelTab::Output => {
if output_entries.is_empty() {
container(
text(t(locale, "panel.noOutput"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else {
let items: Vec<Element<'static, Message>> = output_entries
.iter()
.map(|entry| {
text(entry.text.clone())
.size(11)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.70, 0.70, 0.75))
.into()
})
.collect();
scrollable(
iced::widget::Column::with_children(items)
.spacing(2)
.padding(8),
)
.direction(scrollable::Direction::Vertical(inputs::compact_scrollbar()))
.style(inputs::scrollable_style)
.into()
}
}
PanelTab::PostLinks => {
if post_outlinks.is_empty() && post_backlinks.is_empty() {
container(
text(t(locale, "panel.postLinksPlaceholder"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else {
let mut items: Vec<Element<'static, Message>> = vec![
text(t(locale, "editor.outlinks"))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.75, 0.77, 0.82))
.into(),
];
if post_outlinks.is_empty() {
items.push(
text(t(locale, "panel.postLinksPlaceholder"))
.size(11)
.color(muted)
.into(),
);
} else {
for link in post_outlinks {
items.push(post_link_button(locale, link));
}
}
items.push(Space::with_height(8.0).into());
items.push(
text(t(locale, "editor.backlinks"))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.75, 0.77, 0.82))
.into(),
);
if post_backlinks.is_empty() {
items.push(
text(t(locale, "panel.postLinksPlaceholder"))
.size(11)
.color(muted)
.into(),
);
} else {
for link in post_backlinks {
items.push(post_link_button(locale, link));
}
}
scrollable(
iced::widget::Column::with_children(items)
.spacing(4)
.padding(8),
)
.direction(scrollable::Direction::Vertical(inputs::compact_scrollbar()))
.style(inputs::scrollable_style)
.into()
}
}
PanelTab::GitLog => {
if git_file_history.is_empty() {
container(
text(t(locale, "git.noFileHistory"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else {
let items = git_file_history
.iter()
.map(|commit| {
let hash = commit.hash.clone();
let subject = commit.subject.clone().unwrap_or_else(|| hash.clone());
let short = hash.chars().take(7).collect::<String>();
button(
row![
text(short).size(11).font(iced::Font::MONOSPACE),
text(subject.clone()).size(11),
Space::with_width(Length::Fill),
text(commit.date.clone().unwrap_or_default()).size(10),
]
.spacing(8),
)
.on_press(Message::OpenGitCommitDiff { hash, subject })
.padding([4, 8])
.width(Length::Fill)
.style(inputs::disclosure_button)
.into()
})
.collect::<Vec<Element<'static, Message>>>();
scrollable(
iced::widget::Column::with_children(items)
.spacing(2)
.padding(8),
)
.direction(scrollable::Direction::Vertical(inputs::compact_scrollbar()))
.style(inputs::scrollable_style)
.into()
}
}
};
container(column![tab_header, content].spacing(0))
.width(Length::Fill)
.height(Length::Fixed(200.0))
.style(panel_style)
.into()
}
fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'static, Message> {
button(text(link.title.clone()).size(11).shaping(Shaping::Advanced))
.on_press(Message::OpenTab(Tab {
id: link.post_id.clone(),
title: if link.title.is_empty() {
t(locale, "editor.untitled")
} else {
link.title.clone()
},
tab_type: TabType::Post,
is_transient: false,
is_dirty: false,
}))
.padding([4, 8])
.style(tab_inactive)
.into()
}