trnovel 0.10.4

Terminal reader for novel
Documentation
use crate::{
    ThemeConfig,
    components::{WarningModal, list_select::ListSelect, search_input::SearchInput},
    errors::Errors,
    hooks::{UseInitState, UseThemeConfig},
    pages::network_novel::book_detail::BookDetailState,
};
use crossterm::event::{Event, KeyCode, KeyEventKind};
use parse_book_source::{BookListItem, Engine};
use ratatui::{
    text::{Line, Span},
    widgets::{Block, Padding, Paragraph, WidgetRef, Wrap},
};
use ratatui_kit::prelude::*;
use tui_widget_list::{ListBuildContext, ListState};

#[derive(Props)]
pub struct FindBooksProps {
    pub engine: State<Option<Engine>>,
    pub current_explore: Option<super::ExploreListItem>,
    pub is_editing: bool,
}

#[component]
pub fn FindBooks(props: &FindBooksProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let mut filter_text = hooks.use_state(String::default);
    let theme = hooks.use_theme_config();
    let is_inputting = *hooks.use_context::<State<bool>>();
    let mut page = hooks.use_state(|| 1);
    let page_size = hooks.use_state(|| 20);
    let list_state = hooks.use_state(ListState::default);
    let mut navigate = hooks.use_navigate();
    let is_editing = props.is_editing;

    hooks.use_events(move |event| {
        if let Event::Key(key) = event
            && key.kind == KeyEventKind::Press
            && is_editing
            && !is_inputting.get()
        {
            match key.code {
                KeyCode::Char('h') | KeyCode::Left if page.get() > 1 => {
                    page.set(page.get() - 1);
                }
                KeyCode::Char('l') | KeyCode::Right => {
                    page.set(page.get() + 1);
                }
                _ => {}
            }
        }
    });

    hooks.use_effect(
        || {
            page.set(1);
        },
        props.current_explore.clone(),
    );

    let (books, loading, error) = hooks.use_effect_state(
        {
            let engine = props.engine.read().clone();
            let url = props.current_explore.as_ref().map(|e| e.0.url.clone());
            let future = if filter_text.read().is_empty() {
                engine.zip(url).map(|(engine, url)| {
                    let page = page.get();
                    let page_size = page_size.get();
                    tokio::spawn(async move { engine.explore(&url, page, page_size).await })
                })
            } else {
                engine.map(|engine| {
                    let page = page.get();
                    let page_size = page_size.get();
                    let filter_text = filter_text.read().clone();
                    tokio::spawn(async move { engine.search(&filter_text, page, page_size).await })
                })
            };

            async move {
                if let Some(future) = future {
                    let res = future.await??;
                    list_state.write().select(Some(0));
                    return Ok(res);
                }

                Ok::<Vec<BookListItem>, Errors>(vec![])
            }
        },
        (
            props.current_explore.clone(),
            filter_text.read().clone(),
            props.engine.read().is_some(),
            page.get(),
        ),
    );

    element!(View{
        SearchInput(
            is_editing: is_editing,
            placeholder: "按s键搜索书籍, 按tab键切换频道",
            on_submit: move |text| {
                filter_text.set(text);
                page.set(1);
                true
            },
            clear_on_escape: true,
            on_clear: move |_| {
                filter_text.set(String::default());
            },
        )
        ListSelect<BookListItem>(
            items: books.read().clone().unwrap_or_default(),
            top_title: Line::from(
                if let Some(explore)= &props.current_explore{
                    format!("选择书籍 ({})",explore.0.title)
                }else{
                    "选择书籍".to_string()
                }
            ).style(theme.basic.border_title).centered(),
            bottom_title: if books.read().as_ref().map(|b|b.len()).unwrap_or(0)>0{
                Line::from(
                    format!("{} 页, {}/{}", page.get(), list_state.read().selected.unwrap_or(0)+1, books.read().as_ref().map(|b|b.len()).unwrap_or(0))
                ).centered().style(theme.basic.border_info)
            }else{
                Line::from("暂无书籍").centered().style(theme.basic.border_info)
            },
            is_editing: !is_inputting.get() && props.is_editing,
            empty_message: "暂无书籍,请切换频道,或者搜索",
            loading: loading.get(),
            loading_tip: if filter_text.read().is_empty() {
                "加载中..."
            } else {
                "搜索中..."
            },
            render_item: move |context:&ListBuildContext| {
                let list=books.read().clone().unwrap_or_default();
                (FindBookItem {
                    book_list_item: list[context.index].clone(),
                    selected: context.is_selected,
                    theme: theme.clone(),
                }.into(),8)
            },
            state: list_state,
            on_select: {
                let engine = props.engine.read().clone();
                move |item:BookListItem| {
                    if let Some(engine)=&engine{
                        navigate.push_with_state(
                            "/book-detail",
                            BookDetailState::new(item,engine.clone()),
                        );
                    }
                }
            },
        )
        WarningModal(
            tip: format!("{:?}", error.read().as_ref()),
            is_error: error.read().is_some(),
            open: error.read().is_some(),
        )
    })
}

pub struct FindBookItem {
    pub book_list_item: BookListItem,
    pub selected: bool,
    pub theme: ThemeConfig,
}

impl WidgetRef for FindBookItem {
    fn render_ref(&self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) {
        let item = &self.book_list_item;

        let block = if self.selected {
            Block::bordered()
                .padding(Padding::horizontal(2))
                .style(self.theme.selected)
        } else {
            Block::bordered().padding(Padding::horizontal(2))
        };
        let inner_area = block.inner(area);

        block.render_ref(area, buf);

        let text_style = if self.selected {
            self.theme.basic.text.patch(self.theme.selected)
        } else {
            self.theme.basic.text
        };

        let mut text = vec![];

        if !item.info.name.is_empty() {
            text.push(
                Line::from(vec![
                    Span::from("名称:").style(self.theme.basic.border_info),
                    Span::from(&item.info.name),
                ])
                .style(text_style),
            );
        }
        if !item.info.author.is_empty() {
            text.push(
                Line::from(vec![
                    Span::from("作者:").style(self.theme.basic.border_info),
                    Span::from(&item.info.author),
                ])
                .style(text_style),
            );
        }

        if !item.info.kind.is_empty() {
            text.push(
                Line::from(vec![
                    Span::from("类型:").style(self.theme.basic.border_info),
                    Span::from(&item.info.kind),
                ])
                .style(text_style),
            );
        }

        if !item.info.last_chapter.is_empty() {
            text.push(
                Line::from(vec![
                    Span::from("最新章节:").style(self.theme.basic.border_info),
                    Span::from(&item.info.last_chapter),
                ])
                .style(text_style),
            );
        }

        if !item.info.word_count.is_empty() {
            text.push(
                Line::from(vec![
                    Span::from("字数:").style(self.theme.basic.border_info),
                    Span::from(&item.info.word_count),
                ])
                .style(text_style),
            );
        }

        if !item.info.intro.is_empty() {
            text.push(
                Line::from(vec![
                    Span::from("简介:").style(self.theme.basic.border_info),
                    Span::from(&item.info.intro),
                ])
                .style(text_style),
            );
        }

        let paragraph = Paragraph::new(text).wrap(Wrap { trim: true });
        paragraph.render_ref(inner_area, buf);
    }
}