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