use crate::{
TTSConfig,
components::Loading,
hooks::UseScrollbar,
keymap::{ReaderAction, display_first_key},
theme::ReaderTheme,
};
use novel_tts::utils::TextSegment;
use ratatui::{
layout::{Constraint, Direction, Flex, Margin},
style::Style,
text::{Line, Span},
widgets::Paragraph,
};
use ratatui_kit::prelude::*;
use ratatui_kit_keymap::UseKeymapHandler;
use std::time::Duration;
#[derive(Clone, Copy, PartialEq)]
enum Edge {
None,
Prev,
Next,
AtFirst,
AtLast,
}
pub fn visible_lines(height: u16) -> usize {
(height as usize).saturating_sub(3).max(1)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ScrollTarget {
Ratio(f64),
ChapterEnd,
Overscroll { ratio: f64, end_scroll: usize },
}
impl ScrollTarget {
pub fn from_ratio(ratio: f64) -> Self {
if ratio >= 1.0 {
Self::ChapterEnd
} else {
Self::Ratio(ratio)
}
}
pub fn as_ratio(self) -> f64 {
match self {
Self::Ratio(ratio) | Self::Overscroll { ratio, .. } => ratio,
Self::ChapterEnd => 1.0,
}
}
fn resolve(self, total: usize, end_scroll: usize) -> usize {
let line_of = |ratio: f64| (ratio * total as f64).round() as usize;
match self {
Self::ChapterEnd => end_scroll,
Self::Ratio(ratio) => line_of(ratio).min(end_scroll),
Self::Overscroll {
ratio,
end_scroll: at,
} if at == end_scroll => line_of(ratio).min(total.saturating_sub(1)),
Self::Overscroll { .. } => end_scroll,
}
}
}
impl Default for ScrollTarget {
fn default() -> Self {
Self::Ratio(0.0)
}
}
#[derive(Default, Props)]
pub struct ReadContentProps {
pub content: String,
pub is_scroll: bool,
pub is_loading: bool,
pub width: u16,
pub height: u16,
pub on_prev: Handler<'static, bool>,
pub on_next: Handler<'static, ()>,
pub chapter_name: String,
pub chapter_percent: f64,
pub scroll_target: Option<State<ScrollTarget>>,
pub has_prev: bool,
pub has_next: bool,
}
#[component]
pub fn ReadContent(
props: &mut ReadContentProps,
mut hooks: Hooks,
) -> impl Into<AnyElement<'static>> {
let theme = hooks.use_component_theme::<ReaderTheme>();
let mut reader_display = hooks.use_atom(&crate::state::READER_DISPLAY);
let mut is_listening = hooks.use_state(|| false);
let mut highlight_range = hooks.use_state(|| None::<TextSegment>);
let tts_config = *hooks.use_context::<State<TTSConfig>>();
let novel_tts = hooks.use_atom(&crate::state::NOVEL_TTS);
let mut chapter_tts = hooks.use_state(|| None::<novel_tts::ChapterTTS>);
let mut player = hooks.use_state(|| None::<novel_tts::Player>);
let mut is_listening_done = hooks.use_state(|| false);
let mut on_prev = props.on_prev.take();
let mut on_next = props.on_next.take();
let mut edge = hooks.use_state(|| Edge::None);
if is_listening_done.get() && tts_config.read().auto_play {
if props.has_next {
on_next(());
} else {
is_listening.set(false);
}
is_listening_done.set(false);
}
hooks.use_effect(
move || {
if let Some(player) = player.write().take() {
player.sink.stop();
}
if let Some(chapter_tts) = chapter_tts.write().take() {
chapter_tts.cancel();
}
is_listening.set(false);
},
props.content.clone(),
);
hooks.use_effect(
|| {
if let Some(player) = player.write().as_mut() {
player.set_speed(tts_config.read().speed);
player.set_volume(tts_config.read().volume);
}
},
format!("{}-{}", tts_config.read().speed, tts_config.read().volume),
);
hooks.use_async_effect(
{
let content = props.content.clone();
async move {
if let Some(tts) = novel_tts.read().as_ref()
&& tts_config.read().auto_play
&& chapter_tts.read().is_none()
{
let mut chapter = if let Some(chapter_tts) = chapter_tts.read().as_ref() {
chapter_tts.cancel();
chapter_tts.clone()
} else {
tts.chapter_tts(&content)
};
let (queue_output, mut receiver) =
chapter.stream(tts_config.read().voice.into(), |e| {
eprintln!("{e:?}");
});
let texts = chapter.texts.clone();
tokio::spawn(async move {
while let Some(index) = receiver.recv().await {
if let Some(index) = index {
highlight_range.set(Some(texts[index].clone()));
} else {
is_listening_done.set(true);
}
}
});
let p = tts.player(queue_output);
p.set_speed(tts_config.read().speed);
p.set_volume(tts_config.read().volume);
is_listening.set(true);
player.set(Some(p));
chapter_tts.set(Some(chapter));
}
}
},
(props.content.clone(), novel_tts.read().is_some()),
);
hooks.use_async_effect(
async move {
if let Some(tts) = novel_tts.read().as_ref()
&& let Some(chapter) = chapter_tts.write().as_mut()
{
let (queue_output, mut receiver) =
chapter.stream(tts_config.read().voice.into(), |e| {
eprintln!("{e:?}");
});
let texts = chapter.texts.clone();
tokio::spawn(async move {
while let Some(index) = receiver.recv().await {
if let Some(index) = index {
highlight_range.set(Some(texts[index].clone()));
} else {
is_listening_done.set(true);
}
}
});
let p = tts.player(queue_output);
p.set_speed(tts_config.read().speed);
p.set_volume(tts_config.read().volume);
is_listening.set(true);
player.set(Some(p));
}
},
tts_config.read().voice,
);
let paragraph = hooks.use_memo(
|| {
let paragraph = if let Some(segment) = highlight_range.read().as_ref()
&& is_listening.get()
{
Paragraph::new(highlight(
&props.content,
segment,
(props.width as usize).saturating_sub(2),
theme.tts_highlight,
))
} else {
Paragraph::new(textwrap::fill(
&props.content,
(props.width as usize).saturating_sub(2),
))
};
TextParagraph::from(paragraph)
},
(
is_listening.get(),
highlight_range.read().clone(),
props.content.clone(),
props.width,
theme.tts_highlight,
),
);
let scroll_target = hooks.use_state(ScrollTarget::default);
let mut scroll_target = props.scroll_target.unwrap_or(scroll_target);
let is_scroll = props.is_scroll;
let total = paragraph.line_count(props.width.saturating_sub(2));
let view = visible_lines(props.height);
let end_scroll = total.saturating_sub(view);
let mut current_line = hooks.use_memo(
|| scroll_target.get().resolve(total, end_scroll),
(total, view, scroll_target.get()),
);
let mut current_time = hooks.use_state(String::default);
hooks.use_future(async move {
current_time.set(chrono::Local::now().format("%H:%M").to_string());
tokio::time::sleep(Duration::from_secs(1)).await;
});
hooks.use_scrollbar(end_scroll, Some(current_line));
let props_content = props.content.clone();
let has_prev = props.has_prev;
let has_next = props.has_next;
let step = reader_display.read().page_step(view);
let reader_keymap = hooks.use_atom(&crate::state::KEYMAP).read().reader.clone();
hooks.use_keymap_handler(
EventScope::Current,
EventPriority::Normal,
reader_keymap.clone(),
move |action, _key| {
if !is_scroll {
return EventResult::Ignored;
}
let delta = match action {
ReaderAction::PageUp | ReaderAction::PageDown => step,
_ => 1,
};
match action {
ReaderAction::ScrollUp | ReaderAction::PageUp => {
if current_line > 0 {
current_line = current_line.saturating_sub(delta);
scroll_target.set(ScrollTarget::Ratio(current_line as f64 / total as f64));
edge.set(Edge::None);
} else if !has_prev {
edge.set(Edge::AtFirst);
} else if edge.get() == Edge::Prev {
edge.set(Edge::None);
on_prev(true);
} else {
edge.set(Edge::Prev);
}
EventResult::Consumed
}
ReaderAction::ScrollDown | ReaderAction::PageDown => {
if current_line < end_scroll {
current_line += delta;
let ratio = current_line as f64 / total as f64;
scroll_target.set(if current_line > end_scroll {
ScrollTarget::Overscroll { ratio, end_scroll }
} else {
ScrollTarget::Ratio(ratio)
});
edge.set(Edge::None);
} else if !has_next {
edge.set(Edge::AtLast);
} else if edge.get() == Edge::Next {
edge.set(Edge::None);
on_next(());
} else {
edge.set(Edge::Next);
}
EventResult::Consumed
}
ReaderAction::PrevChapter => {
edge.set(Edge::None);
on_prev(false);
EventResult::Consumed
}
ReaderAction::NextChapter => {
edge.set(Edge::None);
on_next(());
EventResult::Consumed
}
ReaderAction::GoTop => {
scroll_target.set(ScrollTarget::Ratio(0.0));
edge.set(Edge::None);
EventResult::Consumed
}
ReaderAction::GoBottom => {
scroll_target.set(ScrollTarget::ChapterEnd);
edge.set(Edge::None);
EventResult::Consumed
}
ReaderAction::VolumeUp => {
tts_config.write().increase_volume();
EventResult::Consumed
}
ReaderAction::VolumeDown => {
tts_config.write().decrease_volume();
EventResult::Consumed
}
ReaderAction::TogglePlay => {
if let Some(player) = player.read().as_ref() {
if is_listening.get() {
player.pause();
is_listening.set(false);
} else {
player.play();
is_listening.set(true);
}
} else if let Some(tts) = novel_tts.read().as_ref() {
let mut chapter = tts.chapter_tts(&props_content);
let (queue_output, mut receiver) =
chapter.stream(tts_config.read().voice.into(), |e| {
eprintln!("{e:?}");
});
let texts = chapter.texts.clone();
tokio::spawn(async move {
while let Some(index) = receiver.recv().await {
if let Some(index) = index {
highlight_range.set(Some(texts[index].clone()));
} else {
is_listening_done.set(true);
}
}
});
let p = tts.player(queue_output);
p.set_speed(tts_config.read().speed);
p.set_volume(tts_config.read().volume);
is_listening.set(true);
player.set(Some(p));
chapter_tts.set(Some(chapter));
}
EventResult::Consumed
}
ReaderAction::ToggleTitle => {
let mut display = *reader_display.read();
display.show_title = !display.show_title;
reader_display.set(display);
EventResult::Consumed
}
_ => EventResult::Ignored,
}
},
);
let show_title = reader_display.read().show_title;
element!(Border(
border_style: theme.border,
top_title: if show_title {
Some(Line::from(props.chapter_name.to_string()).style(theme.chapter).centered())
}else{
None
},
bottom_title: if show_title {
Some((if is_listening.get(){
Line::from(
format!(
"播放中: 播放速度{} / 音量{}",
tts_config.read().speed,
tts_config.read().volume,
)
)
.style(theme.footer)
}else{
Line::from(format!(
"按 {} 播放/暂停",
display_first_key(&reader_keymap, ReaderAction::TogglePlay)
)).style(theme.footer)
}).style(theme.footer).centered())
}else{
None
},
){
{ if props.is_loading {
element!(Loading(tip:"加载内容中...")).into_any()
}else{
element!(Text(
text: paragraph,
style:theme.content,
scroll: (current_line as u16,0))
).into_any()
} }
View(
flex_direction: Direction::Horizontal,
justify_content: Flex::SpaceBetween,
height: Constraint::Length(1),
margin: Margin::new(1,0),
){
widget(Line::from(format!("{}/{} 行", (current_line + view).min(total), total)).style(theme.footer))
widget(Line::from(match edge.get() {
Edge::Next => format!(
"● 已到本章末尾 · 再按 {} 进入下一章",
display_first_key(&reader_keymap, ReaderAction::ScrollDown)
),
Edge::Prev => format!(
"● 已到本章开头 · 再按 {} 返回上一章",
display_first_key(&reader_keymap, ReaderAction::ScrollUp)
),
Edge::AtLast => "● 已是全书最后一章".to_string(),
Edge::AtFirst => "● 已是第一章".to_string(),
Edge::None => String::new(),
}).style(theme.chapter).centered())
widget(Line::from(format!("{:.2}% {}",props.chapter_percent, current_time.read().clone())).style(theme.progress).right_aligned())
}
})
}
pub fn highlight(
text: &str,
segment: &TextSegment,
width: usize,
highlight_style: Style,
) -> Vec<Line<'static>> {
let pattern: String = regex::escape(&segment.text);
let regex = regex::Regex::new(&pattern).unwrap();
let res = regex.find_at(text, segment.start);
if let Some(mat) = res {
let marked = format!(
"{}\u{001E}{}\u{002E}{}",
&text[..mat.start()],
mat.as_str(),
&text[mat.end()..]
);
let highlighted = textwrap::fill(&marked, width);
highlight_text(&highlighted, highlight_style)
} else {
let texts = textwrap::fill(text, width);
texts
.lines()
.map(|line| Line::from(line.to_string()))
.collect::<Vec<_>>()
}
}
fn highlight_text(text: &str, highlight_style: Style) -> Vec<Line<'static>> {
let mut lines = vec![];
let mut matched = 0;
for line in text.lines() {
let mut spans = vec![];
let mut highlight = line.to_string();
if let Some((start, rest)) = line.split_once('\u{001E}') {
matched += 1;
spans.push(Span::from(start.to_string()));
highlight = rest.to_string();
}
if matched > 0 {
if let Some((highlight, end)) = &highlight.split_once('\u{002E}') {
matched -= 1;
spans.push(Span::from(highlight.to_string()).style(highlight_style));
spans.push(Span::from(end.to_string()));
} else {
spans.push(Span::from(highlight).style(highlight_style));
}
} else {
spans.push(Span::from(line.to_string()));
}
lines.push(Line::from(spans));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visible_lines_never_zero() {
for height in 0..=4u16 {
assert_eq!(visible_lines(height), 1, "height={height}");
}
assert_eq!(visible_lines(33), 30);
}
#[test]
fn ratio_round_trip_never_collides_with_chapter_end() {
for total in [1usize, 2, 40, 121, 5000] {
for line in [0, total / 3, total.saturating_sub(1)] {
let ratio = line as f64 / total as f64;
assert!(ratio < 1.0, "total={total} line={line}");
assert_eq!(ScrollTarget::from_ratio(ratio), ScrollTarget::Ratio(ratio));
}
}
assert_eq!(ScrollTarget::from_ratio(1.0), ScrollTarget::ChapterEnd);
assert_eq!(ScrollTarget::ChapterEnd.as_ratio(), 1.0);
}
#[test]
fn ratio_resolve_clamps_to_end_scroll_across_viewports() {
let target = ScrollTarget::from_ratio(19.0 / 40.0);
assert_eq!(target.resolve(40, 0), 0);
assert_eq!(ScrollTarget::from_ratio(0.7).resolve(60, 30), 30);
assert_eq!(ScrollTarget::from_ratio(0.5).resolve(100, 70), 50);
}
#[test]
fn overscroll_survives_only_its_own_viewport() {
let target = ScrollTarget::Overscroll {
ratio: 84.0 / 100.0,
end_scroll: 70,
};
assert_eq!(target.resolve(100, 70), 84, "同视口应保留留白");
assert_eq!(target.resolve(100, 40), 40, "视口变了应退化为贴底");
assert_eq!(
ScrollTarget::from_ratio(target.as_ratio()).resolve(100, 70),
70
);
}
#[test]
fn resolve_is_safe_when_content_is_empty() {
assert_eq!(ScrollTarget::Ratio(0.5).resolve(0, 0), 0);
assert_eq!(ScrollTarget::ChapterEnd.resolve(0, 0), 0);
assert_eq!(
ScrollTarget::Overscroll {
ratio: 0.5,
end_scroll: 0
}
.resolve(0, 0),
0
);
}
}