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(¶graph, 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(¶graph, 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}