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_spacing = reader_display.read().paragraph_spacing;
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,
paragraph_spacing,
))
} else {
Paragraph::new(wrap_content(
&props.content,
(props.width as usize).saturating_sub(2),
paragraph_spacing,
))
};
TextParagraph::from(paragraph)
},
(
is_listening.get(),
highlight_range.read().clone(),
props.content.clone(),
props.width,
theme.tts_highlight,
paragraph_spacing,
),
);
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())
}
})
}
const HIGHLIGHT_START: char = '\u{001E}';
const HIGHLIGHT_END: char = '\u{001F}';
pub fn highlight(
text: &str,
segment: &TextSegment,
width: usize,
highlight_style: Style,
spacing: bool,
) -> Vec<Line<'static>> {
let found = text.get(segment.start..).and_then(|rest| {
rest.find(segment.text.as_str())
.map(|offset| segment.start + offset)
});
let Some(start) = found else {
return wrap_content(text, width, spacing);
};
let end = start + segment.text.len();
let mut lines = Vec::new();
let mut offset = 0;
for raw_line in text.split_inclusive('\n') {
let line = raw_line.trim_end_matches(['\n', '\r']);
let line_end = offset + line.len();
let marked = if start >= offset && end <= line_end {
format!(
"{}{HIGHLIGHT_START}{}{HIGHLIGHT_END}{}",
&line[..start - offset],
&line[start - offset..end - offset],
&line[end - offset..]
)
} else {
line.to_string()
};
append_wrapped_line(
&mut lines,
&marked,
width,
marked.contains(HIGHLIGHT_START).then_some(highlight_style),
spacing,
);
offset += raw_line.len();
}
lines
}
fn wrap_content(text: &str, width: usize, spacing: bool) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for line in text.lines() {
append_wrapped_line(&mut lines, line, width, None, spacing);
}
lines
}
fn append_wrapped_line(
lines: &mut Vec<Line<'static>>,
line: &str,
width: usize,
highlight_style: Option<Style>,
spacing: bool,
) {
let line = line.trim_end_matches('\r');
let is_blank = line.trim().is_empty();
if (is_blank || spacing)
&& !lines.is_empty()
&& !lines.last().is_some_and(|line| line.spans.is_empty())
{
lines.push(Line::default());
}
if is_blank {
return;
}
let wrapped = textwrap::fill(line, width);
if let Some(style) = highlight_style {
lines.extend(highlight_text(&wrapped, style));
} else {
lines.extend(wrapped.lines().map(|line| Line::from(line.to_string())));
}
}
fn highlight_text(text: &str, highlight_style: Style) -> Vec<Line<'static>> {
let mut lines = vec![];
let mut in_highlight = false;
for line in text.lines() {
let mut spans = vec![];
let mut rest = line;
if let Some((before, after)) = line.split_once(HIGHLIGHT_START) {
spans.push(Span::from(before.to_string()));
rest = after;
in_highlight = true;
}
if in_highlight {
match rest.split_once(HIGHLIGHT_END) {
Some((highlighted, after)) => {
in_highlight = false;
spans.push(Span::from(highlighted.to_string()).style(highlight_style));
spans.push(Span::from(after.to_string()));
}
None => spans.push(Span::from(rest.to_string()).style(highlight_style)),
}
} else {
spans.push(Span::from(rest.to_string()));
}
lines.push(Line::from(spans));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
fn highlighted_of(line: &Line<'static>, style: Style) -> String {
line.spans
.iter()
.filter(|span| span.style == style)
.map(|span| span.content.as_ref())
.collect()
}
fn segment(text: &str, start: usize) -> TextSegment {
TextSegment {
text: text.to_string(),
start,
end: start + text.len(),
}
}
#[test]
fn highlight_survives_ascii_dots_in_the_segment() {
let style = Style::default().bold();
let text = "圆周率约等于 3.14,记作 Mr. Pi。";
let lines = highlight(text, &segment(text, 0), 200, style, false);
assert_eq!(lines.len(), 1);
assert_eq!(highlighted_of(&lines[0], style), text);
}
#[test]
fn highlight_covers_only_the_segment() {
let style = Style::default().bold();
let text = "前半句。后半句。";
let target = "后半句。";
let start = text.find(target).unwrap();
let lines = highlight(text, &segment(target, start), 200, style, false);
assert_eq!(highlighted_of(&lines[0], style), target);
assert_eq!(texts(&lines), [text]);
}
#[test]
fn highlight_tolerates_trimmed_segment_offset() {
let style = Style::default().bold();
let text = "开头。 缩进的一句。";
let target = "缩进的一句。";
let start = text.find(" ").unwrap();
let lines = highlight(text, &segment(target, start), 200, style, false);
assert_eq!(highlighted_of(&lines[0], style), target);
}
#[test]
fn highlight_with_stale_offset_falls_back_instead_of_panicking() {
let style = Style::default().bold();
let text = "短短一句。";
let out_of_range = highlight(text, &segment("不存在的内容", 9999), 200, style, false);
assert_eq!(texts(&out_of_range), [text]);
let mid_char = highlight(text, &segment("一句", 1), 200, style, false);
assert_eq!(texts(&mid_char), [text]);
}
fn texts(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn paragraph_spacing_toggles_blank_lines_between_paragraphs() {
let text = "第一段。\n第二段。\n第三段。";
assert_eq!(
texts(&wrap_content(text, 40, false)),
["第一段。", "第二段。", "第三段。"]
);
assert_eq!(
texts(&wrap_content(text, 40, true)),
["第一段。", "", "第二段。", "", "第三段。"]
);
}
#[test]
fn blank_lines_in_source_survive_both_modes_without_doubling() {
let text = "上一场景。\n\n下一场景。";
assert_eq!(
texts(&wrap_content(text, 40, false)),
["上一场景。", "", "下一场景。"]
);
assert_eq!(
texts(&wrap_content(text, 40, true)),
["上一场景。", "", "下一场景。"]
);
}
#[test]
fn no_leading_blank_line() {
for spacing in [false, true] {
let lines = wrap_content("开篇。\n次段。", 40, spacing);
assert!(
!lines[0].spans.is_empty(),
"spacing={spacing} 首行不该是空行"
);
}
}
#[test]
fn consecutive_blank_lines_collapse() {
assert_eq!(
texts(&wrap_content("甲。\n\n\n\n乙。", 40, true)),
["甲。", "", "乙。"]
);
}
#[test]
fn empty_content_yields_no_lines() {
assert!(wrap_content("", 40, true).is_empty());
assert!(wrap_content("", 40, false).is_empty());
}
#[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
);
}
}