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