diff --git a/crates/synd_term/src/application/mod.rs b/crates/synd_term/src/application/mod.rs index 5b989386..202da883 100644 --- a/crates/synd_term/src/application/mod.rs +++ b/crates/synd_term/src/application/mod.rs @@ -28,8 +28,8 @@ use crate::{ ui::{ self, components::{ - authentication::AuthenticateState, filter::FeedFilter, root::Root, tabs::Tab, - Components, + authentication::AuthenticateState, filter::FeedFilter, root::Root, + subscription::UnsubscribeSelection, tabs::Tab, Components, }, theme::Theme, }, @@ -379,7 +379,30 @@ impl Application { self.should_render(); } Command::PromptFeedUnsubscription => { - self.prompt_feed_unsubscription(); + if self.components.subscription.selected_feed().is_some() { + self.components.subscription.show_unsubscribe_popup(true); + self.keymaps().enable(KeymapId::UnsubscribePopupSelection); + self.should_render(); + } + } + Command::MoveFeedUnsubscriptionPopupSelection(direction) => { + self.components + .subscription + .move_unsubscribe_popup_selection(direction); + self.should_render(); + } + Command::SelectFeedUnsubscriptionPopup => { + if let (UnsubscribeSelection::Yes, Some(feed)) = + self.components.subscription.unsubscribe_popup_selection() + { + self.unsubscribe_feed(feed.url.clone()); + } + next = Some(Command::CancelFeedUnsubscriptionPopup); + self.should_render(); + } + Command::CancelFeedUnsubscriptionPopup => { + self.components.subscription.show_unsubscribe_popup(false); + self.keymaps().disable(KeymapId::UnsubscribePopupSelection); self.should_render(); } Command::SubscribeFeed { input } => { @@ -696,20 +719,6 @@ impl Application { self.jobs.futures.push(fut); } - fn prompt_feed_unsubscription(&mut self) { - // TODO: prompt deletion confirm - let Some(url) = self - .components - .subscription - .selected_feed() - .map(|feed| feed.url.clone()) - else { - return; - }; - let fut = async move { Ok(Command::UnsubscribeFeed { url }) }.boxed(); - self.jobs.futures.push(fut); - } - fn subscribe_feed(&mut self, input: SubscribeFeedInput) { let client = self.client.clone(); let request_seq = self.in_flight.add(RequestId::SubscribeFeed); diff --git a/crates/synd_term/src/command.rs b/crates/synd_term/src/command.rs index 50eeb752..2298b8db 100644 --- a/crates/synd_term/src/command.rs +++ b/crates/synd_term/src/command.rs @@ -46,6 +46,9 @@ pub enum Command { PromptFeedSubscription, PromptFeedEdition, PromptFeedUnsubscription, + MoveFeedUnsubscriptionPopupSelection(Direction), + SelectFeedUnsubscriptionPopup, + CancelFeedUnsubscriptionPopup, SubscribeFeed { input: SubscribeFeedInput, }, @@ -167,6 +170,18 @@ impl Command { pub fn prompt_feed_unsubscription() -> Self { Command::PromptFeedUnsubscription } + pub fn move_feed_unsubscription_popup_selection_left() -> Self { + Command::MoveFeedUnsubscriptionPopupSelection(Direction::Left) + } + pub fn move_feed_unsubscription_popup_selection_right() -> Self { + Command::MoveFeedUnsubscriptionPopupSelection(Direction::Right) + } + pub fn select_feed_unsubscription_popup() -> Self { + Command::SelectFeedUnsubscriptionPopup + } + pub fn cancel_feed_unsubscription_popup() -> Self { + Command::CancelFeedUnsubscriptionPopup + } pub fn move_up_subscribed_feed() -> Self { Command::MoveSubscribedFeed(Direction::Up) } diff --git a/crates/synd_term/src/keymap/default.rs b/crates/synd_term/src/keymap/default.rs index d809c203..9882b4a8 100644 --- a/crates/synd_term/src/keymap/default.rs +++ b/crates/synd_term/src/keymap/default.rs @@ -40,6 +40,12 @@ pub fn default() -> KeymapsConfig { "/" => activate_search_filtering, "esc" => deactivate_filtering, }); + let unsubscribe_popup = keymap!({ + "h" | "left" => move_feed_unsubscription_popup_selection_left, + "l" | "right" => move_feed_unsubscription_popup_selection_right, + "enter" => select_feed_unsubscription_popup, + "esc" => cancel_feed_unsubscription_popup, + }); let global = keymap!({ "q" | "C-c" => quit , }); @@ -50,6 +56,7 @@ pub fn default() -> KeymapsConfig { entries, subscription, filter, + unsubscribe_popup, global, } } diff --git a/crates/synd_term/src/keymap/mod.rs b/crates/synd_term/src/keymap/mod.rs index bf3f878a..0b886f95 100644 --- a/crates/synd_term/src/keymap/mod.rs +++ b/crates/synd_term/src/keymap/mod.rs @@ -18,6 +18,7 @@ pub enum KeymapId { Subscription = 4, Filter = 5, CategoryFiltering = 6, + UnsubscribePopupSelection = 7, } #[derive(Debug)] @@ -76,6 +77,7 @@ pub struct KeymapsConfig { pub entries: KeyTrie, pub subscription: KeyTrie, pub filter: KeyTrie, + pub unsubscribe_popup: KeyTrie, pub global: KeyTrie, } @@ -101,6 +103,10 @@ impl Keymaps { Keymap::new(KeymapId::Subscription, config.subscription), Keymap::new(KeymapId::Filter, config.filter), Keymap::new(KeymapId::CategoryFiltering, KeyTrie::default()), + Keymap::new( + KeymapId::UnsubscribePopupSelection, + config.unsubscribe_popup, + ), ]; Self { keymaps } diff --git a/crates/synd_term/src/ui/components/subscription.rs b/crates/synd_term/src/ui/components/subscription.rs index 1493fb9d..377b6a43 100644 --- a/crates/synd_term/src/ui/components/subscription.rs +++ b/crates/synd_term/src/ui/components/subscription.rs @@ -7,8 +7,8 @@ use ratatui::{ text::{Line, Span}, widgets::{ block::{Position, Title}, - Block, BorderType, Borders, Cell, HighlightSpacing, Padding, Row, Scrollbar, - ScrollbarOrientation, ScrollbarState, StatefulWidget, Table, TableState, Widget, + Block, BorderType, Borders, Cell, HighlightSpacing, Padding, Paragraph, Row, Scrollbar, + ScrollbarOrientation, ScrollbarState, StatefulWidget, Table, TableState, Tabs, Widget, }, }; use synd_feed::types::{FeedType, FeedUrl}; @@ -20,6 +20,7 @@ use crate::{ ui::{ self, components::filter::{FeedFilter, FilterResult}, + extension::RectExt, Context, }, }; @@ -29,6 +30,28 @@ pub struct Subscription { feeds: Vec, effective_feeds: Vec, filter: FeedFilter, + + unsubscribe_popup: UnsubscribePopup, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum UnsubscribeSelection { + Yes, + No, +} + +impl UnsubscribeSelection { + fn toggle(self) -> Self { + match self { + UnsubscribeSelection::Yes => UnsubscribeSelection::No, + UnsubscribeSelection::No => UnsubscribeSelection::Yes, + } + } +} + +struct UnsubscribePopup { + selection: UnsubscribeSelection, + selected_feed: Option, } impl Subscription { @@ -38,6 +61,10 @@ impl Subscription { feeds: Vec::new(), effective_feeds: Vec::new(), filter: FeedFilter::default(), + unsubscribe_popup: UnsubscribePopup { + selection: UnsubscribeSelection::Yes, + selected_feed: None, + }, } } @@ -55,6 +82,21 @@ impl Subscription { .map(|&idx| self.feeds.get(idx).unwrap()) } + pub fn show_unsubscribe_popup(&mut self, show: bool) { + if show { + self.unsubscribe_popup.selected_feed = self.selected_feed().cloned(); + } else { + self.unsubscribe_popup.selected_feed = None; + } + } + + pub fn unsubscribe_popup_selection(&self) -> (UnsubscribeSelection, Option<&types::Feed>) { + ( + self.unsubscribe_popup.selection, + self.unsubscribe_popup.selected_feed.as_ref(), + ) + } + pub fn update_subscription(&mut self, populate: Populate, subscription: SubscriptionOutput) { let feed_metas = subscription.feeds.nodes.into_iter().map(types::Feed::from); match populate { @@ -110,6 +152,12 @@ impl Subscription { self.selected_feed_index = self.effective_feeds.len() - 1; } } + + pub fn move_unsubscribe_popup_selection(&mut self, direction: Direction) { + if matches!(direction, Direction::Left | Direction::Right) { + self.unsubscribe_popup.selection = self.unsubscribe_popup.selection.toggle(); + } + } } impl Subscription { @@ -119,6 +167,10 @@ impl Subscription { self.render_feeds(feeds_area, buf, cx); self.render_feed_detail(feed_detail_area, buf, cx); + + if let Some(feed) = self.unsubscribe_popup.selected_feed.as_ref() { + self.render_unsubscribe_popup(area, buf, cx, feed); + } } fn render_feeds(&self, area: Rect, buf: &mut Buffer, cx: &Context<'_>) { @@ -375,4 +427,86 @@ impl Subscription { Widget::render(table, entries_area, buf); } + + fn render_unsubscribe_popup( + &self, + area: Rect, + buf: &mut Buffer, + cx: &Context<'_>, + feed: &types::Feed, + ) { + let area = { + let area = area.centered(60, 60); + let vertical = Layout::vertical([ + Constraint::Fill(1), + Constraint::Min(12), + Constraint::Fill(2), + ]); + let [_, area, _] = vertical.areas(area); + area.reset(buf); + area + }; + + let block = Block::new() + .title_top("Unsubscribe") + .title_alignment(Alignment::Center) + .title_style(Style::new().add_modifier(Modifier::BOLD)) + .padding(Padding { + left: 1, + right: 1, + top: 1, + bottom: 1, + }) + .borders(Borders::ALL) + .style(cx.theme.background); + + let inner_area = block.inner(area); + let vertical = Layout::vertical([Constraint::Length(6), Constraint::Fill(1)]); + let [info_area, selection_area] = vertical.areas(inner_area); + + block.render(area, buf); + + // for align line + let feed_n = "Feed: ".len() + feed.title.as_deref().unwrap_or("-").len(); + let url_n = "URL : ".len() + feed.url.as_str().len(); + + Paragraph::new(vec![ + Line::from("Do you unsubscribe from this feed?"), + Line::from(""), + Line::from(vec![ + Span::from("Feed: "), + Span::from(feed.title.as_deref().unwrap_or("-")).bold(), + Span::from(" ".repeat(url_n.saturating_sub(feed_n))), + ]), + Line::from(vec![ + Span::from("URL : "), + Span::from(feed.url.to_string()).bold(), + Span::from(" ".repeat(feed_n.saturating_sub(url_n))), + ]), + ]) + .alignment(Alignment::Center) + .block( + Block::new() + .borders(Borders::BOTTOM) + .border_type(BorderType::Plain) + .border_style(Style::new().add_modifier(Modifier::DIM)), + ) + .render(info_area, buf); + + // align center + let horizontal = + Layout::horizontal([Constraint::Fill(1), Constraint::Min(1), Constraint::Fill(1)]); + let [_, selection_area, _] = horizontal.areas(selection_area); + + Tabs::new([" Yes ", " No "]) + .style(cx.theme.tabs) + .divider("") + .padding(" ", " ") + .select(match self.unsubscribe_popup.selection { + UnsubscribeSelection::Yes => 0, + UnsubscribeSelection::No => 1, + }) + .highlight_style(cx.theme.selection_popup.highlight) + .render(selection_area, buf); + } } diff --git a/crates/synd_term/src/ui/extension.rs b/crates/synd_term/src/ui/extension.rs index 87a685c0..4c7cdd63 100644 --- a/crates/synd_term/src/ui/extension.rs +++ b/crates/synd_term/src/ui/extension.rs @@ -1,8 +1,14 @@ -use ratatui::prelude::{Constraint, Direction, Layout, Rect}; +use ratatui::{ + buffer::Buffer, + prelude::{Constraint, Direction, Layout, Rect}, +}; pub(super) trait RectExt { /// Create centered Rect fn centered(self, percent_x: u16, percent_y: u16) -> Rect; + + /// Reset this area + fn reset(&self, buf: &mut Buffer); } impl RectExt for Rect { @@ -27,4 +33,12 @@ impl RectExt for Rect { ]) .split(layout[1])[1] } + + fn reset(&self, buf: &mut Buffer) { + for x in self.x..(self.x + self.width) { + for y in self.y..(self.y + self.height) { + buf.get_mut(x, y).reset(); + } + } + } } diff --git a/crates/synd_term/src/ui/theme.rs b/crates/synd_term/src/ui/theme.rs index fee06acd..f083f6c9 100644 --- a/crates/synd_term/src/ui/theme.rs +++ b/crates/synd_term/src/ui/theme.rs @@ -16,6 +16,7 @@ pub struct Theme { pub error: ErrorTheme, pub default_icon_fg: Color, pub requirement: RequirementLabelTheme, + pub selection_popup: SelectionPopup, } #[derive(Clone)] @@ -58,6 +59,11 @@ pub struct RequirementLabelTheme { pub fg: Color, } +#[derive(Clone)] +pub struct SelectionPopup { + pub highlight: Style, +} + impl Theme { pub fn with_palette(p: &Palette) -> Self { let gray = tailwind::ZINC; @@ -101,6 +107,9 @@ impl Theme { may: Color::Rgb(35, 57, 91), fg: bg, }, + selection_popup: SelectionPopup { + highlight: Style::new().bg(Color::Yellow).fg(bg), + }, } } pub fn new() -> Self {