1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
//! The window's one view: a document's flow, and its headings beside it.
//
// Author: David M. Anderson
// Built with AI assistance (Claude, Anthropic)
use std::path::Path;
use eframe::egui::{self, Ui};
use odox_core::doc::TextDocument;
use odox_core::{Document, edit};
use odox_ui::i18n::{fill, t};
use odox_ui::{Editing, Flow, ParagraphEditor, ParagraphOutcome, Pictures, View, fonts};
/// A text document, open or not.
#[derive(Default)]
pub struct TextView {
document: Option<TextDocument>,
pictures: Pictures,
/// The heading the outline asked to be shown, answered on the next frame
/// while the body is laid out.
scroll_to: Option<usize>,
/// The paragraph being typed into, while one is.
editor: Option<ParagraphEditor>,
}
impl View for TextView {
fn open(&mut self, ctx: &egui::Context, bytes: &[u8], path: &Path) -> Result<(), String> {
let document = TextDocument::read(bytes).map_err(|e| {
fill(
t("{file} could not be opened: {reason}"),
&[
(
"file",
&path.file_name().unwrap_or_default().to_string_lossy(),
),
("reason", &e.to_string()),
],
)
})?;
self.pictures.clear();
self.document = Some(document);
self.scroll_to = None;
self.editor = None;
self.load_fonts(ctx);
Ok(())
}
fn close(&mut self) {
self.document = None;
self.pictures.clear();
self.editor = None;
}
fn is_open(&self) -> bool {
self.document.is_some()
}
fn title(&self) -> Option<String> {
self.document.as_ref()?.document.meta.title.clone()
}
fn document_mut(&mut self) -> Option<&mut Document> {
Some(&mut self.document.as_mut()?.document)
}
fn reindex(&mut self) {
self.editor = None;
}
fn central(&mut self, ui: &mut Ui, zoom: f32, editing: &mut Editing) {
let edit_mode = editing.on && !editing.asking;
let mut clicked = None;
let mut outcome = None;
let Some(document) = &self.document else {
return;
};
let Some(body) = document.body() else {
ui.centered_and_justified(|ui| {
ui.weak(t("This document has no text in it."));
});
return;
};
// The page's text width, which is what the document's own lines were
// written to. Narrower than the window, the flow is drawn on a page the
// width the document asked for; wider, the page shrinks to fit rather
// than being cut off.
let page = document.text_width() * zoom;
let margin = 24.0 * zoom;
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.vertical_centered(|ui| {
let width = page.min(ui.available_width() - margin * 2.0).max(80.0);
egui::Frame::new()
.fill(odox_ui::format::Palette::default().paper)
.inner_margin(margin)
.show(ui, |ui| {
ui.set_width(width);
let mut flow = Flow::new(&document.document, &mut self.pictures, zoom);
flow.scroll_to_heading = self.scroll_to.take();
flow.edit_mode = edit_mode;
flow.editor = self.editor.as_mut();
flow.blocks(ui, body, width);
clicked = flow.clicked.take();
outcome = flow.outcome.take();
});
});
});
if let Some(outcome) = outcome {
self.finish_editing(outcome, editing);
} else if let Some(path) = clicked
&& self.editor.is_none()
{
self.begin_editing(path);
}
}
fn side(&mut self, ui: &mut Ui) -> bool {
let Some(document) = &self.document else {
return false;
};
let outline = document.outline();
if outline.is_empty() {
return false;
}
ui.heading(t("Outline"));
ui.separator();
for (index, heading) in outline.iter().enumerate() {
// A heading's level is its indent, which is how an outline reads as
// the shape of the document rather than as a list of its headings.
let indent = f32::from(heading.level.saturating_sub(1)) * 10.0;
ui.horizontal(|ui| {
ui.add_space(indent);
let label = if heading.text.trim().is_empty() {
t("(untitled)").to_owned()
} else {
heading.text.clone()
};
if ui
.add(
egui::Label::new(label)
.truncate()
.sense(egui::Sense::click()),
)
.clicked()
{
self.scroll_to = Some(index);
}
});
}
true
}
fn view_menu(&mut self, ui: &mut Ui) {
let Some(document) = &self.document else {
return;
};
ui.separator();
if ui.button(t("Copy the document as text")).clicked() {
if let Some(body) = document.body() {
ui.ctx().copy_text(body.plain_text());
}
ui.close();
}
}
}
impl TextView {
/// Open the text box on the paragraph at a path from the body.
fn begin_editing(&mut self, path: Vec<usize>) {
let Some(paragraph) = self
.document
.as_ref()
.and_then(TextDocument::body)
.and_then(|body| body.at(&path))
else {
return;
};
self.editor = Some(ParagraphEditor::open(path, edit::text(paragraph), None));
}
/// Close the text box, writing what was typed where it was kept.
///
/// A join first keeps what was typed, then joins the paragraph onto the
/// one before it and opens the box again there, with the caret at the
/// join, so that Backspace at the start of a paragraph reads as it does
/// in any editor.
fn finish_editing(&mut self, outcome: ParagraphOutcome, editing: &mut Editing) {
let Some(editor) = self.editor.take() else {
return;
};
if outcome == ParagraphOutcome::Cancel {
return;
}
let Some(document) = &mut self.document else {
return;
};
let mut recorded = false;
if editor.changed() {
editing.record(&document.document.content);
recorded = true;
let Some(body) = document.body_mut() else {
return;
};
// What was typed may have become several paragraphs; the one a
// join wants is the first of them, at the same path.
let _ = edit::apply(body, &editor.path, &editor.text);
}
if outcome == ParagraphOutcome::JoinPrevious {
if !recorded {
editing.record(&document.document.content);
}
let Some(body) = document.body_mut() else {
return;
};
let previous_len = body
.at(&editor.path[..editor.path.len() - 1])
.and_then(|parent| {
parent.children[..*editor.path.last()?]
.iter()
.rev()
.find_map(|node| match node {
odox_core::Node::Element(e)
if e.is(&odox_core::Ns::Text, "p")
|| e.is(&odox_core::Ns::Text, "h") =>
{
Some(edit::text(e).chars().count())
}
_ => None,
})
});
if let Ok(joined) = edit::join_with_previous(body, &editor.path)
&& let Some(paragraph) = body.at(&joined)
{
self.editor = Some(ParagraphEditor::open(
joined,
edit::text(paragraph),
previous_len,
));
}
}
}
/// Load the faces the document's styles name, once, when it opens.
///
/// egui rebuilds its glyph atlas when the font definitions change, so this
/// happens on opening a document and never while one is being drawn.
fn load_fonts(&self, ctx: &egui::Context) {
let Some(document) = &self.document else {
return;
};
let mut parts = vec![&document.document.content];
if let Some(styles) = &document.document.styles_part {
parts.push(styles);
}
ctx.set_fonts(fonts::definitions(&fonts::families_used(&parts)));
}
}