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}