changxi 0.2.0

TUI EPUB Reader
use crate::app::App;
use crate::core::{Chapter, ContentElement, Reader};
use crate::ui::{Component, count_lines, get_border_type};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::{
    Frame,
    layout::{Margin, Rect},
    widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
};
use ratatui_image::sliced::{SignedPosition, SlicedImage};
use ratatui_image::{StatefulImage, picker::Picker};

pub struct ReadingView;

impl Component for ReadingView {
    fn render(&self, f: &mut Frame, area: Rect, app: &mut App, picker: &mut Picker) {
        if let Ok(chapter) = app.reader.get_chapter(app.current_chapter) {
            let width = area.width.saturating_sub(2) as usize;
            let viewport_height = area.height.saturating_sub(2) as usize;

            // Elements heights calculation
            let mut heights = Vec::new();
            for element in &chapter.elements {
                heights.push(count_lines(element, width));
            }
            let total_height: usize = heights.iter().sum();
            app.total_height = total_height;
            app.viewport_height = viewport_height;

            // Handle go_to_end flag
            if app.scroll_to_end && total_height > viewport_height {
                app.scroll = total_height.saturating_sub(viewport_height);
                app.scroll_to_end = false;
            }

            // Handle go_to_start flag
            if app.scroll_to_start {
                app.scroll = 0;
                app.scroll_to_start = false;
            }

            if app.scroll + viewport_height > total_height && total_height > viewport_height {
                app.scroll = total_height.saturating_sub(viewport_height);
            }

            let chapter_title = format!(" {} ", chapter.title);

            render_chapter_content(
                f,
                area,
                app,
                picker,
                &chapter,
                app.scroll,
                &heights,
                viewport_height,
                Some(chapter_title),
                true,
            );

            let max_scroll = total_height.saturating_sub(viewport_height);
            let scroll_position = app.scroll.min(max_scroll);

            // Render Scrollbar
            let mut scrollbar_state = ScrollbarState::new(total_height)
                .position(scroll_position)
                .viewport_content_length(viewport_height);
            f.render_stateful_widget(
                Scrollbar::new(ScrollbarOrientation::VerticalRight)
                    .begin_symbol(Some(""))
                    .end_symbol(Some("")),
                area.inner(Margin {
                    vertical: 1,
                    horizontal: 0,
                }),
                &mut scrollbar_state,
            );
        }
    }
}

pub struct ContinuousReadingView;

impl Component for ContinuousReadingView {
    fn render(&self, f: &mut Frame, area: Rect, app: &mut App, picker: &mut Picker) {
        let width = area.width.saturating_sub(2) as usize;
        let viewport_height = area.height.saturating_sub(2) as usize;

        app.viewport_height = viewport_height;
        app.update_layout(width);

        // Handle go_to_end flag
        if app.scroll_to_end {
            let total_height = app.chapter_heights[app.current_chapter];
            if total_height > viewport_height {
                app.scroll = total_height.saturating_sub(viewport_height);
            } else {
                app.scroll = 0;
            }
            app.scroll_to_end = false;
        }

        // Handle go_to_start flag
        if app.scroll_to_start {
            app.scroll = 0;
            app.scroll_to_start = false;
        }

        let mut current_y = 0;
        let mut chapter_index = app.current_chapter;

        while current_y < viewport_height && chapter_index < app.reader.chapter_count() {
            if let Ok(chapter) = app.reader.get_chapter(chapter_index) {
                let mut heights = Vec::new();
                for element in &chapter.elements {
                    heights.push(count_lines(element, width));
                }
                let total_chapter_height: usize = heights.iter().sum();

                let render_scroll = if chapter_index == app.current_chapter {
                    app.scroll
                } else {
                    0
                };

                let remaining_viewport = viewport_height.saturating_sub(current_y);
                let content_height = total_chapter_height.saturating_sub(render_scroll);
                let visible_content_height = content_height.min(remaining_viewport);

                if visible_content_height > 0
                    || (chapter_index == app.current_chapter && visible_content_height == 0)
                {
                    let chapter_area = Rect {
                        x: area.x + 1,
                        y: area.y + 1 + current_y as u16,
                        width: area.width.saturating_sub(2),
                        height: visible_content_height as u16,
                    };

                    render_chapter_content(
                        f,
                        chapter_area,
                        app,
                        picker,
                        &chapter,
                        render_scroll,
                        &heights,
                        visible_content_height,
                        None,
                        false,
                    );

                    current_y += visible_content_height;
                }
                chapter_index += 1;
            } else {
                break;
            }
        }

        let border_type = get_border_type(&app.config);

        // Global border for the whole view
        let chapter_title = app
            .chapter_titles
            .get(app.current_chapter)
            .cloned()
            .unwrap_or_default();
        let block = Block::default()
            .borders(Borders::ALL)
            .border_type(border_type)
            .title(format!(" {} ", chapter_title));
        f.render_widget(block, area);

        // Render Global Scrollbar
        let total_book_height = app.get_total_book_height();
        let global_scroll = app.get_global_scroll();
        let max_scroll = total_book_height.saturating_sub(viewport_height);
        let scroll_position = global_scroll.min(max_scroll);

        let mut scrollbar_state = ScrollbarState::new(total_book_height)
            .position(scroll_position)
            .viewport_content_length(viewport_height);
        f.render_stateful_widget(
            Scrollbar::new(ScrollbarOrientation::VerticalRight)
                .begin_symbol(Some(""))
                .end_symbol(Some("")),
            area.inner(Margin {
                vertical: 1,
                horizontal: 0,
            }),
            &mut scrollbar_state,
        );
    }
}

