novel_cli/cmd/
info.rs

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