Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions crates/ui-egui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,9 @@ impl NoteCraftApp {
}

pub fn new(session: Session) -> NoteCraftApp {
// The installed fonts are read in the background, so the window opens at once.
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
crate::fonts::start_catalog();
let mode_pref = session.settings.get("theme_mode").and_then(|v| ModePref::parse(&v)).unwrap_or(ModePref::System);
let ui_language =
session.settings.get("ui_language").map(|value| crate::i18n::normalize_preference(&value)).unwrap_or_else(|| crate::i18n::AUTO.into());
Expand Down Expand Up @@ -3011,13 +3014,16 @@ impl NoteCraftApp {
pageedit::FontValue::Value(Some(name)) => Some(name.clone()),
pageedit::FontValue::Value(None) => Some(crate::tl_id!("ui.default").to_string()),
pageedit::FontValue::Mixed => Some(crate::tl_id!("ui.mixed").to_string()),
pageedit::FontValue::Unavailable => None,
// With no caret in the page the box shows the default font, as the size box shows its base size.
pageedit::FontValue::Unavailable => Some(crate::tl_id!("ui.default").to_string()),
};
let size = match font_selection.size {
pageedit::FontValue::Value(Some(size)) => Some(format!("{size} pt")),
pageedit::FontValue::Value(None) => Some(crate::tl_id!("ui.default").to_string()),
pageedit::FontValue::Value(Some(size)) => Some(size.to_string()),
// The page's base size is 11 points, so the box shows that number, not the word Default.
pageedit::FontValue::Value(None) => Some("11".to_owned()),
pageedit::FontValue::Mixed => Some(crate::tl_id!("ui.mixed").to_string()),
pageedit::FontValue::Unavailable => None,
// With no caret in the page the box shows the base size, as OneNote does.
pageedit::FontValue::Unavailable => Some("11".to_owned()),
};
let view = ribbon::View {
split_panes: self.split_panes,
Expand Down Expand Up @@ -4306,12 +4312,26 @@ impl NoteCraftApp {
self.status_is_error = false;
}

/// With no caret in the page, a font or size choice goes to the end of the last writable
/// block, so it applies to the next text typed there, as OneNote does.
fn place_caret_for_format(ed: &mut Editor) {
if ed.caret.is_some() {
return;
}
let Some(index) = ed.blocks.iter().rposition(pageedit::is_writable) else { return };
let text = pageedit::caret_text(&ed.blocks[index]);
let end = text.chars().count();
ed.caret =
Some(pageedit::Caret { block: index, text, focus: true, selection: (end, end), place: Some(end), cell: None, span: None, inline: None });
}

fn apply_font_family(&mut self, family: &str) {
if !notecraft_formats::markdown::valid_font_family(family) {
log::warn!("ribbon requested an invalid font family");
return;
}
let Some(ed) = self.editor.as_mut() else { return };
Self::place_caret_for_format(ed);
if ed.across.is_some_and(|selection| !selection.inside_one_block()) {
self.status = crate::tl_id!("ui.inline_mark_whole_line").into();
self.status_is_error = false;
Expand Down Expand Up @@ -4400,6 +4420,7 @@ impl NoteCraftApp {
return;
}
let Some(ed) = self.editor.as_mut() else { return };
Self::place_caret_for_format(ed);
if ed.across.is_some_and(|selection| !selection.inside_one_block()) {
self.status = crate::tl_id!("ui.inline_mark_whole_line").into();
self.status_is_error = false;
Expand Down
115 changes: 115 additions & 0 deletions crates/ui-egui/src/combo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! An editable combo box: a value box that shows the current value, with a drop-down arrow whose
//! list opens under the box. The font name and font size controls use it, so the arrow, the list
//! and the typing behave the same everywhere.
//!
//! The arrow is a generous hit region with a tooltip and an accessible label. The list highlights
//! the current value and scrolls to it once each time it opens. A typed value is applied when the
//! box loses focus, if the caller accepts it.

use egui::{Align, Area, Color32, Frame, Id, Order, ScrollArea, Shape, Stroke, Ui, WidgetInfo, WidgetType, vec2};

/// What the user chose this frame: a value picked from the list, or typed and accepted.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Outcome {
pub chosen: Option<String>,
}

/// The parts of one combo box that the caller supplies.
pub struct Spec<'a> {
/// Keeps the box's typed text, the open state and the scroll state apart from other combos.
pub id: Id,
/// The accessible name of the value box.
pub box_label: &'a str,
/// The accessible name and tooltip of the drop-down arrow.
pub arrow_label: &'a str,
/// The value shown in the box and highlighted in the list.
pub shown: &'a str,
/// The width of the value box in points.
pub width: f32,
/// The items of the list, in order.
pub items: &'a [String],
/// Shown in the list instead of items when there are none (for example, the font catalog is
/// unavailable).
pub empty_note: Option<&'a str>,
/// Whether a typed value is accepted when the box loses focus.
pub accept: &'a dyn Fn(&str) -> bool,
}

/// Shows the combo box and returns what was chosen this frame.
pub fn show(ui: &mut Ui, text: Color32, spec: Spec<'_>) -> Outcome {
let mut outcome = Outcome::default();
let typed_id = spec.id.with("typed");
let open_id = spec.id.with("open");
let scroll_id = spec.id.with("scroll");

let mut typed = ui.data(|d| d.get_temp::<String>(typed_id)).unwrap_or_else(|| spec.shown.to_owned());
let value_box = ui.add(egui::TextEdit::singleline(&mut typed).id(typed_id).desired_width(spec.width));
value_box.widget_info(|| WidgetInfo::labeled(WidgetType::TextInput, true, spec.box_label));
if !value_box.has_focus() {
typed = spec.shown.to_owned();
}
// The typed value is applied when the box loses focus, as in OneNote.
if value_box.lost_focus() {
let value = typed.trim();
if value != spec.shown && (spec.accept)(value) {
outcome.chosen = Some(value.to_owned());
}
typed = spec.shown.to_owned();
}
ui.data_mut(|d| d.insert_temp(typed_id, typed));

let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
let mut scroll = ui.data(|d| d.get_temp::<bool>(scroll_id)).unwrap_or(false);
let arrow = ui.add(egui::Button::new("").min_size(vec2(18.0, 20.0)));
{
let c = arrow.rect.center();
let points = vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)];
ui.painter().add(Shape::convex_polygon(points, text, Stroke::NONE));
}
arrow.widget_info(|| {
let mut info = WidgetInfo::labeled(WidgetType::Button, true, spec.arrow_label);
info.current_text_value = Some(spec.shown.to_owned());
info
});
let arrow = arrow.on_hover_text(spec.arrow_label);
if arrow.clicked() {
open = !open;
scroll = open;
}

if open {
let list = Area::new(spec.id.with("list")).order(Order::Foreground).fixed_pos(value_box.rect.left_bottom()).show(ui.ctx(), |ui| {
Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(value_box.rect.width());
if spec.items.is_empty() {
if let Some(note) = spec.empty_note {
ui.label(note);
}
return;
}
ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
for item in spec.items {
let current = item == spec.shown;
let row = ui.selectable_label(current, item);
if current && scroll {
row.scroll_to_me(Some(Align::Center));
}
if row.clicked() {
outcome.chosen = Some(item.clone());
open = false;
}
}
});
});
});
if ui.input(|i| i.pointer.any_click()) && !list.response.hovered() && !arrow.hovered() {
open = false;
}
}
scroll = false;
ui.data_mut(|d| {
d.insert_temp(scroll_id, scroll);
d.insert_temp(open_id, open);
});
outcome
}
21 changes: 20 additions & 1 deletion crates/ui-egui/src/fonts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,28 @@ pub fn page_families(blocks: &[notecraft_formats::Block]) -> BTreeSet<String> {
families
}

