novel_cli/cmd/
info.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::Duration;
4
5use clap::Args;
6use color_eyre::eyre::Result;
7use fluent_templates::Loader;
8use novel_api::{CiweimaoClient, CiyuanjiClient, Client, Comment, CommentType, SfacgClient};
9use ratatui::Frame;
10use ratatui::buffer::Buffer;
11use ratatui::crossterm::event::{
12    self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
13};
14use ratatui::layout::{Constraint, Direction, Layout, Rect};
15use ratatui::text::Line;
16use ratatui::widgets::{Block, Paragraph, StatefulWidget, StatefulWidgetRef, Tabs, Widget, Wrap};
17use ratatui_image::picker::Picker;
18use ratatui_image::protocol::StatefulProtocol;
19use ratatui_image::{FilterType, Resize, StatefulImage};
20use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
21use tokio::runtime::Handle;
22use tokio::task;
23use tui_widgets::scrollview::ScrollViewState;
24use url::Url;
25
26use super::{Mode, ScrollableParagraph};
27use crate::cmd::{Convert, Source};
28use crate::{LANG_ID, LOCALES, Tui, utils};
29
30#[must_use]
31#[derive(Args)]
32#[command(arg_required_else_help = true,
33    about = LOCALES.lookup(&LANG_ID, "info_command"))]
34pub struct Info {
35    #[arg(help = LOCALES.lookup(&LANG_ID, "novel_id"))]
36    pub novel_id: u32,
37
38    #[arg(short, long,
39        help = LOCALES.lookup(&LANG_ID, "source"))]
40    pub source: Source,
41
42    #[arg(short, long, value_enum, value_delimiter = ',',
43        help = LOCALES.lookup(&LANG_ID, "converts"))]
44    pub converts: Vec<Convert>,
45
46    #[arg(long, default_value_t = false,
47        help = LOCALES.lookup(&LANG_ID, "ignore_keyring"))]
48    pub ignore_keyring: bool,
49
50    #[arg(long, num_args = 0..=1, default_missing_value = super::DEFAULT_PROXY,
51        help = LOCALES.lookup(&LANG_ID, "proxy"))]
52    pub proxy: Option<Url>,
53
54    #[arg(long, default_value_t = false,
55        help = LOCALES.lookup(&LANG_ID, "no_proxy"))]
56    pub no_proxy: bool,
57
58    #[arg(long, num_args = 0..=1, default_missing_value = super::default_cert_path(),
59        help = super::cert_help_msg())]
60    pub cert: Option<PathBuf>,
61}
62
63pub async fn execute(config: Info) -> Result<()> {
64    match config.source {
65        Source::Sfacg => {
66            let mut client = SfacgClient::new().await?;
67            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
68            do_execute(client, config).await?
69        }
70        Source::Ciweimao => {
71            let mut client = CiweimaoClient::new().await?;
72            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
73            utils::log_in(&client, &config.source, config.ignore_keyring).await?;
74            do_execute(client, config).await?
75        }
76        Source::Ciyuanji => {
77            let mut client = CiyuanjiClient::new().await?;
78            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
79            utils::log_in_without_password(&client).await?;
80            do_execute(client, config).await?
81        }
82    }
83
84    Ok(())
85}
86
87async fn do_execute<T>(client: T, config: Info) -> Result<()>
88where
89    T: Client + Send + Sync + 'static,
90{
91    let client = Arc::new(client);
92    super::handle_shutdown_signal(&client);
93
94    let mut terminal = crate::init_terminal()?;
95    App::new(client, config).await?.run(&mut terminal)?;
96    crate::restore_terminal()?;
97
98    Ok(())
99}
100
101struct App<T> {
102    mode: Mode,
103    tab: Tab,
104    info_tab: InfoTab,
105    short_comment_tab: CommentTab<T>,
106    long_comment_tab: CommentTab<T>,
107}
108
109impl<T> App<T>
110where
111    T: Client + Send + Sync + 'static,
112{
113    async fn new(client: Arc<T>, config: Info) -> Result<Self> {
114        Ok(App {
115            mode: Mode::default(),
116            tab: Tab::default(),
117            info_tab: InfoTab::new(Arc::clone(&client), &config).await?,
118            short_comment_tab: CommentTab::new(Arc::clone(&client), &config, CommentType::Short)
119                .await?,
120            long_comment_tab: CommentTab::new(Arc::clone(&client), &config, CommentType::Long)
121                .await?,
122        })
123    }
124
125    fn run(&mut self, terminal: &mut Tui) -> Result<()> {
126        self.draw(terminal)?;
127
128        while self.is_running() {
129            if self.handle_events()? {
130                self.draw(terminal)?;
131            }
132        }
133
134        Ok(())
135    }
136
137    fn is_running(&self) -> bool {
138        self.mode != Mode::Quit
139    }
140
141    fn draw(&mut self, terminal: &mut Tui) -> Result<()> {
142        terminal.draw(|frame| self.render_frame(frame))?;
143        Ok(())
144    }
145
146    fn render_frame(&mut self, frame: &mut Frame) {
147        frame.render_widget(self, frame.area());
148    }
149
150    fn handle_events(&mut self) -> Result<bool> {
151        if event::poll(Duration::from_secs_f64(1.0 / 60.0))? {
152            return match event::read()? {
153                Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
154                    Ok(self.handle_key_event(key_event))
155                }
156                Event::Mouse(mouse_event) => Ok(self.handle_mouse_event(mouse_event)),
157                _ => Ok(false),
158            };
159        }
160        Ok(false)
161    }
162
163    fn handle_key_event(&mut self, key_event: KeyEvent) -> bool {
164        match key_event.code {
165            KeyCode::Char('q') | KeyCode::Esc => self.exit(),
166            KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
167                self.exit()
168            }
169            KeyCode::Tab => self.next_tab(),
170            KeyCode::Left => match self.tab {
171                Tab::ShortComment => self.prev_page_short_comment_tab(),
172                Tab::LongComment => self.prev_page_long_comment_tab(),
173                _ => false,
174            },
175            KeyCode::Right => match self.tab {
176                Tab::ShortComment => self.next_page_short_comment_tab(),
177                Tab::LongComment => self.next_page_long_comment_tab(),
178                _ => false,
179            },
180            KeyCode::Up => match self.tab {
181                Tab::Info => self.scroll_up_info_tab(),
182                Tab::ShortComment => self.scroll_up_short_comment_tab(),
183                Tab::LongComment => self.scroll_up_long_comment_tab(),
184            },
185            KeyCode::Down => match self.tab {
186                Tab::Info => self.scroll_down_info_tab(),
187                Tab::ShortComment => self.scroll_down_short_comment_tab(),
188                Tab::LongComment => self.scroll_down_long_comment_tab(),
189            },
190            _ => false,
191        }
192    }
193
194    fn handle_mouse_event(&mut self, mouse_event: MouseEvent) -> bool {
195        match mouse_event.kind {
196            MouseEventKind::ScrollUp => match self.tab {
197                Tab::Info => self.scroll_up_info_tab(),
198                Tab::ShortComment => self.scroll_up_short_comment_tab(),
199                Tab::LongComment => self.scroll_up_long_comment_tab(),
200            },
201            MouseEventKind::ScrollDown => match self.tab {
202                Tab::Info => self.scroll_down_info_tab(),
203                Tab::ShortComment => self.scroll_down_short_comment_tab(),
204                Tab::LongComment => self.scroll_down_long_comment_tab(),
205            },
206            _ => false,
207        }
208    }
209
210    fn exit(&mut self) -> bool {
211        self.mode = Mode::Quit;
212        false
213    }
214
215    fn next_tab(&mut self) -> bool {
216        self.tab = self.tab.next();
217        true
218    }
219
220    fn scroll_up_info_tab(&mut self) -> bool {
221        self.info_tab.scroll_view_state.scroll_up();
222        true
223    }
224
225    fn scroll_down_info_tab(&mut self) -> bool {
226        self.info_tab.scroll_view_state.scroll_down();
227        true
228    }
229
230    fn scroll_up_short_comment_tab(&mut self) -> bool {
231        self.short_comment_tab.scroll_view_state.scroll_up();
232        true
233    }
234
235    fn scroll_down_short_comment_tab(&mut self) -> bool {
236        self.short_comment_tab.scroll_view_state.scroll_down();
237        true
238    }
239
240    fn scroll_up_long_comment_tab(&mut self) -> bool {
241        self.long_comment_tab.scroll_view_state.scroll_up();
242        true
243    }
244
245    fn scroll_down_long_comment_tab(&mut self) -> bool {
246        self.long_comment_tab.scroll_view_state.scroll_down();
247        true
248    }
249
250    fn prev_page_short_comment_tab(&mut self) -> bool {
251        self.short_comment_tab.prev_page();
252        self.short_comment_tab.scroll_view_state.scroll_to_top();
253        true
254    }
255
256    fn next_page_short_comment_tab(&mut self) -> bool {
257        self.short_comment_tab.next_page();
258        self.short_comment_tab.scroll_view_state.scroll_to_top();
259        true
260    }
261
262    fn prev_page_long_comment_tab(&mut self) -> bool {
263        self.long_comment_tab.prev_page();
264        self.long_comment_tab.scroll_view_state.scroll_to_top();
265        true
266    }
267
268    fn next_page_long_comment_tab(&mut self) -> bool {
269        self.long_comment_tab.next_page();
270        self.long_comment_tab.scroll_view_state.scroll_to_top();
271        true
272    }
273
274    fn render_tabs(&self, area: Rect, buf: &mut Buffer) {
275        Tabs::new(Tab::iter().map(Tab::title))
276            .select(self.tab as usize)
277            .render(area, buf);
278    }
279
280    fn render_selected_tab(&mut self, area: Rect, buf: &mut Buffer) {
281        match self.tab {
282            Tab::Info => self.info_tab.render(area, buf),
283            Tab::ShortComment => self.short_comment_tab.render(area, buf),
284            Tab::LongComment => self.long_comment_tab.render(area, buf),
285        };
286    }
287}
288
289impl<T> Widget for &mut App<T>
290where
291    T: Client + Send + Sync + 'static,
292{
293    fn render(self, area: Rect, buf: &mut Buffer) {
294        let vertical = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]);
295        let [header_area, tab_area] = vertical.areas(area);
296
297        self.render_tabs(header_area, buf);
298        self.render_selected_tab(tab_area, buf);
299    }
300}
301
302#[derive(Clone, Copy, Default, Display, FromRepr, EnumIter)]
303enum Tab {
304    #[default]
305    #[strum(to_string = "简介")]
306    Info,
307    #[strum(to_string = "短评")]
308    ShortComment,
309    #[strum(to_string = "长评")]
310    LongComment,
311}
312
313impl Tab {
314    fn next(self) -> Self {
315        let current_index = self as usize;
316        let next_index = current_index.saturating_add(1);
317        Self::from_repr(next_index).unwrap_or(Tab::Info)
318    }
319
320    fn title(self) -> String {
321        format!(" {self} ")
322    }
323}
324
325struct InfoTab {
326    novel_info_str: String,
327    cover_state: Option<StatefulProtocol>,
328    scroll_view_state: ScrollViewState,
329}
330
331impl InfoTab {
332    async fn new<T>(client: Arc<T>, config: &Info) -> Result<Self>
333    where
334        T: Client + Send + Sync + 'static,
335    {
336        let novel_info = utils::novel_info(&client, config.novel_id).await?;
337        let novel_info_str = utils::novel_info_to_string(&novel_info, &config.converts)?;
338
339        let picker = Picker::from_query_stdio().unwrap_or(Picker::halfblocks());
340
341        tracing::debug!("protocol type: {:?}", picker.protocol_type());
342        tracing::debug!("font size: {:?}", picker.font_size());
343
344        let mut cover_image = None;
345        if let Some(ref url) = novel_info.cover_url {
346            match client.image(url).await {
347                Ok(image) => cover_image = Some(image),
348                Err(err) => {
349                    tracing::error!("Cover image download failed: `{err}`");
350                }
351            }
352        }
353
354        let cover_state = cover_image.map(|image| picker.new_resize_protocol(image));
355
356        Ok(Self {
357            novel_info_str,
358            cover_state,
359            scroll_view_state: ScrollViewState::default(),
360        })
361    }
362
363    fn render_image(&mut self, area: Rect, buf: &mut Buffer) {
364        if let Some(cover_state) = self.cover_state.as_mut() {
365            let block = Block::bordered();
366            let block_area = block.inner(area);
367            Widget::render(block, area, buf);
368
369            StatefulWidget::render(
370                StatefulImage::default().resize(Resize::Scale(Some(FilterType::Lanczos3))),
371                block_area,
372                buf,
373                cover_state,
374            );
375        }
376    }
377
378    fn render_paragraph(&mut self, area: Rect, buf: &mut Buffer) {
379        let paragraph = ScrollableParagraph::new(self.novel_info_str.clone());
380        StatefulWidgetRef::render_ref(&paragraph, area, buf, &mut self.scroll_view_state);
381    }
382}
383
384impl Widget for &mut InfoTab {
385    fn render(self, area: Rect, buf: &mut Buffer) {
386        if self.cover_state.is_some() {
387            let layout = Layout::default()
388                .direction(Direction::Horizontal)
389                .constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
390                .split(area);
391
392            self.render_image(layout[0], buf);
393            self.render_paragraph(layout[1], buf);
394        } else {
395            self.render_paragraph(area, buf);
396        }
397    }
398}
399
400struct CommentTab<T> {
401    client: Arc<T>,
402    comments: Vec<Comment>,
403    scroll_view_state: ScrollViewState,
404    novel_id: u32,
405    page: u16,
406    size: u16,
407    max_page: Option<u16>,
408    comment_type: CommentType,
409    converts: Vec<Convert>,
410}
411
412impl<T> CommentTab<T>
413where
414    T: Client + Send + Sync + 'static,
415{
416    async fn new(client: Arc<T>, config: &Info, comment_type: CommentType) -> Result<Self> {
417        let size = match comment_type {
418            CommentType::Short => 20,
419            CommentType::Long => 5,
420        };
421
422        Ok(Self {
423            client,
424            comments: Vec::new(),
425            scroll_view_state: ScrollViewState::default(),
426            novel_id: config.novel_id,
427            page: 0,
428            size,
429            max_page: None,
430            comment_type,
431            converts: config.converts.clone(),
432        })
433    }
434
435    fn prev_page(&mut self) -> bool {
436        if self.page >= 1 {
437            self.page -= 1;
438            true
439        } else {
440            false
441        }
442    }
443
444    fn next_page(&mut self) -> bool {
445        if !self.gte_max_page() {
446            self.page += 1;
447            true
448        } else {
449            false
450        }
451    }
452
453    fn gte_max_page(&self) -> bool {
454        self.max_page
455            .as_ref()
456            .is_some_and(|max_page| self.page >= *max_page)
457    }
458
459    fn comments(&self) -> Result<Option<Vec<Comment>>> {
460        let page = self.page;
461        let size = self.size;
462        let novel_id = self.novel_id;
463        let comment_type = self.comment_type;
464        let client = Arc::clone(&self.client);
465
466        let comments = task::block_in_place(move || {
467            Handle::current().block_on(async move {
468                client
469                    .comments(novel_id, comment_type, false, page, size)
470                    .await
471            })
472        })?;
473
474        Ok(comments)
475    }
476
477    fn title(&self) -> Result<String> {
478        let title = if let Some(max_page) = self.max_page {
479            format!("第 {} 页,共 {} 页", self.page + 1, max_page + 1)
480        } else {
481            format!("第 {} 页", self.page + 1)
482        };
483
484        utils::convert_str(title, &self.converts, false)
485    }
486
487    fn content(&self) -> Result<String> {
488        let content = self
489            .comments
490            .iter()
491            .skip((self.page * self.size) as usize)
492            .take(self.size as usize)
493            .map(|comment| match comment {
494                Comment::Short(comment) => comment.content.join("\n"),
495                Comment::Long(comment) => {
496                    format!("{}\n{}", comment.title, comment.content.join("\n"))
497                }
498            })
499            .collect::<Vec<String>>()
500            .join("\n\n\n");
501
502        utils::convert_str(content, &self.converts, false)
503    }
504}
505
506impl<T> Widget for &mut CommentTab<T>
507where
508    T: Client + Send + Sync + 'static,
509{
510    fn render(self, area: Rect, buf: &mut Buffer) {
511        if T::has_this_type_of_comments(self.comment_type) {
512            if !self.gte_max_page() && self.comments.len() < ((self.page + 1) * self.size) as usize
513            {
514                let comments = self.comments().expect("failed to get comments");
515
516                if let Some(comments) = comments {
517                    if comments.len() < self.size as usize {
518                        self.max_page = Some(self.page);
519                    }
520                    self.comments.extend(comments);
521                } else {
522                    self.max_page = Some(self.page - 1);
523                    self.page -= 1;
524                }
525            }
526
527            let paragraph =
528                ScrollableParagraph::new(self.content().expect("failed to get content"))
529                    .title(Line::from(self.title().expect("failed to get title")).right_aligned());
530            StatefulWidgetRef::render_ref(&paragraph, area, buf, &mut self.scroll_view_state);
531        } else {
532            let content = utils::convert_str("无此类型评论", &self.converts, false)
533                .expect("convert_str() failed");
534            let paragraph = Paragraph::new(content)
535                .wrap(Wrap { trim: false })
536                .block(Block::bordered());
537            Widget::render(paragraph, area, buf);
538        }
539    }
540}