use std::{path::PathBuf, sync::Arc, time::Duration};
use clap::Args;
use color_eyre::eyre::Result;
use fluent_templates::Loader;
use novel_api::{CiweimaoClient, CiyuanjiClient, Client, Comment, CommentType, SfacgClient};
use ratatui::{
buffer::Buffer,
crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
},
layout::{Alignment, Constraint, Direction, Layout, Rect},
widgets::{block::Title, Block, Paragraph, StatefulWidget, Tabs, Widget, Wrap},
Frame,
};
use ratatui_image::{
picker::Picker, protocol::StatefulProtocol, FilterType, Resize, StatefulImage,
};
use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
use tokio::{runtime::Handle, task};
use tracing::{debug, error};
use tui_scrollview::ScrollViewState;
use url::Url;
use super::{Mode, ScrollableParagraph};
use crate::{
cmd::{Convert, Source},
utils, Tui, LANG_ID, LOCALES,
};
#[must_use]
#[derive(Args)]
#[command(arg_required_else_help = true,
about = LOCALES.lookup(&LANG_ID, "info_command"))]
pub struct Info {
#[arg(help = LOCALES.lookup(&LANG_ID, "novel_id"))]
pub novel_id: u32,
#[arg(short, long,
help = LOCALES.lookup(&LANG_ID, "source"))]
pub source: Source,
#[arg(short, long, value_enum, value_delimiter = ',',
help = LOCALES.lookup(&LANG_ID, "converts"))]
pub converts: Vec<Convert>,
#[arg(long, default_value_t = false,
help = LOCALES.lookup(&LANG_ID, "ignore_keyring"))]
pub ignore_keyring: bool,
#[arg(long, num_args = 0..=1, default_missing_value = super::DEFAULT_PROXY,
help = LOCALES.lookup(&LANG_ID, "proxy"))]
pub proxy: Option<Url>,
#[arg(long, default_value_t = false,
help = LOCALES.lookup(&LANG_ID, "no_proxy"))]
pub no_proxy: bool,
#[arg(long, num_args = 0..=1, default_missing_value = super::default_cert_path(),
help = super::cert_help_msg())]
pub cert: Option<PathBuf>,
}
pub async fn execute(config: Info) -> Result<()> {
match config.source {
Source::Sfacg => {
let mut client = SfacgClient::new().await?;
super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
do_execute(client, config).await?
}
Source::Ciweimao => {
let mut client = CiweimaoClient::new().await?;
super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
utils::log_in(&client, &config.source, config.ignore_keyring).await?;
do_execute(client, config).await?
}
Source::Ciyuanji => {
let mut client = CiyuanjiClient::new().await?;
super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
utils::log_in_without_password(&client).await?;
do_execute(client, config).await?
}
}
Ok(())
}
async fn do_execute<T>(client: T, config: Info) -> Result<()>
where
T: Client + Send + Sync + 'static,
{
let client = Arc::new(client);
super::handle_ctrl_c(&client);
let mut terminal = crate::init_terminal()?;
App::new(client, config).await?.run(&mut terminal)?;
crate::restore_terminal()?;
Ok(())
}
struct App<T> {
mode: Mode,
tab: Tab,
info_tab: InfoTab,
short_comment_tab: CommentTab<T>,
long_comment_tab: CommentTab<T>,
}
impl<T> App<T>
where
T: Client + Send + Sync + 'static,
{
async fn new(client: Arc<T>, config: Info) -> Result<Self> {
Ok(App {
mode: Mode::default(),
tab: Tab::default(),
info_tab: InfoTab::new(Arc::clone(&client), &config).await?,
short_comment_tab: CommentTab::new(Arc::clone(&client), &config, CommentType::Short)
.await?,
long_comment_tab: CommentTab::new(Arc::clone(&client), &config, CommentType::Long)
.await?,
})
}
fn run(&mut self, terminal: &mut Tui) -> Result<()> {
self.draw(terminal)?;
while self.is_running() {
if self.handle_events()? {
self.draw(terminal)?;
}
}
Ok(())
}
fn is_running(&self) -> bool {
self.mode != Mode::Quit
}
fn draw(&mut self, terminal: &mut Tui) -> Result<()> {
terminal.draw(|frame| self.render_frame(frame))?;
Ok(())
}
fn render_frame(&mut self, frame: &mut Frame) {
frame.render_widget(self, frame.size());
}
fn handle_events(&mut self) -> Result<bool> {
if event::poll(Duration::from_secs_f64(1.0 / 60.0))? {
return match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
Ok(self.handle_key_event(key_event))
}
Event::Mouse(mouse_event) => Ok(self.handle_mouse_event(mouse_event)),
_ => Ok(false),
};
}
Ok(false)
}
fn handle_key_event(&mut self, key_event: KeyEvent) -> bool {
match key_event.code {
KeyCode::Char('q') | KeyCode::Esc => self.exit(),
KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
self.exit()
}
KeyCode::Tab => self.next_tab(),
KeyCode::Left => match self.tab {
Tab::ShortComment => self.prev_page_short_comment_tab(),
Tab::LongComment => self.prev_page_long_comment_tab(),
_ => false,
},
KeyCode::Right => match self.tab {
Tab::ShortComment => self.next_page_short_comment_tab(),
Tab::LongComment => self.next_page_long_comment_tab(),
_ => false,
},
KeyCode::Up => match self.tab {
Tab::Info => self.scroll_up_info_tab(),
Tab::ShortComment => self.scroll_up_short_comment_tab(),
Tab::LongComment => self.scroll_up_long_comment_tab(),
},
KeyCode::Down => match self.tab {
Tab::Info => self.scroll_down_info_tab(),
Tab::ShortComment => self.scroll_down_short_comment_tab(),
Tab::LongComment => self.scroll_down_long_comment_tab(),
},
_ => false,
}
}
fn handle_mouse_event(&mut self, mouse_event: MouseEvent) -> bool {
match mouse_event.kind {
MouseEventKind::ScrollUp => match self.tab {
Tab::Info => self.scroll_up_info_tab(),
Tab::ShortComment => self.scroll_up_short_comment_tab(),
Tab::LongComment => self.scroll_up_long_comment_tab(),
},
MouseEventKind::ScrollDown => match self.tab {
Tab::Info => self.scroll_down_info_tab(),
Tab::ShortComment => self.scroll_down_short_comment_tab(),
Tab::LongComment => self.scroll_down_long_comment_tab(),
},
_ => false,
}
}
fn exit(&mut self) -> bool {
self.mode = Mode::Quit;
false
}
fn next_tab(&mut self) -> bool {
self.tab = self.tab.next();
true
}
fn scroll_up_info_tab(&mut self) -> bool {
self.info_tab.scroll_view_state.scroll_up();
true
}
fn scroll_down_info_tab(&mut self) -> bool {
self.info_tab.scroll_view_state.scroll_down();
true
}
fn scroll_up_short_comment_tab(&mut self) -> bool {
self.short_comment_tab.scroll_view_state.scroll_up();
true
}
fn scroll_down_short_comment_tab(&mut self) -> bool {
self.short_comment_tab.scroll_view_state.scroll_down();
true
}
fn scroll_up_long_comment_tab(&mut self) -> bool {
self.long_comment_tab.scroll_view_state.scroll_up();
true
}
fn scroll_down_long_comment_tab(&mut self) -> bool {
self.long_comment_tab.scroll_view_state.scroll_down();
true
}
fn prev_page_short_comment_tab(&mut self) -> bool {
self.short_comment_tab.prev_page();
self.short_comment_tab.scroll_view_state.scroll_to_top();
true
}
fn next_page_short_comment_tab(&mut self) -> bool {
self.short_comment_tab.next_page();
self.short_comment_tab.scroll_view_state.scroll_to_top();
true
}
fn prev_page_long_comment_tab(&mut self) -> bool {
self.long_comment_tab.prev_page();
self.long_comment_tab.scroll_view_state.scroll_to_top();
true
}
fn next_page_long_comment_tab(&mut self) -> bool {
self.long_comment_tab.next_page();
self.long_comment_tab.scroll_view_state.scroll_to_top();
true
}
fn render_tabs(&self, area: Rect, buf: &mut Buffer) {
Tabs::new(Tab::iter().map(Tab::title))
.select(self.tab as usize)
.render(area, buf);
}
fn render_selected_tab(&mut self, area: Rect, buf: &mut Buffer) {
match self.tab {
Tab::Info => self.info_tab.render(area, buf),
Tab::ShortComment => self.short_comment_tab.render(area, buf),
Tab::LongComment => self.long_comment_tab.render(area, buf),
};
}
}
impl<T> Widget for &mut App<T>
where
T: Client + Send + Sync + 'static,
{
fn render(self, area: Rect, buf: &mut Buffer) {
let vertical = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]);
let [header_area, tab_area] = vertical.areas(area);
self.render_tabs(header_area, buf);
self.render_selected_tab(tab_area, buf);
}
}
#[derive(Clone, Copy, Default, Display, FromRepr, EnumIter)]
enum Tab {
#[default]
#[strum(to_string = "简介")]
Info,
#[strum(to_string = "短评")]
ShortComment,
#[strum(to_string = "长评")]
LongComment,
}
impl Tab {
fn next(self) -> Self {
let current_index = self as usize;
let next_index = current_index.saturating_add(1);
Self::from_repr(next_index).unwrap_or(Tab::Info)
}
fn title(self) -> String {
format!(" {self} ")
}
}
struct InfoTab {
novel_info_str: String,
cover_state: Option<Box<dyn StatefulProtocol>>,
scroll_view_state: ScrollViewState,
}
impl InfoTab {
async fn new<T>(client: Arc<T>, config: &Info) -> Result<Self>
where
T: Client + Send + Sync + 'static,
{
let novel_info = utils::novel_info(&client, config.novel_id).await?;
let novel_info_str = utils::novel_info_to_string(&novel_info, &config.converts)?;
let font_size = (12, 24);
#[cfg(not(target_os = "windows"))]
let mut picker = Picker::from_termios().unwrap_or(Picker::new(font_size));
#[cfg(target_os = "windows")]
let mut picker = Picker::new(font_size);
debug!("font size: {:?}", picker.font_size);
picker.guess_protocol();
let mut cover_image = None;
if let Some(ref url) = novel_info.cover_url {
match client.image(url).await {
Ok(image) => cover_image = Some(image),
Err(err) => {
error!("Cover image download failed: `{err}`");
}
}
}
let cover_state = cover_image.map(|image| {
let size = utils::terminal_size();
let width = (size.0 as u32 * picker.font_size.0 as u32) / 2;
let height = size.1 as u32 * picker.font_size.1 as u32;
picker.new_resize_protocol(image.resize(width, height, FilterType::Lanczos3))
});
Ok(Self {
novel_info_str,
cover_state,
scroll_view_state: ScrollViewState::default(),
})
}
fn render_image(&mut self, area: Rect, buf: &mut Buffer) {
if let Some(cover_state) = self.cover_state.as_mut() {
let block = Block::bordered();
let block_area = block.inner(area);
Widget::render(block, area, buf);
StatefulWidget::render(
StatefulImage::new(None).resize(Resize::Fit(Some(FilterType::Lanczos3))),
block_area,
buf,
cover_state,
);
}
}
fn render_paragraph(&mut self, area: Rect, buf: &mut Buffer) {
let paragraph = ScrollableParagraph::new(self.novel_info_str.as_str());
StatefulWidget::render(paragraph, area, buf, &mut self.scroll_view_state);
}
}
impl Widget for &mut InfoTab {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.cover_state.is_some() {
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
self.render_image(layout[0], buf);
self.render_paragraph(layout[1], buf);
} else {
self.render_paragraph(area, buf);
}
}
}
struct CommentTab<T> {
client: Arc<T>,
comments: Vec<Comment>,
scroll_view_state: ScrollViewState,
novel_id: u32,
page: u16,
size: u16,
max_page: Option<u16>,
comment_type: CommentType,
converts: Vec<Convert>,
}
impl<T> CommentTab<T>
where
T: Client + Send + Sync + 'static,
{
async fn new(client: Arc<T>, config: &Info, comment_type: CommentType) -> Result<Self> {
let size = match comment_type {
CommentType::Short => 20,
CommentType::Long => 5,
};
Ok(Self {
client,
comments: Vec::new(),
scroll_view_state: ScrollViewState::default(),
novel_id: config.novel_id,
page: 0,
size,
max_page: None,
comment_type,
converts: config.converts.clone(),
})
}
fn prev_page(&mut self) -> bool {
if self.page >= 1 {
self.page -= 1;
true
} else {
false
}
}
fn next_page(&mut self) -> bool {
if !self.gte_max_page() {
self.page += 1;
true
} else {
false
}
}
fn gte_max_page(&self) -> bool {
self.max_page
.as_ref()
.is_some_and(|max_page| self.page >= *max_page)
}
fn comments(&self) -> Result<Option<Vec<Comment>>> {
let page = self.page;
let size = self.size;
let novel_id = self.novel_id;
let comment_type = self.comment_type;
let client = Arc::clone(&self.client);
let comments = task::block_in_place(move || {
Handle::current().block_on(async move {
client
.comments(novel_id, comment_type, false, page, size)
.await
})
})?;
Ok(comments)
}
fn title(&self) -> Result<String> {
let title = if let Some(max_page) = self.max_page {
format!("第 {} 页,共 {} 页", self.page + 1, max_page + 1)
} else {
format!("第 {} 页", self.page + 1)
};
utils::convert_str(title, &self.converts, false)
}
fn content(&self) -> Result<String> {
let content = self
.comments
.iter()
.skip((self.page * self.size) as usize)
.take(self.size as usize)
.map(|comment| match comment {
Comment::Short(comment) => comment.content.join("\n"),
Comment::Long(comment) => {
format!("{}\n{}", comment.title, comment.content.join("\n"))
}
})
.collect::<Vec<String>>()
.join("\n\n\n");
utils::convert_str(content, &self.converts, false)
}
}
impl<T> Widget for &mut CommentTab<T>
where
T: Client + Send + Sync + 'static,
{
fn render(self, area: Rect, buf: &mut Buffer) {
if T::has_this_type_of_comments(self.comment_type) {
if !self.gte_max_page() && self.comments.len() < ((self.page + 1) * self.size) as usize
{
let comments = self.comments().expect("failed to get comments");
if let Some(comments) = comments {
if comments.len() < self.size as usize {
self.max_page = Some(self.page);
}
self.comments.extend(comments);
} else {
self.max_page = Some(self.page - 1);
self.page -= 1;
}
}
let paragraph =
ScrollableParagraph::new(self.content().expect("failed to get content")).title(
Title::from(self.title().expect("failed to get title"))
.alignment(Alignment::Right),
);
StatefulWidget::render(paragraph, area, buf, &mut self.scroll_view_state);
} else {
let content = utils::convert_str("无此类型评论", &self.converts, false)
.expect("convert_str() failed");
let paragraph = Paragraph::new(content)
.wrap(Wrap { trim: false })
.block(Block::bordered());
Widget::render(paragraph, area, buf);
}
}
}