Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d6e134a33 | |||
| a700eed63c | |||
| 80f3d50fc4 |
@@ -5,6 +5,32 @@ use super::*;
|
|||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub(super) fn editor(&mut self, ui: &mut egui::Ui) {
|
pub(super) fn editor(&mut self, ui: &mut egui::Ui) {
|
||||||
|
// The bundled scene template is a reference writers copy from by
|
||||||
|
// hand, not something the app reads — so an edit to it just sits
|
||||||
|
// there, invisible, until someone notices the scene they copied from
|
||||||
|
// it looks wrong. Lock it against typing until explicitly unlocked.
|
||||||
|
let is_template = self
|
||||||
|
.selected
|
||||||
|
.and_then(|i| self.files.get(i))
|
||||||
|
.is_some_and(|f| workspace::is_template_file(f));
|
||||||
|
let locked = is_template && !self.template_unlocked;
|
||||||
|
if is_template {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if locked {
|
||||||
|
ui.label("🔒 This is the scene template — locked to prevent accidental edits.");
|
||||||
|
if ui.button("Unlock").clicked() {
|
||||||
|
self.template_unlocked = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ui.label("🔓 Unlocked for editing.");
|
||||||
|
if ui.button("Lock").clicked() {
|
||||||
|
self.template_unlocked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
}
|
||||||
|
|
||||||
// Base monospace size scaled by the zoom percentage. `desired_width` stays
|
// Base monospace size scaled by the zoom percentage. `desired_width` stays
|
||||||
// infinite and the scroll area is vertical-only, so text wraps to the pane
|
// infinite and the scroll area is vertical-only, so text wraps to the pane
|
||||||
// width at any zoom (no horizontal scrolling).
|
// width at any zoom (no horizontal scrolling).
|
||||||
@@ -113,6 +139,7 @@ impl App {
|
|||||||
.desired_width(f32::INFINITY)
|
.desired_width(f32::INFINITY)
|
||||||
.desired_rows(rows)
|
.desired_rows(rows)
|
||||||
.layouter(&mut layouter)
|
.layouter(&mut layouter)
|
||||||
|
.interactive(!locked)
|
||||||
.show(ui);
|
.show(ui);
|
||||||
if output.response.changed() {
|
if output.response.changed() {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
|
|||||||
+42
-10
@@ -415,13 +415,32 @@ impl App {
|
|||||||
+ if show_tag { tag_w } else { 0.0 };
|
+ if show_tag { tag_w } else { 0.0 };
|
||||||
let name_w =
|
let name_w =
|
||||||
(row_width - indent - trailing).max(0.0);
|
(row_width - indent - trailing).max(0.0);
|
||||||
|
// `add_sized` centers a widget
|
||||||
|
// smaller than the box it's given
|
||||||
|
// — fine for a fixed-width badge,
|
||||||
|
// but it let a short folder name
|
||||||
|
// drift away from the indent that
|
||||||
|
// was just drawn for it, and
|
||||||
|
// further than its own deeper
|
||||||
|
// children whose names fill more
|
||||||
|
// of their (narrower) box. Pinning
|
||||||
|
// `main_align` to the start keeps
|
||||||
|
// the name anchored right after
|
||||||
|
// the indent regardless of length.
|
||||||
let button_response = ui
|
let button_response = ui
|
||||||
.add_sized(
|
.allocate_ui_with_layout(
|
||||||
[name_w, 20.0],
|
egui::vec2(name_w, 20.0),
|
||||||
egui::Button::new(text)
|
egui::Layout::left_to_right(egui::Align::Center)
|
||||||
.frame(false)
|
.with_main_align(egui::Align::Min),
|
||||||
.truncate(),
|
|ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::Button::new(text)
|
||||||
|
.frame(false)
|
||||||
|
.truncate(),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
.inner
|
||||||
.on_hover_text(&row.path);
|
.on_hover_text(&row.path);
|
||||||
|
|
||||||
if button_response.clicked() {
|
if button_response.clicked() {
|
||||||
@@ -563,16 +582,29 @@ impl App {
|
|||||||
// file name simply spills out of the
|
// file name simply spills out of the
|
||||||
// panel. The wrap mode is what it
|
// panel. The wrap mode is what it
|
||||||
// actually reads.
|
// actually reads.
|
||||||
|
//
|
||||||
|
// `add_sized` also *centers* a
|
||||||
|
// widget narrower than the box
|
||||||
|
// it's given, which drifted short
|
||||||
|
// file names away from the indent
|
||||||
|
// just like folder names did —
|
||||||
|
// `with_main_align(Min)` pins it
|
||||||
|
// to the start instead.
|
||||||
let mut label = ui
|
let mut label = ui
|
||||||
.scope(|ui| {
|
.scope(|ui| {
|
||||||
ui.style_mut().wrap_mode =
|
ui.style_mut().wrap_mode =
|
||||||
Some(egui::TextWrapMode::Truncate);
|
Some(egui::TextWrapMode::Truncate);
|
||||||
ui.add_sized(
|
ui.allocate_ui_with_layout(
|
||||||
[label_w, 20.0],
|
egui::vec2(label_w, 20.0),
|
||||||
egui::SelectableLabel::new(
|
egui::Layout::left_to_right(egui::Align::Center)
|
||||||
selected, text,
|
.with_main_align(egui::Align::Min),
|
||||||
),
|
|ui| {
|
||||||
|
ui.add(egui::SelectableLabel::new(
|
||||||
|
selected, text,
|
||||||
|
))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
.inner
|
||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
if let Some(tooltip) = &tooltip {
|
if let Some(tooltip) = &tooltip {
|
||||||
|
|||||||
@@ -209,6 +209,11 @@ pub struct App {
|
|||||||
/// The editorial header lifted out of `buffer` while it is collapsed.
|
/// The editorial header lifted out of `buffer` while it is collapsed.
|
||||||
header_stash: Option<String>,
|
header_stash: Option<String>,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
|
/// Whether the currently open `_template.md` has been unlocked for
|
||||||
|
/// editing this session. Not persisted and not file-specific — it resets
|
||||||
|
/// to locked whenever a different file is selected, so re-opening the
|
||||||
|
/// template always starts protected again.
|
||||||
|
template_unlocked: bool,
|
||||||
/// Byte offset of the primary cursor in `buffer`, as of the last frame.
|
/// Byte offset of the primary cursor in `buffer`, as of the last frame.
|
||||||
/// Used by typewriter mode to notice the cursor moved (and re-center it)
|
/// Used by typewriter mode to notice the cursor moved (and re-center it)
|
||||||
/// versus the writer scrolling the view manually, and to find the current
|
/// versus the writer scrolling the view manually, and to find the current
|
||||||
@@ -509,6 +514,7 @@ impl App {
|
|||||||
buffer: String::new(),
|
buffer: String::new(),
|
||||||
header_stash: None,
|
header_stash: None,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
|
template_unlocked: false,
|
||||||
cursor_pos: 0,
|
cursor_pos: 0,
|
||||||
new_name: String::new(),
|
new_name: String::new(),
|
||||||
rename_input: String::new(),
|
rename_input: String::new(),
|
||||||
|
|||||||
@@ -541,6 +541,11 @@ impl App {
|
|||||||
self.spell_menu = None;
|
self.spell_menu = None;
|
||||||
self.clear_lt();
|
self.clear_lt();
|
||||||
self.autocomplete = None;
|
self.autocomplete = None;
|
||||||
|
// Story Bible reference ranges are char offsets into `buffer` too —
|
||||||
|
// stale ones (from before the header moved in or out) land on
|
||||||
|
// whatever text now happens to sit `header.len()` characters away
|
||||||
|
// from the real mention.
|
||||||
|
self.update_current_references();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many header fields are hidden right now, for the toggle's label.
|
/// How many header fields are hidden right now, for the toggle's label.
|
||||||
@@ -610,6 +615,8 @@ impl App {
|
|||||||
self.selected = Some(idx);
|
self.selected = Some(idx);
|
||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
self.pending_delete = false;
|
self.pending_delete = false;
|
||||||
|
// A lock earned on one file doesn't carry over to the next.
|
||||||
|
self.template_unlocked = false;
|
||||||
// Update Story Bible references for the newly selected file
|
// Update Story Bible references for the newly selected file
|
||||||
self.update_current_references();
|
self.update_current_references();
|
||||||
// The rename box holds the whole workspace-relative path, so it doubles
|
// The rename box holds the whole workspace-relative path, so it doubles
|
||||||
@@ -1909,6 +1916,16 @@ pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &s
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `path` is the bundled scene template (`_template.md`): a reference
|
||||||
|
/// file writers open to see the expected header fields and copy from by hand,
|
||||||
|
/// not something the app reads from — "New from template" instead pulls from
|
||||||
|
/// [`Config::new_file_template`](crate::config::Config::new_file_template).
|
||||||
|
/// Easy to edit by mistake while skimming it, so the editor locks it against
|
||||||
|
/// changes until explicitly unlocked — see [`App::editor`](super::App::editor).
|
||||||
|
pub(super) fn is_template_file(path: &str) -> bool {
|
||||||
|
base_name(path).eq_ignore_ascii_case("_template.md")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2092,6 +2109,21 @@ mod tests {
|
|||||||
assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n");
|
assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_template_file_matches_only_the_scene_template_by_name() {
|
||||||
|
assert!(is_template_file("_template.md"));
|
||||||
|
assert!(is_template_file("05-First Draft/_template.md"));
|
||||||
|
// Case-insensitive, like the file system it's read from.
|
||||||
|
assert!(is_template_file("05-First Draft/_TEMPLATE.MD"));
|
||||||
|
// A chapter folder could have its own copy — still matches by name.
|
||||||
|
assert!(is_template_file("Act 1/Chapter 2/_template.md"));
|
||||||
|
// Other underscore-prefixed files (chapter info, notes) aren't it.
|
||||||
|
assert!(!is_template_file("_chapter.md"));
|
||||||
|
assert!(!is_template_file("_notes.md"));
|
||||||
|
assert!(!is_template_file("scene01.md"));
|
||||||
|
assert!(!is_template_file("template.md"));
|
||||||
|
}
|
||||||
|
|
||||||
/// The point of the default template is that the app's own header parser
|
/// The point of the default template is that the app's own header parser
|
||||||
/// understands what it produces — otherwise the seeded fields are just text.
|
/// understands what it produces — otherwise the seeded fields are just text.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user