use super::search::{ChapterSearch, ContentLayout};
use crate::components::{ActionBar, ActionButton};
use crate::{
components::Loading,
keymap::{ReaderAction, display_first_key},
theme::ReaderTheme,
};
use ratatui::{
layout::{Constraint, Direction, Flex, Margin},
text::Line,
widgets::Paragraph,
};
#[cfg(test)]
use ratatui::{style::Style, text::Span};
use ratatui_kit::prelude::*;
use ratatui_kit_keymap::UseKeymapHandler;
use std::sync::Arc;
use std::time::Duration;
const WHEEL_LINES: usize = 3;
#[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 book_id: String,
pub chapter_index: usize,
pub search: Option<State<ChapterSearch>>,
pub is_scroll: bool,
pub show_actions: 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 follow_suspended: Option<State<bool>>,
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 size = hooks.use_previous_size();
let width = if size.width > 0 {
size.width
} else {
props.width
};
let height = if size.height > 0 {
size.height
} else {
props.height.saturating_sub(1)
};
let mut reader_display = hooks.use_atom(&crate::state::READER_DISPLAY);
let mut on_prev = props.on_prev.take();
let mut on_next = props.on_next.take();
let mut edge = hooks.use_state(|| Edge::None);
#[cfg(feature = "tts")]
let listening = hooks.use_context::<crate::tts::TtsContext>().clone();
#[cfg(feature = "tts")]
let request = hooks.use_memo(
|| {
Arc::new(crate::tts::ChapterRequest {
source: tts_protocol::SourceId {
namespace: "reader".into(),
book: props.book_id.clone(),
chapter: props.chapter_index.to_string(),
},
text_hash: tts_protocol::text_hash(&props.content),
text: props.content.clone(),
resume_byte: None,
restore_checkpoint: true,
})
},
(
props.content.clone(),
props.book_id.clone(),
props.chapter_index,
),
);
#[cfg(feature = "tts")]
let snapshot = listening.snapshot.read().clone();
#[cfg(feature = "tts")]
let mut seen_terminal = hooks.use_state(|| snapshot.terminal_revision);
#[cfg(feature = "tts")]
let mut continue_source = hooks.use_state(|| None::<tts_protocol::SourceId>);
#[cfg(feature = "tts")]
if seen_terminal.get() != snapshot.terminal_revision {
seen_terminal.set(snapshot.terminal_revision);
if snapshot.should_continue(&request, props.has_next) {
let mut next = request.source.clone();
next.chapter = (props.chapter_index + 1).to_string();
continue_source.set(Some(next));
on_next(());
}
}
#[cfg(feature = "tts")]
hooks.use_effect(
{
let handle = listening.handle.clone();
let request = request.clone();
let loading = props.is_loading;
move || {
if loading {
handle.stop();
} else {
let play = continue_source.read().as_ref() == Some(&request.source);
continue_source.set(None);
handle.chapter((*request).clone(), play);
}
}
},
(
props.content.clone(),
props.book_id.clone(),
props.chapter_index,
props.is_loading,
),
);
#[cfg(feature = "tts")]
hooks.use_on_drop({
let handle = listening.handle.clone();
move || handle.stop()
});
#[cfg(feature = "tts")]
let is_listening = snapshot.matches(&request)
&& matches!(
snapshot.state,
tts_protocol::SessionState::Playing
| tts_protocol::SessionState::Buffering
| tts_protocol::SessionState::Generating
| tts_protocol::SessionState::Paused
);
#[cfg(not(feature = "tts"))]
let is_listening = false;
let paragraph_spacing = reader_display.read().paragraph_spacing;
let local_search = hooks.use_state(ChapterSearch::default);
let mut search = props.search.unwrap_or(local_search);
hooks.use_effect(
|| {
let query = search.read().query.clone();
if !query.is_empty() {
search.write().submit(&props.content, query);
}
},
props.content.clone(),
);
let editing = search.read().editing && props.is_scroll && !props.is_loading;
let layout = hooks.use_memo(
|| {
Arc::new(ContentLayout::new(
&props.content,
width.saturating_sub(2) as usize,
paragraph_spacing,
))
},
(props.content.clone(), width, paragraph_spacing),
);
let matches = search.read().matches.clone();
let selected = search.read().selected;
#[cfg(feature = "tts")]
let tts_range = snapshot
.range
.filter(|range| snapshot.matches(&request) && range.is_valid(&props.content))
.map(|range| range.start..range.end);
#[cfg(not(feature = "tts"))]
let tts_range: Option<std::ops::Range<usize>> = None;
let paragraph = hooks.use_memo(
|| {
TextParagraph::from(Paragraph::new(
layout.styled(
&matches,
selected,
theme.search_highlight,
tts_range
.as_ref()
.filter(|_| is_listening)
.map(|range| (range, theme.tts_highlight)),
),
))
},
(
props.content.clone(),
width,
paragraph_spacing,
search.read().revision,
selected,
tts_range.clone(),
is_listening,
theme.search_highlight,
theme.tts_highlight,
),
);
let scroll_target = hooks.use_state(ScrollTarget::default);
let mut scroll_target = props.scroll_target.unwrap_or(scroll_target);
#[cfg(feature = "tts")]
let mut follow_suspended = {
let local = hooks.use_state(|| false);
props.follow_suspended.unwrap_or(local)
};
let is_scroll = props.is_scroll;
let total = paragraph.line_count(width.saturating_sub(2));
let can_search = props.is_scroll && !props.is_loading;
let view = visible_lines(height);
let end_scroll = total.saturating_sub(view);
hooks.use_effect(
|| {
edge.set(Edge::None);
if !editing && let Some(target) = matches.get(selected) {
let line = layout.match_line(target).min(end_scroll);
scroll_target.set(ScrollTarget::Ratio(line as f64 / total.max(1) as f64));
}
},
(search.read().revision, width, paragraph_spacing, height),
);
let mut current_line = hooks.use_memo(
|| scroll_target.get().resolve(total, end_scroll),
(total, view, scroll_target.get()),
);
#[cfg(feature = "tts")]
{
let enabled = reader_display.read().follow_tts;
let deps = (
(
enabled,
follow_suspended.get(),
is_scroll,
editing,
props.is_loading,
),
(
snapshot.state,
tts_range.clone(),
width,
height,
paragraph_spacing,
),
(total, view, scroll_target.get()),
);
hooks.use_effect(
|| {
if enabled
&& !follow_suspended.get()
&& is_scroll
&& !editing
&& !props.is_loading
&& snapshot.state == tts_protocol::SessionState::Playing
&& let Some(range) = &tts_range
&& let Some(line) = super::follow::target(
layout.match_line(range),
current_line,
view,
end_scroll,
false,
)
{
scroll_target.set(ScrollTarget::Ratio(line as f64 / total.max(1) as f64));
edge.set(Edge::None);
}
},
deps,
);
}
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(
ScrollbarOptions {
content_length: total,
viewport_length: view,
position: current_line.min(end_scroll),
scrollbar: crate::theme::scrollbar_style(theme.border, theme.chapter),
..Default::default()
},
{
let mut scroll_target = scroll_target;
let mut edge = edge;
#[cfg(feature = "tts")]
let mut follow_suspended = follow_suspended;
move |line| {
scroll_target.set(ScrollTarget::Ratio(line as f64 / total.max(1) as f64));
edge.set(Edge::None);
#[cfg(feature = "tts")]
follow_suspended.set(true);
}
},
);
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();
#[cfg(feature = "tts")]
let playback_request = request.clone();
#[cfg(feature = "tts")]
let playback_line = tts_range.as_ref().map(|range| layout.match_line(range));
let action_handler = Arc::new(std::sync::Mutex::new(move |action| {
if !is_scroll {
return EventResult::Ignored;
}
#[cfg(feature = "tts")]
if matches!(
action,
ReaderAction::ScrollUp
| ReaderAction::ScrollDown
| ReaderAction::PageUp
| ReaderAction::PageDown
| ReaderAction::GoTop
| ReaderAction::GoBottom
| ReaderAction::PrevChapter
| ReaderAction::NextChapter
| ReaderAction::SearchContent
| ReaderAction::NextSearchMatch
| ReaderAction::PrevSearchMatch
) {
follow_suspended.set(true);
}
let delta = match action {
ReaderAction::PageUp | ReaderAction::PageDown => step,
_ => 1,
};
match action {
ReaderAction::SearchContent if can_search => {
let query = search.read().query.clone();
let mut state = search.write();
state.draft = tui_input::Input::new(query);
state.editing = true;
EventResult::Consumed
}
ReaderAction::NextSearchMatch | ReaderAction::PrevSearchMatch if can_search => {
search
.write()
.advance(action == ReaderAction::PrevSearchMatch);
EventResult::Consumed
}
ReaderAction::ClearContentSearch if can_search => {
search.set(ChapterSearch::default());
EventResult::Consumed
}
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
}
#[cfg(feature = "tts")]
ReaderAction::VolumeUp | ReaderAction::VolumeDown => {
if let Some(config) = listening.snapshot.read().config.as_ref() {
let delta = if action == ReaderAction::VolumeUp {
0.1
} else {
-0.1
};
listening.handle.update(tts_protocol::ConfigPatch {
volume: Some((config.volume + delta).clamp(0.0, 10.0)),
..Default::default()
});
}
EventResult::Consumed
}
#[cfg(feature = "tts")]
ReaderAction::FollowPlayback if can_search => {
reader_display.write().follow_tts = true;
follow_suspended.set(false);
if let Some(line) = playback_line.and_then(|line| {
super::follow::target(line, current_line, view, end_scroll, true)
}) {
scroll_target.set(ScrollTarget::Ratio(line as f64 / total.max(1) as f64));
}
edge.set(Edge::None);
EventResult::Consumed
}
#[cfg(feature = "tts")]
ReaderAction::TogglePlay if can_search => {
listening.handle.toggle((*playback_request).clone());
EventResult::Consumed
}
ReaderAction::ToggleTitle => {
let mut display = *reader_display.read();
display.show_title = !display.show_title;
reader_display.set(display);
EventResult::Consumed
}
_ => EventResult::Ignored,
}
}));
hooks.use_keymap_handler(
EventScope::Current,
EventPriority::Normal,
reader_keymap.clone(),
{
let handler = action_handler.clone();
move |action, _key| handler.lock().unwrap_or_else(|e| e.into_inner())(action)
},
);
hooks.use_pointer(PointerOptions::default(), {
let mut scroll_target = scroll_target;
let mut edge = edge;
let is_loading = props.is_loading;
#[cfg(feature = "tts")]
let mut follow_suspended = follow_suspended;
move |ev| match ev.kind {
PointerKind::ScrollUp | PointerKind::ScrollDown if is_scroll && !is_loading => {
let line = scroll_target.get().resolve(total, end_scroll);
let next = if matches!(ev.kind, PointerKind::ScrollUp) {
line.saturating_sub(WHEEL_LINES)
} else {
(line + WHEEL_LINES).min(end_scroll)
};
if next != line {
scroll_target.set(ScrollTarget::Ratio(next as f64 / total.max(1) as f64));
}
edge.set(Edge::None);
#[cfg(feature = "tts")]
follow_suspended.set(true);
EventResult::Consumed
}
_ => EventResult::Ignored,
}
});
let footer_status = 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 => search.read().status(),
};
let show_title = reader_display.read().show_title;
#[cfg(feature = "tts")]
let listening_footer = if let Some(error) = &snapshot.error {
format!("听书: {error}")
} else if is_listening
|| snapshot.matches(&request) && snapshot.state == tts_protocol::SessionState::Paused
{
let config = snapshot.config.as_ref();
format!(
"{}: 速度{:.1} / 音量{:.1} · {} · {} · {}",
if snapshot.state == tts_protocol::SessionState::Buffering {
"缓冲中".into()
} else {
format!("{:?}", snapshot.state)
},
config.map_or(1.0, |value| value.speed),
config.map_or(1.0, |value| value.volume),
"片段高亮",
snapshot.buffer,
if reader_display.read().follow_tts && !follow_suspended.get() {
"跟随中".into()
} else {
format!(
"自由浏览 · {} 回到朗读位置",
display_first_key(&reader_keymap, ReaderAction::FollowPlayback)
)
}
)
} else {
String::new()
};
#[cfg(not(feature = "tts"))]
let listening_footer = String::new();
let action_button = |label: &str, action: ReaderAction, disabled: bool| {
element!(ActionButton(
key: format!("{action:?}"),
label: label.to_owned(),
shortcut: crate::keymap::display_keys(&reader_keymap, action),
disabled: disabled,
on_press: crate::components::action_bar::action_callback(&action_handler, action),
))
.into_any()
};
element!(View {
if props.show_actions {
crate::components::search_input::SearchInput(
value: search.read().query.clone(), placeholder: "搜索本章正文", label: "搜索",
is_editing: can_search, open: editing,
shortcut: crate::keymap::display_keys(&reader_keymap, ReaderAction::SearchContent),
activate_key: Some(ratatui_kit::crossterm::event::KeyCode::Null),
on_submit: { let text = props.content.clone(); move |query| { search.write().submit(&text, query); true } },
on_close: move |_| search.write().editing = false,
)
ActionBar(menu: "阅读") {
{action_button("‹ 上一章", ReaderAction::PrevChapter, !has_prev || !can_search)}
{action_button("下一章 ›", ReaderAction::NextChapter, !has_next || !can_search)}
{action_button("搜索正文", ReaderAction::SearchContent, !can_search)}
if !search.read().query.is_empty() {
{action_button("上一个匹配", ReaderAction::PrevSearchMatch, !can_search)}
{action_button("下一个匹配", ReaderAction::NextSearchMatch, !can_search)}
{action_button("清除搜索", ReaderAction::ClearContentSearch, !can_search)}
}
}
ActionBar(menu: "阅读") {
{action_button("‹ 上一页", ReaderAction::PageUp, !can_search)}
{action_button("下一页 ›", ReaderAction::PageDown, !can_search)}
if cfg!(feature = "tts") {
ActionBar(menu: "听书") {
{action_button("播放/暂停", ReaderAction::TogglePlay, !can_search)}
{action_button("跟随朗读", ReaderAction::FollowPlayback, !can_search)}
}
}
}
}
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 && !listening_footer.is_empty() {
Some(Line::from(listening_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),
){
View(width: Constraint::Length(12)) {
widget(Line::from(format!("{}/{} 行", (current_line + view).min(total), total)).style(theme.footer))
}
View(width: Constraint::Fill(1)) {
widget(Line::from(footer_status).style(theme.chapter).centered())
}
View(width: Constraint::Length(13)) {
widget(Line::from(format!("{:.2}% {}",props.chapter_percent, current_time.read().clone())).style(theme.progress).right_aligned())
}
}
}
})
}
#[cfg(test)]
const HIGHLIGHT_START: char = '\u{001E}';
#[cfg(test)]
const HIGHLIGHT_END: char = '\u{001F}';
#[cfg(test)]
pub fn highlight(
text: &str,
range: &std::ops::Range<usize>,
width: usize,
highlight_style: Style,
spacing: bool,
) -> Vec<Line<'static>> {
let valid = text.get(range.clone()).map(|_| range);
ContentLayout::new(text, width, spacing).styled(
&[],
0,
Style::default(),
valid.map(|range| (range, highlight_style)),
)
}
#[cfg(test)]
pub(super) 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
}
#[cfg(test)]
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())));
}
}
#[cfg(test)]
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) -> std::ops::Range<usize> {
start..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_uses_original_range_after_indentation() {
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);
}
#[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
);
}
}