mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 02:57:01 -06:00
Merge PR #512 from NikitkaRa: Home: Replace Fonts dialog and font.replace command
# Conflicts: # crates/ui-egui/src/dialogs.rs # crates/ui-egui/src/lib.rs # crates/ui-egui/src/ribbon.rs
This commit is contained in:
@@ -13,6 +13,16 @@ use crate::{DocState, Result, Selection, Session, Target, TextSel};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used),
|
||||
cmd!(
|
||||
"font.replace",
|
||||
"Replace Fonts",
|
||||
["Home", "Editing", "Replace"],
|
||||
None,
|
||||
"{from, to} — every explicit use of family `from`, theme fonts included → {replaced}",
|
||||
has_doc,
|
||||
replace_font
|
||||
),
|
||||
cmd!(noundo "text.edit", "Edit Text", [], Some("Enter"), "{id?, at?: [paragraph, char], end?: bool, cell?: [row, col], notes?: bool}", has_slide, edit),
|
||||
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
|
||||
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
|
||||
@@ -964,3 +974,71 @@ pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn fonts_used(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut v = crate::fonts::used_fonts(&s.doc()?.doc);
|
||||
v.sort_by_key(|f| f.to_lowercase());
|
||||
Ok(json!(v))
|
||||
}
|
||||
|
||||
fn replace_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = str_param(p, "from").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `from`"))?.to_string();
|
||||
let to = str_param(p, "to").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `to`"))?.to_string();
|
||||
if to.len() > 256 || to.chars().any(char::is_control) {
|
||||
return Err(bad("font.replace", "`to` is not a font name"));
|
||||
}
|
||||
// Count on a copy first so a replace that finds nothing leaves no undo step.
|
||||
let mut probe = (*s.doc()?.doc).clone();
|
||||
if crate::fonts::replace_font(&mut probe, &from, &to) == 0 {
|
||||
return Ok(json!({"replaced": 0}));
|
||||
}
|
||||
let n = s.edit(|doc, _| Ok(crate::fonts::replace_font(doc, &from, &to)))?;
|
||||
let st = s.doc_mut()?;
|
||||
st.missing_fonts = crate::fonts::missing_fonts(&st.doc);
|
||||
Ok(json!({"replaced": n}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod replace_font_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn fonts(s: &mut Session) -> Vec<String> {
|
||||
serde_json::from_value(s.execute("font.used", &json!({})).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_runs_tables_notes_and_theme_and_undoes() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
|
||||
let theme = s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin.clone();
|
||||
s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "Hello"})).unwrap();
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap();
|
||||
assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"), "{:?}", fonts(&mut s));
|
||||
let r = s.execute("font.replace", &json!({"from": "comic neue", "to": "Lato"})).unwrap();
|
||||
assert!(r["replaced"].as_u64().unwrap() >= 1, "{r}");
|
||||
let used = fonts(&mut s);
|
||||
assert!(!used.iter().any(|f| f == "Comic Neue") && used.iter().any(|f| f == "Lato"), "{used:?}");
|
||||
// The theme's body font is replaced too, so placeholders follow.
|
||||
s.execute("font.replace", &json!({"from": theme, "to": "Lato"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin, "Lato");
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_replace_is_no_undo_step_and_bad_names_fail() {
|
||||
let mut s = Session::with_new();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("font.replace", &json!({"from": "Nope Sans", "to": "Inter"})).unwrap()["replaced"], 0);
|
||||
assert_eq!(s.execute("font.replace", &json!({"from": "+mn-lt", "to": "Inter"})).unwrap()["replaced"], 0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert!(s.execute("font.replace", &json!({"from": "", "to": "Inter"})).is_err());
|
||||
assert!(s.execute("font.replace", &json!({"from": "Inter", "to": "a\nb"})).is_err());
|
||||
assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// The families the slides' text runs and bullets are drawn in.
|
||||
fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||
pub fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut add = |f: String| {
|
||||
let f = f.trim().to_string();
|
||||
@@ -78,3 +78,91 @@ fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the font family `from` with `to` everywhere it is named explicitly: text runs (and
|
||||
/// their east Asian, complex script and symbol faces), bullet fonts, paragraph end marks, list
|
||||
/// styles of shapes, layouts and masters, the master text styles, the deck default style, speaker
|
||||
/// notes, tables and the theme fonts. Names compare case-insensitively; theme references
|
||||
/// (`+mn-lt`) are left alone. Returns how many names changed.
|
||||
pub fn replace_font(p: &mut Presentation, from: &str, to: &str) -> usize {
|
||||
use std::sync::Arc;
|
||||
let from = from.trim();
|
||||
let to = to.trim();
|
||||
if from.is_empty() || to.is_empty() || from.starts_with('+') || from == to {
|
||||
return 0;
|
||||
}
|
||||
let mut n = 0usize;
|
||||
let mut fix = |f: &mut Option<String>| {
|
||||
if f.as_deref().is_some_and(|x| x.trim().eq_ignore_ascii_case(from)) {
|
||||
*f = Some(to.to_string());
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
};
|
||||
replace_list(&mut p.default_text_style, &mut fix);
|
||||
for slide in &mut p.slides {
|
||||
let sl = Arc::make_mut(slide);
|
||||
replace_shapes(&mut sl.shapes, &mut fix);
|
||||
replace_body(&mut sl.notes, &mut fix);
|
||||
}
|
||||
let mut theme_hits = 0usize;
|
||||
let masters = p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut());
|
||||
for m in masters {
|
||||
let m = Arc::make_mut(m);
|
||||
replace_shapes(&mut m.shapes, &mut fix);
|
||||
for l in [&mut m.title_style, &mut m.body_style, &mut m.other_style] {
|
||||
replace_list(l, &mut fix);
|
||||
}
|
||||
for lay in &mut m.layouts {
|
||||
replace_shapes(&mut lay.shapes, &mut fix);
|
||||
}
|
||||
for set in [&mut m.theme.fonts.major, &mut m.theme.fonts.minor] {
|
||||
for face in [&mut set.latin, &mut set.ea, &mut set.cs].into_iter().chain(set.scripts.iter_mut().map(|s| &mut s.typeface)) {
|
||||
if face.trim().eq_ignore_ascii_case(from) {
|
||||
*face = to.to_string();
|
||||
theme_hits = theme_hits.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n.saturating_add(theme_hits)
|
||||
}
|
||||
|
||||
fn replace_shapes(shapes: &mut [Shape], fix: &mut dyn FnMut(&mut Option<String>)) {
|
||||
for s in shapes {
|
||||
if let Some(body) = &mut s.text {
|
||||
replace_body(body, fix);
|
||||
}
|
||||
if let ShapeKind::Table(t) = &mut s.kind {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| &mut r.cells) {
|
||||
replace_body(&mut cell.text, fix);
|
||||
}
|
||||
}
|
||||
if let Some(children) = s.children_mut() {
|
||||
replace_shapes(children, fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_body(body: &mut TextBody, fix: &mut dyn FnMut(&mut Option<String>)) {
|
||||
replace_list(&mut body.list_style, fix);
|
||||
for para in &mut body.paragraphs {
|
||||
fix(&mut para.props.bullet_font);
|
||||
replace_run(&mut para.end_props, fix);
|
||||
for run in &mut para.runs {
|
||||
replace_run(&mut run.props, fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_list(l: &mut deckcraft_model::text::ListStyle, fix: &mut dyn FnMut(&mut Option<String>)) {
|
||||
for lvl in l.levels.iter_mut().flatten() {
|
||||
fix(&mut lvl.para.bullet_font);
|
||||
replace_run(&mut lvl.run, fix);
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_run(r: &mut deckcraft_model::text::RunProps, fix: &mut dyn FnMut(&mut Option<String>)) {
|
||||
for f in [&mut r.font, &mut r.font_ea, &mut r.font_cs, &mut r.font_sym] {
|
||||
fix(f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ fn title(id: &str) -> &'static str {
|
||||
"export" => "Export",
|
||||
"symbol" => "Symbol",
|
||||
"photoAlbum" => "Photo Album",
|
||||
"replaceFonts" => "Replace Font",
|
||||
"equation" => "Equation",
|
||||
"spelling" => "Spelling",
|
||||
"pasteSpecial" => "Paste Special",
|
||||
@@ -1134,6 +1135,52 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"replaceFonts" => {
|
||||
let used: Vec<String> =
|
||||
app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default();
|
||||
let mut from = d.get("from", used.first().map(String::as_str).unwrap_or(""));
|
||||
let mut to = d.get("to", "");
|
||||
egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||||
ui.label("Replace:");
|
||||
egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| {
|
||||
for f in &used {
|
||||
ui.selectable_value(&mut from, f.clone(), f);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("With:");
|
||||
ui.vertical(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name"));
|
||||
let needle = to.trim().to_lowercase();
|
||||
egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| {
|
||||
for f in
|
||||
crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200)
|
||||
{
|
||||
if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() {
|
||||
to = f.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
if let Some(msg) = d.fields.get("result") {
|
||||
ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
d.fields.insert("from".into(), from.clone());
|
||||
d.fields.insert("to".into(), to.clone());
|
||||
let (ok, cancel) = buttons(ui, "Replace");
|
||||
if ok && !from.is_empty() && !to.trim().is_empty() {
|
||||
let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0);
|
||||
let msg =
|
||||
if n == 0 { format!("“{from}” isn't used explicitly.") } else { format!("Replaced “{from}” with “{}”.", to.trim()) };
|
||||
d.fields.insert("result".into(), msg);
|
||||
d.fields.remove("from");
|
||||
// Stay open, like PowerPoint, so several fonts can be replaced in a row.
|
||||
return cancel;
|
||||
}
|
||||
cancel
|
||||
}
|
||||
"symbol" => {
|
||||
ui.label("Click a symbol to insert it at the insertion point.");
|
||||
let groups: [(&str, &str); 6] = [
|
||||
|
||||
@@ -1224,6 +1224,8 @@ mod ime_tests;
|
||||
mod slide_size_tests;
|
||||
#[cfg(test)]
|
||||
mod ui_soak_tests;
|
||||
#[cfg(test)]
|
||||
mod replacefonts_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Home › Replace Fonts…: the dialog lists the deck's fonts and replaces one, as one undo step.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.textBox", &json!({"rect": [40, 40, 400, 80], "text": "Hello fonts"})).unwrap();
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap();
|
||||
s.execute("format.size", &json!({"size": 40})).unwrap();
|
||||
let app = SlideApp::new(s, Services::default());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn run_font(h: &Harness<'_, SlideApp>) -> Option<String> {
|
||||
let st = h.state().session.active().unwrap();
|
||||
st.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| s.text.as_ref())
|
||||
.and_then(|b| b.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.and_then(|r| r.props.font.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbon_button_opens_the_dialog_and_replaces() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Replace Fonts…").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("replaceFonts"));
|
||||
// Pick the font to replace and type the new one, the way a person would.
|
||||
if let Some(d) = h.state_mut().dialog.as_mut() {
|
||||
d.fields.insert("from".into(), "Comic Neue".into());
|
||||
d.fields.insert("to".into(), "Lato".into());
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Replace").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(run_font(&h).as_deref(), Some("Lato"));
|
||||
// The dialog stays open with a confirmation, like PowerPoint's.
|
||||
assert!(h.query_by_label_contains("Replaced “Comic Neue”").is_some());
|
||||
h.state_mut().session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(run_font(&h).as_deref(), Some("Comic Neue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "writes a screenshot for review"]
|
||||
fn screenshot() {
|
||||
let mut h = harness();
|
||||
h.render().unwrap().save("/tmp/replacefonts0.png").unwrap();
|
||||
h.state_mut().dialog = Some(crate::dialogs::Dialog::new("replaceFonts"));
|
||||
h.run_steps(4);
|
||||
h.render().unwrap().save("/tmp/replacefonts.png").unwrap();
|
||||
}
|
||||
@@ -1046,6 +1046,11 @@ fn home(app: &mut SlideApp, ui: &mut Ui) {
|
||||
group(ui, |ui| {
|
||||
// Also under Arrange, but that is disabled until something is selected (#132).
|
||||
big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"}));
|
||||
if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts"));
|
||||
}
|
||||
});
|
||||
group(ui, |ui| {
|
||||
big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele
|
||||
let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y));
|
||||
let main_resp =
|
||||
if split { ui.interact(main_rect, resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() };
|
||||
// Screen readers (and UI tests) find the button by its text.
|
||||
if !label.is_empty() {
|
||||
main_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
|
||||
}
|
||||
let arrow_resp = split.then(|| {
|
||||
ui.interact(
|
||||
Rect::from_min_max(pos2(rect.max.x - arrow_w, rect.min.y), rect.max),
|
||||
|
||||
@@ -312,6 +312,8 @@ Detail in [ui-parity.md](ui-parity.md).
|
||||
It is not a saved library of theme fonts, and it does not pick East Asian or complex-script
|
||||
faces. Still missing: Font, Bullets and Numbering, Find/Replace dialog, Replace Fonts, Action
|
||||
Settings, Print, Insert Object. 8–14 h.
|
||||
- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog,
|
||||
Action Settings, Print, Insert Object…). 8–14 h.
|
||||
- **Command palette defaults to an Export entry that fails** (#79). 0.5 h.
|
||||
|
||||
## Hardware
|
||||
|
||||
@@ -85,6 +85,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Photo Album, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Photo Album (Edit Photo Album reopens this dialog), Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages, Create New Theme Colors |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages, Create New Theme Fonts |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Replace Fonts, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
|
||||
## Direct manipulation
|
||||
|
||||
@@ -163,6 +164,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
|
||||
| Format Shape pane depth | 6–10 |
|
||||
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object; Create New Theme Colors is in) | 8–14 |
|
||||
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object; Create New Theme Fonts is in) | 8–14 |
|
||||
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Action Settings, Print, Insert Object) | 8–14 |
|
||||
| Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 |
|
||||
| Grid Options, edit points UI, crop depth | 4–7 |
|
||||
| Arrange All (New Window is one extra window) | 2–3 |
|
||||
|
||||
Reference in New Issue
Block a user