Files
ElyOffice/apps/ely-mark/src/look.rs
T
ZacharyZhang-NYandClaude Opus 5.5 d07f0ddb7c ely-mark: rustfmt the files R3.1 left unformatted
Formatting alone; cargo fmt --check failed on these six.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:12:48 -04:00

331 lines
11 KiB
Rust

//! The writing view, kept across launches (MARK-09, MARK-12).
use std::{io, path::Path};
use ely_gpui_component::{documents::DocumentLook, primitives::Severity, theme::ActiveTheme as _};
use ely_office::prelude::*;
use gpui::{App, Context, actions, rems};
use serde::{Deserialize, Serialize};
use crate::desk::Desk;
actions!(
mark,
[
FocusMode, Typewriter, Narrow, Medium, Wide, Sans, Serif, Mono, Larger, Smaller, ActualSize
]
);
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Width {
Narrow,
Medium,
Wide,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Font {
Sans,
Serif,
Mono,
}
/// How text shows; the Markdown never changes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Look {
pub focus: bool,
pub typewriter: bool,
pub width: Width,
pub font: Font,
/// Percent of the app's text size.
pub size: u32,
}
impl Default for Look {
fn default() -> Self {
Look {
focus: false,
typewriter: false,
width: Width::Medium,
font: Font::Sans,
size: 100,
}
}
}
/// A serif face each system carries.
const SERIF: &str = if cfg!(target_os = "linux") {
"DejaVu Serif"
} else {
"Georgia"
};
impl Look {
/// How the document shows text by this look.
pub fn document(&self, cx: &App) -> DocumentLook {
let measure = match self.width {
Width::Narrow => 36.0,
Width::Medium => 48.0,
Width::Wide => 64.0,
};
let font = match self.font {
Font::Sans => None,
Font::Serif => Some(SERIF.into()),
Font::Mono => Some(cx.theme().mono_family.clone()),
};
DocumentLook {
focus: self.focus,
typewriter: self.typewriter,
measure: Some(rems(measure)),
font,
scale: self.size as f32 / 100.0,
}
}
}
/// The kept look; none kept is the default.
pub fn load(file: &Path) -> io::Result<Look> {
match std::fs::read(file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Look::default()),
Err(error) => Err(error),
}
}
/// Sizes step by a tenth, from 70 to 200 percent.
const SIZES: (u32, u32, u32) = (70, 200, 10);
impl Desk {
/// Changes the look; its file and menus follow.
pub fn relook(&mut self, change: impl FnOnce(&mut Look), cx: &mut Context<Self>) {
change(&mut self.look);
self.look.size = self.look.size.clamp(SIZES.0, SIZES.1);
log::info!("mark: the look is now {:?}", self.look);
let json = serde_json::to_vec_pretty(&self.look).expect("a look serializes");
if let Err(error) = ely_files::save_atomic(&self.look_file, &json, 0) {
log::error!("mark: the look did not save: {error}");
let said =
ely_ui::i18n::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
self.apply_look(cx);
let menus = crate::keys::menus(&self.look, cx);
ely_ui::set_document_menus(
ely_platform::AppId::Mark,
&ely_ui::commands::RENAMING,
menus,
cx,
);
cx.notify();
}
/// Every tab's document takes the look.
fn apply_look(&mut self, cx: &mut Context<Self>) {
let look = self.look.document(cx);
let documents: Vec<_> = self
.core
.tabs
.iter()
.map(|tab| tab.view.read(cx).document().clone())
.collect();
for document in documents {
document.update(cx, |document, cx| document.set_look(look.clone(), cx));
}
}
/// A new tab's document takes the look once.
pub fn look_once(&mut self, id: u64, cx: &mut Context<Self>) {
let look = self.look.document(cx);
let Some(tab) = self.tab_mut(id).filter(|tab| !tab.extra.looked) else {
return;
};
tab.extra.looked = true;
let document = tab.view.read(cx).document().clone();
document.update(cx, |document, cx| document.set_look(look, cx));
}
pub fn look_actions(element: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
use gpui::InteractiveElement as _;
let step = SIZES.2;
element
.on_action(cx.listener(|desk, _: &FocusMode, _, cx| {
desk.relook(|look| look.focus = !look.focus, cx)
}))
.on_action(cx.listener(|desk, _: &Typewriter, _, cx| {
desk.relook(|look| look.typewriter = !look.typewriter, cx)
}))
.on_action(cx.listener(|desk, _: &Narrow, _, cx| {
desk.relook(|look| look.width = Width::Narrow, cx)
}))
.on_action(cx.listener(|desk, _: &Medium, _, cx| {
desk.relook(|look| look.width = Width::Medium, cx)
}))
.on_action(
cx.listener(|desk, _: &Wide, _, cx| {
desk.relook(|look| look.width = Width::Wide, cx)
}),
)
.on_action(
cx.listener(|desk, _: &Sans, _, cx| desk.relook(|look| look.font = Font::Sans, cx)),
)
.on_action(
cx.listener(|desk, _: &Serif, _, cx| {
desk.relook(|look| look.font = Font::Serif, cx)
}),
)
.on_action(
cx.listener(|desk, _: &Mono, _, cx| desk.relook(|look| look.font = Font::Mono, cx)),
)
.on_action(
cx.listener(move |desk, _: &Larger, _, cx| {
desk.relook(|look| look.size += step, cx)
}),
)
.on_action(cx.listener(move |desk, _: &Smaller, _, cx| {
desk.relook(|look| look.size = look.size.saturating_sub(step), cx)
}))
.on_action(
cx.listener(|desk, _: &ActualSize, _, cx| desk.relook(|look| look.size = 100, cx)),
)
}
}
#[cfg(test)]
mod tests {
use ely_gpui_component::documents::DocumentLook;
use gpui::{
Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point,
rems,
};
use crate::desk::{
Desk,
tests::{open, start},
};
fn document_look(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> DocumentLook {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().read(cx).look().clone()
})
}
#[gpui::test]
fn a_new_tab_takes_the_kept_look(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let kept = r#"{"focus":true,"typewriter":true,"width":"narrow","font":"serif","size":120}"#;
std::fs::write(data.path().join("look.json"), kept).expect("a look");
let path = files.path().join("a.md");
std::fs::write(
&path, "a
",
)
.expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let look = document_look(&desk, cx);
assert!(look.focus && look.typewriter);
assert_eq!((look.measure, look.scale), (Some(rems(36.0)), 1.2));
assert_eq!(look.font.as_deref(), Some(super::SERIF));
}
#[gpui::test]
fn a_menu_change_reaches_open_tabs_and_is_kept(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(
&path, "a
",
)
.expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.dispatch_action(super::Wide);
cx.dispatch_action(super::Larger);
let look = document_look(&desk, cx);
assert_eq!((look.measure, look.scale), (Some(rems(64.0)), 1.1));
let kept = std::fs::read_to_string(data.path().join("look.json")).expect("kept");
assert!(kept.contains("\"wide\"") && kept.contains("110"), "{kept}");
}
#[test]
fn a_kept_look_reads_back_and_none_is_default() {
let dir = tempfile::tempdir().expect("a temp dir");
let file = dir.path().join("look.json");
assert_eq!(
super::load(&file).expect("none kept"),
super::Look::default()
);
std::fs::write(
&file,
r#"{"focus":true,"typewriter":false,"width":"wide","font":"serif","size":120}"#,
)
.expect("a look");
let look = super::load(&file).expect("it reads");
assert!(look.focus);
assert_eq!(
(look.width, look.font, look.size),
(super::Width::Wide, super::Font::Serif, 120)
);
}
#[gpui::test]
fn ctrl_and_the_wheel_size_the_text(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(
&path, "a
",
)
.expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
let at = cx.update(|window, _| window.viewport_size()).center();
let wheel = |modifiers, y| ScrollWheelEvent {
position: at,
delta: ScrollDelta::Lines(point(0.0, y)),
modifiers,
..Default::default()
};
let other = match cfg!(target_os = "macos") {
true => Modifiers {
control: true,
..Default::default()
},
false => Modifiers {
platform: true,
..Default::default()
},
};
cx.simulate_event(wheel(other, 3.0));
cx.run_until_parked();
assert_eq!(
document_look(&desk, cx).scale,
1.0,
"only Ctrl, or Cmd on macOS"
);
cx.simulate_event(wheel(Modifiers::secondary_key(), 3.0));
cx.run_until_parked();
assert_eq!(document_look(&desk, cx).scale, 1.1, "a notch is one step");
cx.simulate_event(wheel(Modifiers::secondary_key(), -1.0));
cx.simulate_event(wheel(Modifiers::secondary_key(), -1.0));
cx.run_until_parked();
assert_eq!(document_look(&desk, cx).scale, 0.9);
}
}