Task panes: draw them with craft_ui::panes; Styles docks left or right or floats (#1756)

The nine task panes (SmartArt Text Pane, Navigation, Clipboard, Styles,
Style Inspector, Accessibility, Alt Text, Restrict Editing, Comments) are
drawn by craft_ui::panes with the Columns policy: a title bar with a menu
and close button, a resize edge that also takes arrow keys, and F6 to move
between panes and the document. Panes open from the same view flags, in
the same order and at the same default widths; close runs each pane's
existing command.

The Styles pane can dock left or right or float through the pane menu or
the new command view.stylesPaneDock. ui.json keeps its position and float
rect; unknown values read as the defaults.
This commit is contained in:
aiongg
2026-10-19 12:17:59 +00:00
parent d588e0b873
commit 0dd5b56dfe
31 changed files with 522 additions and 124 deletions
Generated
+1 -1
View File
@@ -880,7 +880,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
source = "git+https://github.com/aiongg/craft-libs?rev=c94ab4d313832ea9a84da721a6d3e7a7838dacef#c94ab4d313832ea9a84da721a6d3e7a7838dacef"
dependencies = [
"egui",
]
+14
View File
@@ -91,6 +91,10 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"value"?: "light" | "dark" | "system"; without it, toggles light and dark}"#)
.pure(),
CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(),
// Where the Styles pane sits: docked right or left, or floating (#1756). No `dock` reads it.
CommandSpec::new("view.stylesPaneDock", "Styles Pane Position", "Home › Styles", styles_pane_dock)
.params(r#"{"dock"?: "left" | "right" | "float"}"#)
.pure(),
CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(),
CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| {
toggle(s, v, |x| &mut x.accessibility_pane)
@@ -158,6 +162,16 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
Ok(json!({"mode": m, "readMode": read}))
}
fn styles_pane_dock(s: &mut Session, v: &Value) -> CmdResult {
if let Some(d) = v.get("dock") {
let Some(dock) = d.as_str().and_then(crate::StylesPaneDock::parse) else {
return Err(CmdError::Params("dock must be left, right or float".into()));
};
s.view.styles_pane_dock = dock;
}
Ok(json!({"dock": s.view.styles_pane_dock.name()}))
}
fn read_column_width(s: &mut Session, v: &Value) -> CmdResult {
let width = match v.get("value").and_then(Value::as_str) {
Some("auto") => None,
+2 -1
View File
@@ -28,7 +28,8 @@ pub use io::Password;
pub use keymap::KeyMap;
pub use ribbon::RibbonLayout;
pub use session::{
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale,
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX, ViewState, blank_document,
set_system_locale,
};
pub use wordcraft_doc as doc;
pub use wordcraft_geom::Region;
+37
View File
@@ -131,6 +131,10 @@ pub struct ViewState {
pub table_gridlines: bool,
pub nav_pane: bool,
pub styles_pane: bool,
/// Where the Styles pane sits: docked right (the default) or left, or floating (#1756).
/// Kept in the user's preferences, not the document.
#[serde(default)]
pub styles_pane_dock: StylesPaneDock,
/// Style Inspector pane (Home › Styles).
#[serde(default)]
pub style_inspector: bool,
@@ -204,6 +208,38 @@ pub struct ViewState {
pub full_screen: bool,
}
/// Where the Styles pane sits (`view.stylesPaneDock`, #1756).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StylesPaneDock {
#[default]
Right,
Left,
/// A window of its own over the document, which can be moved and resized.
#[serde(rename = "float")]
Floating,
}
impl StylesPaneDock {
/// `left`, `right` or `float`; anything else is `None`.
pub fn parse(name: &str) -> Option<Self> {
match name {
"right" => Some(Self::Right),
"left" => Some(Self::Left),
"float" => Some(Self::Floating),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Right => "right",
Self::Left => "left",
Self::Floating => "float",
}
}
}
fn on() -> bool {
true
}
@@ -240,6 +276,7 @@ impl Default for ViewState {
table_gridlines: true,
nav_pane: false,
styles_pane: false,
styles_pane_dock: StylesPaneDock::Right,
style_inspector: false,
comments_pane: false,
clipboard_pane: false,
+25
View File
@@ -3882,6 +3882,31 @@ fn styles_apply_pane_is_not_an_edit() {
assert!(s.view.styles_pane);
}
/// #1756: the Styles pane docks on the right (the default), on the left, or floats.
#[test]
fn styles_pane_docks_left_right_or_floats() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
s.dirty = false;
let undo = s.undo_labels();
assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], "right");
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "right"}), "no dock reads it");
for dock in ["left", "float", "right"] {
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({"dock": dock})), json!({"dock": dock}));
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": dock}));
assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], dock);
}
run(&mut s, "view.stylesPaneDock", json!({"dock": "left"}));
for junk in [json!({"dock": "top"}), json!({"dock": 1}), json!({"dock": null}), json!({"dock": ["left"]}), json!({"dock": "LEFT "})] {
assert!(s.run("view.stylesPaneDock", &junk).is_err(), "{junk} is an error");
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "left"}), "{junk} changes nothing");
}
// A view setting: no undo step, the document isn't changed, and the pane's visibility stays.
assert_eq!(s.undo_labels(), undo);
assert!(!s.dirty);
assert!(!s.view.styles_pane);
}
#[test]
fn styles_apply_rejects_bad_style_params() {
let mut s = s();
+1 -1
View File
@@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi
workspace = true
[dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c94ab4d313832ea9a84da721a6d3e7a7838dacef" }
wordcraft-geom = { workspace = true }
wordcraft-doc = { workspace = true }
unicode-bidi = { workspace = true }
-8
View File
@@ -10,10 +10,6 @@ use crate::theme::{regular, semibold};
/// Review › Check Accessibility: the findings for the document as it is now.
pub fn accessibility(app: &mut WordApp, ui: &mut Ui) {
if crate::panes::header(ui, "Accessibility") {
let _ = app.run("view.accessibilityPane", json!({"value": false}));
return;
}
let issues = issues(app, ui);
if issues.is_empty() {
ui.label(egui::RichText::new(tl!("No accessibility issues found.")).weak());
@@ -90,10 +86,6 @@ fn go_to(app: &mut WordApp, issue: &Value) {
/// Alt Text: the selected picture's, shape's, chart's or group's description and decorative mark,
/// or the title and description of the table the caret is in.
pub fn alt_text(app: &mut WordApp, ui: &mut Ui) {
if crate::panes::header(ui, "Alt Text") {
let _ = app.run("view.altTextPane", json!({"value": false}));
return;
}
if let Some((pos, obj)) = wordcraft_engine::cmd::objects::selected(&app.session) {
let decorative = obj.is_decorative();
ui.label(egui::RichText::new(tl!("Describe what this object shows, for people who can't see it.")).weak());
+8
View File
@@ -2687,3 +2687,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Въведете размер в произволна единица, например 5 cm или 300 px. Другата страна се променя съответно, така че картината запазва пропорциите си.
Import… Импортиране…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Положение на панела със стилове
Dock Left Закачане вляво
Dock Right Закачане вдясно
Float Откачане
Pane Options Опции на панела
Move Pane Преместване на панела
Resize Pane Преоразмеряване на панела
Can't render this file because it contains an unexpected character in line 7 and column 25.
+8
View File
@@ -2690,3 +2690,11 @@ preview Title Ukázka
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Zadejte velikost v libovolné jednotce, například 5 cm nebo 300 px. Druhá strana se přizpůsobí, takže obrázek zachová své proporce.
Import… Importovat…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Umístění podokna stylů
Dock Left Ukotvit vlevo
Dock Right Ukotvit vpravo
Float Plovoucí
Pane Options Možnosti podokna
Move Pane Přesunout podokno
Resize Pane Změnit velikost podokna
Can't render this file because it contains an unexpected character in line 7 and column 25.
+8
View File
@@ -2287,3 +2287,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Geben Sie eine Größe in einer beliebigen Einheit ein, z. B. 5 cm oder 300 px. Die andere Seite passt sich an, sodass das Bild seine Proportionen behält.
Import… Importieren…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Position des Formatvorlagenbereichs
Dock Left Links andocken
Dock Right Rechts andocken
Float Unverankert
Pane Options Bereichsoptionen
Move Pane Bereich verschieben
Resize Pane Bereichsgröße ändern
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -2654,3 +2654,11 @@ preview Title Título
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Escribe un tamaño en cualquier unidad, como 5 cm o 300 px. El otro lado se ajusta, así que la imagen conserva sus proporciones.
Import… Importar…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Posición del panel de estilos
Dock Left Acoplar a la izquierda
Dock Right Acoplar a la derecha
Float Flotante
Pane Options Opciones del panel
Move Pane Mover panel
Resize Pane Cambiar tamaño del panel
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -2322,3 +2322,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Sisesta suurus mis tahes ühikus, näiteks 5 cm või 300 px. Teine külg muutub kaasa, nii et pilt säilitab oma proportsioonid.
Import… Impordi…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Stiilipaani asukoht
Dock Left Dokki vasakule
Dock Right Dokki paremale
Float Ujuv
Pane Options Paani suvandid
Move Pane Teisalda paan
Resize Pane Muuda paani suurust
Can't render this file because it contains an unexpected character in line 847 and column 23.
+8
View File
@@ -2700,3 +2700,11 @@ template Letter Lettre
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Tapez une taille dans n'importe quelle unité, par exemple 5 cm ou 300 px. L'autre côté suit, et l'image garde ses proportions.
Import… Importer…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Position du volet Styles
Dock Left Ancrer à gauche
Dock Right Ancrer à droite
Float Flottant
Pane Options Options du volet
Move Pane Déplacer le volet
Resize Pane Redimensionner le volet
Can't render this file because it contains an unexpected character in line 7 and column 25.
+8
View File
@@ -2698,3 +2698,11 @@ preview Title Naslov
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Upišite veličinu u bilo kojoj jedinici, npr. 5 cm ili 300 px. Druga se strana prilagođava pa slika zadržava svoje proporcije.
Import… Uvezi…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Položaj okna stilova
Dock Left Usidri lijevo
Dock Right Usidri desno
Float Plutajuće
Pane Options Mogućnosti okna
Move Pane Premjesti okno
Resize Pane Promijeni veličinu okna
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -2694,3 +2694,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Írj be egy méretet bármilyen mértékegységben, például 5 cm vagy 300 px. A másik oldal követi, így a kép megtartja az arányait.
Import… Importálás…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Stílusok panel helye
Dock Left Rögzítés balra
Dock Right Rögzítés jobbra
Float Lebegtetés
Pane Options Panel beállításai
Move Pane Panel áthelyezése
Resize Pane Panel átméretezése
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -2707,3 +2707,11 @@ preview Title Esempio
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Scrivi una dimensione in qualsiasi unità, ad esempio 5 cm o 300 px. L'altro lato si adegua, così l'immagine mantiene le proporzioni.
Import… Importa…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Posizione del riquadro Stili
Dock Left Ancora a sinistra
Dock Right Ancora a destra
Float Mobile
Pane Options Opzioni riquadro
Move Pane Sposta riquadro
Resize Pane Ridimensiona riquadro
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -2288,3 +2288,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm や 300 px など、任意の単位でサイズを入力します。もう一方の辺も合わせて変わるため、図の縦横比は保たれます。
Import… インポート…
# Task panes and the Styles pane position (#1756)
Styles Pane Position スタイル ウィンドウの位置
Dock Left 左にドッキング
Dock Right 右にドッキング
Float フローティング
Pane Options ウィンドウ オプション
Move Pane ウィンドウの移動
Resize Pane ウィンドウのサイズ変更
Can't render this file because it contains an unexpected character in line 925 and column 23.
+8
View File
@@ -2542,3 +2542,11 @@ preview Title 제목
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm 또는 300 px처럼 원하는 단위로 크기를 입력하세요. 다른 쪽도 함께 바뀌어 그림의 비율이 유지됩니다.
Import… 가져오기…
# Task panes and the Styles pane position (#1756)
Styles Pane Position 스타일 창 위치
Dock Left 왼쪽에 고정
Dock Right 오른쪽에 고정
Float 떠 있음
Pane Options 창 옵션
Move Pane 창 이동
Resize Pane 창 크기 조정
Can't render this file because it contains an unexpected character in line 931 and column 23.
+8
View File
@@ -2703,3 +2703,11 @@ preview Title Tytuł
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Wpisz rozmiar w dowolnej jednostce, np. 5 cm lub 300 px. Druga strona dopasuje się, więc obraz zachowa proporcje.
Import… Importuj…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Położenie okienka stylów
Dock Left Zadokuj po lewej
Dock Right Zadokuj po prawej
Float Pływające
Pane Options Opcje okienka
Move Pane Przenieś okienko
Resize Pane Zmień rozmiar okienka
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -2300,3 +2300,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Digite um tamanho em qualquer unidade, como 5 cm ou 300 px. O outro lado acompanha, e a imagem mantém as proporções.
Import… Importar…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Posição do painel de estilos
Dock Left Encaixar à esquerda
Dock Right Encaixar à direita
Float Flutuante
Pane Options Opções do painel
Move Pane Mover painel
Resize Pane Redimensionar painel
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -2696,3 +2696,11 @@ preview Title Пример
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введите размер в любых единицах, например 5 cm или 300 px. Другая сторона изменится вместе с ней, и рисунок сохранит пропорции.
Import… Импорт…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Положение области стилей
Dock Left Закрепить слева
Dock Right Закрепить справа
Float Открепить
Pane Options Параметры области
Move Pane Переместить область
Resize Pane Изменить размер области
Can't render this file because it contains an unexpected character in line 7 and column 25.
+8
View File
@@ -2298,3 +2298,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Unesite veličinu u bilo kojoj jedinici, na primer 5 cm ili 300 px. Druga strana se prilagođava, pa slika zadržava svoje proporcije.
Import… Uvezi…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Položaj okna stilova
Dock Left Usidri levo
Dock Right Usidri desno
Float Plutajuće
Pane Options Opcije okna
Move Pane Premesti okno
Resize Pane Promeni veličinu okna
Can't render this file because it contains an unexpected character in line 6 and column 25.
+8
View File
@@ -2302,3 +2302,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Унесите величину у било којој јединици, на пример 5 cm или 300 px. Друга страна се прилагођава, па слика задржава своје пропорције.
Import… Увези…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Положај окна стилова
Dock Left Усидри лево
Dock Right Усидри десно
Float Плутајуће
Pane Options Опције окна
Move Pane Премести окно
Resize Pane Промени величину окна
Can't render this file because it contains an unexpected character in line 11 and column 25.
+8
View File
@@ -2713,3 +2713,11 @@ preview Title Titel
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Skriv en storlek i valfri enhet, till exempel 5 cm eller 300 px. Den andra sidan följer med, så att bilden behåller sina proportioner.
Import… Importera…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Stilpanelens placering
Dock Left Docka till vänster
Dock Right Docka till höger
Float Flytande
Pane Options Panelalternativ
Move Pane Flytta panel
Resize Pane Ändra storlek på panel
Can't render this file because it contains an unexpected character in line 7 and column 25.
+8
View File
@@ -2695,3 +2695,11 @@ preview Title Örnek
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Herhangi bir birimde bir boyut yazın, örneğin 5 cm veya 300 px. Diğer kenar da buna uyar, böylece resim oranlarını korur.
Import… İçe aktar…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Stiller Bölmesi Konumu
Dock Left Sola Yerleştir
Dock Right Sağa Yerleştir
Float Serbest Bırak
Pane Options Bölme Seçenekleri
Move Pane Bölmeyi Taşı
Resize Pane Bölmeyi Yeniden Boyutlandır
Can't render this file because it contains an unexpected character in line 799 and column 23.
+8
View File
@@ -2300,3 +2300,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введіть розмір у будь-яких одиницях, наприклад 5 cm або 300 px. Інша сторона зміниться разом із нею, тож зображення збереже пропорції.
Import… Імпортувати…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Положення панелі стилів
Dock Left Закріпити ліворуч
Dock Right Закріпити праворуч
Float Відкріпити
Pane Options Параметри панелі
Move Pane Перемістити панель
Resize Pane Змінити розмір панелі
Can't render this file because it contains an unexpected character in line 6 and column 31.
+8
View File
@@ -2738,3 +2738,11 @@ template Letter Thư
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Nhập kích thước theo đơn vị bất kỳ, ví dụ 5 cm hoặc 300 px. Cạnh còn lại thay đổi theo, nên ảnh giữ nguyên tỷ lệ.
Import… Nhập…
# Task panes and the Styles pane position (#1756)
Styles Pane Position Vị trí khung kiểu
Dock Left Neo bên trái
Dock Right Neo bên phải
Float Nổi
Pane Options Tùy chọn khung
Move Pane Di chuyển khung
Resize Pane Đổi kích thước khung
Can't render this file because it contains an unexpected character in line 7 and column 25.
+8
View File
@@ -2300,3 +2300,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任意单位输入大小,例如 5 cm 或 300 px。另一边随之调整,图片保持原有比例。
Import… 导入…
# Task panes and the Styles pane position (#1756)
Styles Pane Position 样式窗格位置
Dock Left 停靠在左侧
Dock Right 停靠在右侧
Float 浮动
Pane Options 窗格选项
Move Pane 移动窗格
Resize Pane 调整窗格大小
Can't render this file because it contains an unexpected character in line 7 and column 25.
+8
View File
@@ -2289,3 +2289,11 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任何單位輸入大小,例如 5 cm 或 300 px。另一邊隨之調整,圖片保持原有比例。
Import… 匯入…
# Task panes and the Styles pane position (#1756)
Styles Pane Position 樣式窗格位置
Dock Left 停駐在左側
Dock Right 停駐在右側
Float 浮動
Pane Options 窗格選項
Move Pane 移動窗格
Resize Pane 調整窗格大小
Can't render this file because it contains an unexpected character in line 926 and column 23.
+88 -2
View File
@@ -271,6 +271,15 @@ pub struct UiState {
/// View › Navigation Pane: open or closed as the person left it, kept between runs (#2526).
/// The session owns it while running (see [`WordApp::prefs`]).
pub nav_pane: bool,
/// Where the Styles pane sits (#1756). The session owns it while running (see [`WordApp::prefs`]).
#[serde(deserialize_with = "lenient_styles_pane_dock")]
pub styles_pane_dock: wordcraft_engine::StylesPaneDock,
/// The floating Styles pane's `[x, y, width, height]`, also kept while it is docked.
#[serde(deserialize_with = "lenient_float_rect")]
pub styles_pane_float: Option<[f32; 4]>,
/// The docked Styles pane's width this run; like the other panes' widths, not kept between runs.
#[serde(skip)]
pub styles_pane_width: Option<f32>,
pub show_discord: bool,
/// User name (File › Options) for comments and tracked changes; empty keeps the default.
pub author: String,
@@ -373,6 +382,9 @@ impl Default for UiState {
legacy_dark: None,
nav_tab: "headings".into(),
nav_pane: false,
styles_pane_dock: wordcraft_engine::StylesPaneDock::Right,
styles_pane_float: None,
styles_pane_width: None,
show_discord: true,
author: String::new(),
window: None,
@@ -459,6 +471,21 @@ pub fn recent_fonts_from<'a>(names: impl IntoIterator<Item = &'a str>) -> Vec<St
out
}
/// An unknown saved position reads as the default.
fn lenient_styles_pane_dock<'de, D: serde::Deserializer<'de>>(d: D) -> Result<wordcraft_engine::StylesPaneDock, D::Error> {
let v = Value::deserialize(d)?;
Ok(v.as_str().and_then(wordcraft_engine::StylesPaneDock::parse).unwrap_or_default())
}
/// Anything but four finite numbers reads as no saved rect; `craft_ui::panes` brings a saved one
/// back inside the window.
fn lenient_float_rect<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<[f32; 4]>, D::Error> {
let v = Value::deserialize(d)?;
let Some([x, y, w, h]) = v.as_array().map(Vec::as_slice) else { return Ok(None) };
let n = |v: &Value| v.as_f64().map(|n| n as f32).filter(|n| n.is_finite());
Ok(n(x).zip(n(y)).zip(n(w)).zip(n(h)).map(|(((x, y), w), h)| [x, y, w, h]))
}
/// `recentFonts` as saved, through [`recent_fonts_from`]: an entry that isn't a name is dropped,
/// not the whole list.
fn lenient_recent_fonts<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
@@ -762,6 +789,7 @@ impl WordApp {
ui.read_aloud_skip_citations = self.session.read_aloud.skip_citations;
ui.dark_page = self.session.view.dark_mode;
ui.nav_pane = self.session.view.nav_pane;
ui.styles_pane_dock = self.session.view.styles_pane_dock;
ui.keyboard = self.session.keymap.clone();
ui.ribbon = self.session.ribbon.clone();
ui.ink_pens = self.session.view.draw.pens.clone();
@@ -807,6 +835,7 @@ impl WordApp {
self.session.read_aloud.skip_citations = self.ui.read_aloud_skip_citations;
self.session.view.dark_mode = self.ui.dark_page;
self.session.view.nav_pane = self.ui.nav_pane;
self.session.view.styles_pane_dock = self.ui.styles_pane_dock;
// Keys for commands this version doesn't have are dropped.
self.session.keymap = std::mem::take(&mut self.ui.keyboard);
let reg = self.session.registry.clone();
@@ -6613,6 +6642,62 @@ mod tests {
assert!(!old.session.view.nav_pane, "no saved value: closed");
}
/// Task panes: two open panes on one edge sit side by side, the first outermost; a pane's ×
/// runs its command; the Styles pane's place and float rect survive a restart (#1756).
#[test]
fn task_panes_share_an_edge_close_and_keep_the_styles_place() {
use egui_kittest::kittest::Queryable;
let bars = |h: &egui_kittest::Harness<'_, WordApp>| {
let mut bars: Vec<egui::Rect> = h.get_all_by_label("Move Pane").map(|n| n.rect()).collect();
bars.sort_by(|a, b| a.left().total_cmp(&b.left()));
bars
};
// A title bar's ▾ and × sit right of the bar, on its row.
fn click_button(h: &egui_kittest::Harness<'_, WordApp>, bar: egui::Rect, label: &str) {
let on_row = |r: egui::Rect| r.center().y > bar.top() && r.center().y < bar.bottom() && r.left() >= bar.right() - 1.0;
let node = h.get_all_by_label(label).filter(|n| on_row(n.rect())).min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()));
node.unwrap_or_else(|| panic!("no {label} beside the title bar")).click();
}
let mut a = app();
a.run("view.stylesPane", json!({"value": true})).unwrap();
a.run("view.commentsPane", json!({"value": true})).unwrap();
let mut h = harness(a);
h.run_steps(4);
let [comments, styles] = bars(&h)[..] else { panic!("two pane title bars") };
assert!(comments.right() <= styles.left() + 1.0, "side by side");
assert!(comments.left() > 720.0, "both on the right");
// Default widths 290 and 250: Styles, opened first, is the outer one.
assert!((comments.width() - styles.width() - 40.0).abs() < 2.0, "Styles is outermost: {comments:?} {styles:?}");
click_button(&h, styles, "Close");
h.run_steps(3);
assert!(!h.state().session.view.styles_pane, "× ran view.stylesPane");
assert!(h.state().session.view.comments_pane);
assert_eq!(bars(&h).len(), 1);
h.state_mut().run("view.stylesPane", json!({"value": true})).unwrap();
h.run_steps(3);
let styles = *bars(&h).last().unwrap();
click_button(&h, styles, "Pane Options");
h.run_steps(2);
h.get_by_label("Float").click();
h.run_steps(3);
assert_eq!(h.state().session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating);
let rect = h.state().ui.styles_pane_float.expect("the float rect is kept");
let saved = serde_json::to_vec(&h.state().prefs()).unwrap();
let mut next = app();
next.apply_prefs(serde_json::from_slice(&saved).unwrap());
assert_eq!(next.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating);
assert_eq!(next.ui.styles_pane_float, Some(rect));
let mut junk = app();
junk.apply_prefs(serde_json::from_str(r#"{"stylesPaneDock": "top", "stylesPaneFloat": [1, "x", 3, 4], "navPane": true}"#).unwrap());
assert_eq!(junk.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Right, "junk reads as the default");
assert_eq!(junk.ui.styles_pane_float, None);
assert!(junk.session.view.nav_pane, "the rest of the file still loads");
}
#[test]
fn prefs_without_user_name_keep_default() {
let mut a = app();
@@ -7890,13 +7975,14 @@ mod tests {
h.step();
assert!(h.state().session.view.focus_mode);
assert!(!shown(&h, "Ruler") && !shown(&h, "Focus"), "the ribbon is hidden");
assert!(!shown(&h, "Navigation"), "and so is the Navigation pane");
// The pane's title bar: the only pane open is Navigation.
assert!(!shown(&h, "Move Pane"), "and so is the Navigation pane");
h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
h.step();
h.step();
assert!(!h.state().session.view.focus_mode, "Escape leaves Focus");
assert!(shown(&h, "Ruler"), "and the ribbon is back");
assert!(shown(&h, "Navigation"), "with the pane");
assert!(shown(&h, "Move Pane"), "with the pane");
// Immersive Reader is Read Mode, not Focus: its status bar stays.
h.state_mut().run("view.immersive", json!({})).unwrap();
h.step();
+178 -111
View File
@@ -1,116 +1,198 @@
//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector,
//! Task panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector,
//! Accessibility and Alt Text (see `a11y_pane`), Restrict Editing ([`crate::dialogs_review`]), Comments,
//! and the SmartArt Text Pane.
//! and the SmartArt Text Pane. `craft_ui::panes` draws them around the document, with their title
//! bars, menus and resize edges; the session's view flags say which are open.
use std::collections::BTreeMap;
use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneLabels, PaneRequest, PaneState, Place, SidePolicy};
use egui::{Stroke, Ui, vec2};
use serde_json::{Value, json};
use wordcraft_doc::{Pos, StoryRef};
use wordcraft_engine::StylesPaneDock;
use crate::WordApp;
use crate::theme::{Tokens, regular, semibold};
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected.
if app.session.view.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some() {
egui::Panel::left("smart_art_pane")
.default_size(250.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| {
if header(ui, "Text Pane") {
let _ = app.run("smartArt.textPane", json!({"value": false}));
return;
}
crate::dialogs_smart_art::text_pane(app, ui);
});
/// The task panes, in the order they open: each edge's first pane is outermost.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Pane {
/// SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected.
SmartArtText,
Navigation,
Clipboard,
Styles,
StyleInspector,
Accessibility,
AltText,
RestrictEditing,
Comments,
}
impl Pane {
fn title(self) -> &'static str {
match self {
Self::SmartArtText => "Text Pane",
Self::Navigation => "Navigation",
Self::Clipboard => "Clipboard",
Self::Styles => "Styles",
Self::StyleInspector => "Style Inspector",
Self::Accessibility => "Accessibility",
Self::AltText => "Alt Text",
Self::RestrictEditing => "Restrict Editing",
Self::Comments => "Comments",
}
}
if app.session.view.nav_pane {
egui::Panel::left("nav_pane")
.default_size(260.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| nav(app, ui));
fn default_state(self) -> PaneState {
let (edge, width) = match self {
Self::SmartArtText => (Edge::Start, 250.0),
Self::Navigation => (Edge::Start, 260.0),
Self::Clipboard => (Edge::Start, 250.0),
Self::Styles => (Edge::End, 250.0),
Self::StyleInspector => (Edge::End, 240.0),
Self::Accessibility => (Edge::End, 280.0),
Self::AltText => (Edge::End, 260.0),
Self::RestrictEditing => (Edge::End, 270.0),
Self::Comments => (Edge::End, 290.0),
};
PaneState::docked(edge, width)
}
if app.session.view.clipboard_pane {
egui::Panel::left("clipboard_pane")
.default_size(250.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| clipboard(app, ui));
}
if app.session.view.styles_pane {
egui::Panel::right("styles_pane")
.default_size(250.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| styles(app, ui));
}
if app.session.view.style_inspector {
egui::Panel::right("style_inspector")
.default_size(240.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| inspector(app, ui));
}
if app.session.view.accessibility_pane {
egui::Panel::right("accessibility_pane")
.default_size(280.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| crate::a11y_pane::accessibility(app, ui));
}
if app.session.view.alt_text_pane {
egui::Panel::right("alt_text_pane")
.default_size(260.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| crate::a11y_pane::alt_text(app, ui));
}
if app.restrict.open {
egui::Panel::right("restrict_pane")
.default_size(270.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| {
if header(ui, "Restrict Editing") {
app.restrict.open = false;
return;
}
crate::dialogs_review::restrict_pane(app, ui);
});
}
if app.session.view.comments_pane {
egui::Panel::right("comments_pane")
.default_size(290.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| comments(app, ui));
/// The command that closes the pane; Restrict Editing has none and closes in [`close`].
fn close_command(self) -> Option<&'static str> {
match self {
Self::SmartArtText => Some("smartArt.textPane"),
Self::Navigation => Some("view.navigationPane"),
Self::Clipboard => Some("edit.clipboardPane"),
Self::Styles => Some("view.stylesPane"),
Self::StyleInspector => Some("styles.inspector"),
Self::Accessibility => Some("view.accessibilityPane"),
Self::AltText => Some("view.altTextPane"),
Self::Comments => Some("view.commentsPane"),
Self::RestrictEditing => None,
}
}
}
pub(crate) fn header(ui: &mut Ui, title: &str) -> bool {
let mut close = false;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Drawn, not the "✕" character: the interface fonts have no glyph for it.
if crate::widgets::icon_button(ui, "close", tl!("Close")).clicked() {
close = true;
}
});
});
ui.add_space(4.0);
close
/// The panes open now, from the session's view flags.
pub fn open(app: &WordApp) -> Vec<Pane> {
let v = &app.session.view;
let smart_art = v.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some();
[
(Pane::SmartArtText, smart_art),
(Pane::Navigation, v.nav_pane),
(Pane::Clipboard, v.clipboard_pane),
(Pane::Styles, v.styles_pane),
(Pane::StyleInspector, v.style_inspector),
(Pane::Accessibility, v.accessibility_pane),
(Pane::AltText, v.alt_text_pane),
(Pane::RestrictEditing, app.restrict.open),
(Pane::Comments, v.comments_pane),
]
.into_iter()
.filter_map(|(pane, shown)| shown.then_some(pane))
.collect()
}
/// The Styles pane's place and size, from `view.stylesPaneDock` and the UI preferences (#1756).
/// Other panes keep theirs in the host for the session.
fn styles_state(app: &WordApp) -> PaneState {
let place = match app.session.view.styles_pane_dock {
StylesPaneDock::Right => Place::Docked(Edge::End),
StylesPaneDock::Left => Place::Docked(Edge::Start),
StylesPaneDock::Floating => Place::Floating,
};
PaneState { place, size: app.ui.styles_pane_width.or(Pane::Styles.default_state().size), float_rect: app.ui.styles_pane_float }
}
fn labels() -> PaneLabels {
PaneLabels {
menu: tl!("Pane Options").into(),
move_start: tl!("Dock Left").into(),
move_end: tl!("Dock Right").into(),
float: tl!("Float").into(),
close: tl!("Close").into(),
move_pane: tl!("Move Pane").into(),
resize: tl!("Resize Pane").into(),
..PaneLabels::default()
}
}
struct Content<'a> {
app: &'a mut WordApp,
}
impl PaneContent<Pane> for Content<'_> {
fn title(&self, pane: &Pane) -> String {
tl!(pane.title()).into()
}
fn body(&mut self, ui: &mut Ui, pane: &Pane) {
let app = &mut *self.app;
match pane {
Pane::SmartArtText => crate::dialogs_smart_art::text_pane(app, ui),
Pane::Navigation => nav(app, ui),
Pane::Clipboard => clipboard(app, ui),
Pane::Styles => styles(app, ui),
Pane::StyleInspector => inspector(app, ui),
Pane::Accessibility => crate::a11y_pane::accessibility(app, ui),
Pane::AltText => crate::a11y_pane::alt_text(app, ui),
Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui),
Pane::Comments => comments(app, ui),
}
}
fn default_state(&self, pane: &Pane) -> PaneState {
pane.default_state()
}
}
/// Draw the open panes around the document. Call before the canvas's `CentralPanel`.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let open = open(app);
let styles = styles_state(app);
let out = PaneHost::new("task_panes")
.policy(SidePolicy::Columns)
.labels(labels())
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border)))
.body_margin(egui::Margin::same(10))
.show(ui, &open, |pane| (*pane == Pane::Styles).then_some(styles), &mut Content { app });
for request in out.requests {
apply(app, request);
}
}
/// Run a request from the pane host through the pane's commands and the UI preferences.
pub(crate) fn apply(app: &mut WordApp, request: PaneRequest<Pane>) {
match request {
PaneRequest::Close(pane) => close(app, pane),
PaneRequest::Place(Pane::Styles, place) => {
let dock = match place {
Place::Docked(Edge::Start) => "left",
Place::Docked(Edge::End) => "right",
Place::Floating => "float",
Place::Docked(Edge::Bottom) => return,
};
let _ = app.run("view.stylesPaneDock", json!({"dock": dock}));
}
PaneRequest::Resize(Pane::Styles, width) => app.ui.styles_pane_width = Some(width),
PaneRequest::FloatRect(Pane::Styles, rect) => app.ui.styles_pane_float = Some(rect),
PaneRequest::Place(..) | PaneRequest::Resize(..) | PaneRequest::FloatRect(..) | PaneRequest::Focus(_) => {}
}
}
fn close(app: &mut WordApp, pane: Pane) {
match pane.close_command() {
Some(id) => {
let _ = app.run(id, json!({"value": false}));
}
None => app.restrict.open = false,
}
}
fn nav(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Navigation") {
let _ = app.run("view.navigationPane", json!({"value": false}));
return;
}
let qid = egui::Id::new("nav_query");
let mut q = ui.data(|d| d.get_temp::<String>(qid)).unwrap_or_default();
// Ctrl+F with text selected searches for it, as Word does (a selection within one line).
@@ -234,10 +316,6 @@ fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool) -> egui::Resp
/// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret.
fn clipboard(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Clipboard") {
let _ = app.run("edit.clipboardPane", json!({"value": false}));
return;
}
let items: Vec<String> = app.session.clip_history.items().iter().map(|i| i.preview()).collect();
ui.horizontal(|ui| {
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() {
@@ -280,10 +358,6 @@ fn clipboard(app: &mut WordApp, ui: &mut Ui) {
}
fn styles(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Styles") {
let _ = app.run("view.stylesPane", json!({"value": false}));
return;
}
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
let cur = st.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string();
let current_name = app
@@ -294,7 +368,8 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
.map(crate::i18n::style_name)
.unwrap_or_else(|| st.get("styleName").and_then(Value::as_str).unwrap_or("").to_string());
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Current style: {style}"), &[("style", &current_name)])).small());
ui.horizontal(|ui| {
// Wrapped: the host gives the pane a fixed width, and the row is wider than its default.
ui.horizontal_wrapped(|ui| {
if ui.button(tl!("New Style…")).clicked() {
app.dialog = crate::dialogs::Dialog::open("newStyle", app);
}
@@ -355,10 +430,6 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
}
fn comments(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Comments") {
let _ = app.run("view.commentsPane", json!({"value": false}));
return;
}
if crate::widgets::icon_text_button(ui, "newComment", tl!("New Comment"), regular(12.5)).clicked() {
let _ = app.run("review.newComment", json!({"text": ""}));
}
@@ -590,10 +661,6 @@ pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> {
/// Style Inspector: the paragraph and character levels at the caret, each with its style and the
/// direct formatting on top, and a button to clear each level.
fn inspector(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Style Inspector") {
let _ = app.run("styles.inspector", json!({"value": false}));
return;
}
// Read-only, so call it directly: running the command every frame would pay for the command
// machinery and consume per-command state (a pending `join_next_undo`) just to draw the pane.
let r = wordcraft_engine::cmd::inspector::inspect(&app.session);