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, 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> = 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::>(); let mut rendered_groups = HashSet::new(); let mut items: Vec> = 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::>(); let progress_values = members .iter() .filter_map(|member| member.progress) .collect::>(); let progress = (!progress_values.is_empty()).then(|| { format!( " ({:.0}%)", progress_values.iter().sum::() / 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> = 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> = 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::(); 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::>>(); 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() }