#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
static CATALOG: std::sync::OnceLock<Result<system::Catalog, String>> = std::sync::OnceLock::new();

/// Reads the installed fonts on a background thread, so the window never waits for it. Listing
/// every font takes a while on some machines.
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
pub fn start_catalog() {
std::thread::spawn(|| {
let _ = CATALOG.get_or_init(system::Catalog::discover);
});
}

/// The installed fonts, or `None` while they are still being read. Never blocks.
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
pub fn catalog_if_ready() -> Option<Result<&'static system::Catalog, &'static str>> {
CATALOG.get().map(|result| result.as_ref().map_err(String::as_str))
}

/// The installed fonts. Waits for the background read if it is not finished (used when a font is
/// installed, which is rare).
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
pub fn system_catalog() -> Result<&'static system::Catalog, &'static str> {
static CATALOG: std::sync::OnceLock<Result<system::Catalog, String>> = std::sync::OnceLock::new();
CATALOG.get_or_init(system::Catalog::discover).as_ref().map_err(String::as_str)
}

Expand Down
28 changes: 28 additions & 0 deletions crates/ui-egui/src/fonts/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ impl Catalog {
Self { database, families }
}

/// Adds every font file in `folder` to `database`. Windows keeps per-user fonts in a folder the
/// system scan does not read, so the catalog reads it as well.
#[cfg(any(test, target_os = "windows"))]
pub(crate) fn load_fonts_folder(database: &mut fontdb::Database, folder: &std::path::Path) {
if folder.is_dir() {
database.load_fonts_dir(folder);
}
}

pub fn families(&self) -> &[String] {
&self.families
}
Expand Down Expand Up @@ -68,3 +77,22 @@ impl Catalog {
Ok(key)
}
}

#[cfg(test)]
mod folder_tests {
use super::Catalog;

/// A font folder the catalog reads: the real Noto Sans Hebrew file, copied into a fresh folder.
#[test]
fn a_fonts_folder_adds_its_families_to_the_catalog() {
let folder = std::env::temp_dir().join(format!("notecraft-fonts-folder-{}", std::process::id()));
std::fs::create_dir_all(&folder).expect("a folder");
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/NotoSansHebrew.ttf");
std::fs::copy(&source, folder.join("NotoSansHebrew.ttf")).expect("the font file");
let mut database = fontdb::Database::new();
Catalog::load_fonts_folder(&mut database, &folder);
let catalog = Catalog::from_database(database);
let _ = std::fs::remove_dir_all(&folder);
assert!(catalog.families().iter().any(|name| name == "Noto Sans Hebrew"), "the family is listed: {:?}", catalog.families());
}
}
1 change: 1 addition & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod backstage;
pub mod bidi;
pub mod clipboard;
pub mod codeview;
pub mod combo;
pub mod containers;
pub mod control;
pub mod diagramview;
Expand Down
Loading
Loading