pub fn render_chapter_content(
    f: &mut Frame,
    area: Rect,
    app: &mut App,
    picker: &mut Picker,
    chapter: &Chapter,
    scroll: usize,
    heights: &[usize],
    viewport_height: usize,
    title: Option<String>,
    show_borders: bool,
) {
    // Render elements
    let mut current_y_offset = 0;
    let content_rect = if show_borders {
        area.inner(Margin {
            vertical: 1,
            horizontal: 1,
        })
    } else {
        area
    };
    let border_type = get_border_type(&app.config);

    for (i, element) in chapter.elements.iter().enumerate() {
        let h = heights[i];

        // Check if element is at least partially visible
        if current_y_offset + h > scroll && current_y_offset < scroll + viewport_height {
            let rel_y = current_y_offset as i32 - scroll as i32;

            match element {
                ContentElement::Text(spans) => {
                    let start_line = if rel_y < 0 { (-rel_y) as u16 } else { 0 };
                    let draw_y = if rel_y < 0 { 0 } else { rel_y as u16 };
                    let draw_h = (h as u16)
                        .saturating_sub(start_line)
                        .min((viewport_height as u16).saturating_sub(draw_y));

                    if draw_h > 0 {
                        let area = Rect {
                            x: content_rect.x,
                            y: content_rect.y + draw_y,
                            width: content_rect.width,
                            height: draw_h,
                        };

                        let mut ratatui_spans = Vec::new();
                        for s in spans {
                            let mut style = Style::default();
                            if s.style.bold {
                                style = style.add_modifier(Modifier::BOLD);
                            }
                            if s.style.italic {
                                style = style.add_modifier(Modifier::ITALIC);
                            }
                            if s.style.underline {
                                style = style.add_modifier(Modifier::UNDERLINED);
                            }
                            if s.style.strikethrough {
                                style = style.add_modifier(Modifier::CROSSED_OUT);
                            }
                            ratatui_spans.push(Span::styled(&s.text, style));
                        }

                        let p = Paragraph::new(Line::from(ratatui_spans))
                            .wrap(Wrap { trim: true })
                            .scroll((start_line, 0));
                        f.render_widget(p, area);
                    }
                }
                ContentElement::Image(path) => {
                    if (app.config.image_type == "resize") {
                        let draw_y = if rel_y < 0 { 0 } else { rel_y as u16 };
                        let visible_h = if rel_y < 0 {
                            (h as i32 + rel_y).max(0) as u16
                        } else {
                            (h as u16).min((viewport_height as u16).saturating_sub(draw_y))
                        };

                        if visible_h > 0
                            && let Some(protocol) = app.get_image_protocol(path, picker)
                        {
                            let area = Rect {
                                x: content_rect.x,
                                y: content_rect.y + draw_y,
                                width: content_rect.width,
                                height: visible_h,
                            };
                            f.render_stateful_widget(StatefulImage::default(), area, protocol);
                        }
                    } else if app.config.image_type == "sliced" {
                        // Calculate visible portion
                        let element_top = current_y_offset;
                        let element_bottom = current_y_offset + h;
                        let viewport_top = scroll;
                        let viewport_bottom = scroll + viewport_height;

                        if element_bottom > viewport_top && element_top < viewport_bottom {
                            // Calculate visible vertical range in viewport coordinates
                            let start_y = element_top.saturating_sub(viewport_top);
                            let end_y = (element_bottom - viewport_top).min(viewport_height);
                            let visible_h = end_y.saturating_sub(start_y);

                            if visible_h > 0 {
                                let image_y_offset =
                                    (element_top as i32 - viewport_top as i32) as i16;

                                if let Some(sliced) = app.get_sliced_protocol(
                                    path,
                                    content_rect.width,
                                    h as u16,
                                    picker,
                                ) {
                                    let area = Rect {
                                        x: content_rect.x,
                                        y: content_rect.y + start_y as u16,
                                        width: content_rect.width,
                                        height: visible_h as u16,
                                    };

                                    let position =
                                        SignedPosition::from((0, image_y_offset - start_y as i16));

                                    f.render_widget(SlicedImage::new(sliced, position), area);
                                }
                            }
                        }
                    }
                }
                ContentElement::BlankLine => {
                    // Render a blank line
                    let draw_y = if rel_y < 0 { 0 } else { rel_y as u16 };
                    let visible_h = if rel_y < 0 {
                        (h as i32 + rel_y).max(0) as u16
                    } else {
                        (h as u16).min((viewport_height as u16).saturating_sub(draw_y))
                    };

                    if visible_h > 0 {
                        let area = Rect {
                            x: content_rect.x,
                            y: content_rect.y + draw_y,
                            width: content_rect.width,
                            height: visible_h,
                        };

                        let blank_paragraph =
                            Paragraph::new(Line::from("")).wrap(Wrap { trim: true });
                        f.render_widget(blank_paragraph, area);
                    }
                }
            }
        }
        current_y_offset += h;
    }

    if show_borders {
        let mut block = Block::default()
            .borders(Borders::ALL)
            .border_type(border_type);
        if let Some(t) = title {
            block = block.title(t);
        }
        f.render_widget(block, area);
    }
}