diff --git a/apps/quickdeck/README.md b/apps/quickdeck/README.md index 00cbd734..f40223b4 100644 --- a/apps/quickdeck/README.md +++ b/apps/quickdeck/README.md @@ -52,7 +52,7 @@ inspector opens over the canvas from an Inspector button in the toolbar | Editor | The navigator, the canvas and the inspector. Tap to choose (`deck.hit`), a long press adds to the choice, tap a chosen text box again (or double-tap) to type in it, drag to move, a handle to resize or rotate. Insert strips for text, pictures (from images/ or this device), shapes (the engine's gallery), tables and charts. A chosen video or sound plays from its control | `deck.slides`, `deck.slide`, `deck.view`, `deck.hit`, `deck.set_text`, `deck.format_state`, `deck.format`, `deck.insert`, `deck.shape`, `deck.picture`, `deck.table`, `deck.chart`, `deck.layouts`, `deck.slides_op`, `deck.design`, `deck.transition`, `deck.notes`, `deck.fonts`, `deck.shapes`, `deck.media` | | Sorter | Every slide as a tile in its sections, with its number and transition: choose slides, New slide, Duplicate, Delete, Hide or Show, Move earlier and later, drag a tile to move it, add, rename and remove sections | `deck.slides_op`, `deck.view` | | New from an outline | A card per slide (a title, then its points, one per line), a theme and a slide size, Make deck. Draft with AI asks the person's own provider for the cards (`model.complete`, fast class); without a provider it says to set one up in Settings → AI providers, and typing the outline works either way | `deck.create`; the host's `model.complete` | -| Present | The slide alone on a dark stage inside the app's window, a small bar (previous, "4 / 12", next, Notes, the time, Exit) and an Exit in the corner; each build step shows its end state, and nothing animates. Notes opens a strip with the slide's notes. A clip set to play by itself starts when its slide shows, a tap on a clip plays or pauses it, and another slide or Exit stops it | `deck.show`, `deck.view {step}`, `deck.slide`, `deck.media` | +| Present | The slide alone on a dark stage that takes the whole screen: the Present tap asks the shell to put the window in full screen (`window.fullscreen`), and where the host can't (card-host, a phone), the stage fills the app's window. A small bar (previous, "4 / 12", next, Notes, the time, Exit) and an Exit in the corner; Esc, the shell's key in full screen, also ends the show, and the window comes back as it was. Each build step shows its end state, and nothing animates; a slide drawn for the smaller window is drawn again for the full screen. Notes opens a strip with the slide's notes. A clip set to play by itself starts when its slide shows, a tap on a clip plays or pauses it, and another slide or Exit stops it | `deck.show`, `deck.view {step}`, `deck.slide`, `deck.media`; the shell's `window.fullscreen` | | Export | Presentation (.pptx), PDF (slides, notes pages or handouts, 1 to 9 per page) or Images (one PNG a slide); all slides, the current one or a range; hidden slides in or out; a name. The deck's earlier exports are listed and can be removed | `deck.export` | Undo and Redo use the engine's history; Save writes the native `.deckcraft` diff --git a/apps/quickdeck/README.zh-CN.md b/apps/quickdeck/README.zh-CN.md index 99929d5f..c5594f73 100644 --- a/apps/quickdeck/README.zh-CN.md +++ b/apps/quickdeck/README.zh-CN.md @@ -29,7 +29,7 @@ Quick Deck(`os.quickdeck`)是 OctoSense 桌面版的演示文稿应用:演 | 编辑器 | 导航栏、画布和检查器。点按选中(`deck.hit`),长按加入或移出选择,再点一次已选中的文本框(或双击)即可在其中输入,拖动可移动,拖动控制点可调整大小或旋转。插入条用于文本、图片(来自 images/ 或本设备)、形状(引擎的形状库)、表格和图表。选中的视频或音频可用它的控件播放 | `deck.slides`、`deck.slide`、`deck.view`、`deck.hit`、`deck.set_text`、`deck.format_state`、`deck.format`、`deck.insert`、`deck.shape`、`deck.picture`、`deck.table`、`deck.chart`、`deck.layouts`、`deck.slides_op`、`deck.design`、`deck.transition`、`deck.notes`、`deck.fonts`、`deck.shapes`、`deck.media` | | 浏览视图 | 每张幻灯片以磁贴显示在所属分节中,带编号和切换效果:选择幻灯片、New slide、Duplicate、Delete、Hide 或 Show、前移和后移、拖动磁贴移动,添加、重命名和删除分节 | `deck.slides_op`、`deck.view` | | 从大纲新建 | 每张幻灯片一张卡片(一个标题,下面每行一个要点)、一个主题和一种幻灯片尺寸,然后 Make deck。Draft with AI 请用户自己的提供方起草卡片(`model.complete`,fast 档);没有提供方时提示到 Settings → AI providers 设置,手动输入大纲始终可用 | `deck.create`;宿主的 `model.complete` | -| 放映 | 幻灯片单独显示在应用窗口内的深色舞台上,下方一个小控制条(上一张、"4 / 12"、下一张、Notes、计时、Exit),角落还有一个 Exit;每个动画步骤显示其最终状态,不播放任何动画。Notes 打开显示本张备注的条带。设为自动播放的媒体剪辑在其幻灯片出现时开始播放,点按剪辑可播放或暂停,切换到其他幻灯片或 Exit 时停止 | `deck.show`、`deck.view {step}`、`deck.slide`、`deck.media` | +| 放映 | 幻灯片单独显示在占满整个屏幕的深色舞台上:点按 Present 时会请 Shell 让窗口进入全屏(`window.fullscreen`);宿主做不到时(card-host、手机),舞台占满应用窗口。下方一个小控制条(上一张、"4 / 12"、下一张、Notes、计时、Exit),角落还有一个 Exit;全屏时 Esc 是 Shell 的按键,也会结束放映,窗口恢复原样。每个动画步骤显示其最终状态,不播放任何动画;为较小窗口渲染的幻灯片会按全屏尺寸重新渲染。Notes 打开显示本张备注的条带。设为自动播放的媒体剪辑在其幻灯片出现时开始播放,点按剪辑可播放或暂停,切换到其他幻灯片或 Exit 时停止 | `deck.show`、`deck.view {step}`、`deck.slide`、`deck.media`;Shell 的 `window.fullscreen` | | 导出 | Presentation(.pptx)、PDF(幻灯片、备注页或讲义,每页 1 到 9 张)或 Images(每张幻灯片一个 PNG);全部幻灯片、当前幻灯片或一个范围;是否包含隐藏的幻灯片;名称。列出该演示文稿以前的导出,并可删除 | `deck.export` | Undo 和 Redo 使用引擎的历史记录;Save 写入原生 `.deckcraft` 文件(`deck.undo`、`deck.redo`、`deck.save`、`deck.state`)。关闭有未保存更改的标签时会询问:保存、不保存或取消。 diff --git a/apps/quickdeck/bundle/main.splash b/apps/quickdeck/bundle/main.splash index 928d0b2f..ae1f740a 100644 --- a/apps/quickdeck/bundle/main.splash +++ b/apps/quickdeck/bundle/main.splash @@ -1092,6 +1092,7 @@ if st == nil { heartbeat: nil // the interval below, made once index_dirty: false // library.json waits for the heartbeat present: nil // the show going on + screen_full: false // the shell has the window in full screen for Present (on_app_fullscreen) next_key: 1 insp_over: false // a narrow window shows the inspector over the canvas clip: nil // the clip playing or getting ready (playback, below) @@ -3156,14 +3157,21 @@ fn start_present(t){ return } // media: each slide's clips (deck.slide); autoplayed: the slide on - // show has started its clip by itself (playback). - st.present = {key: t.key order: order i: 0 step: 0 notes: st.show_notes started: time_now() pics: {} failed: {} full: {} timer: nil done: false + // show has started its clip by itself (playback); px: the width each + // picture was drawn at, so a bigger stage draws it again. + st.present = {key: t.key order: order i: 0 step: 0 notes: st.show_notes started: time_now() pics: {} px: {} failed: {} full: {} timer: nil done: false media: {} autoplayed: {}} st.present.timer = start_interval(1, || present_tick()) // The stage shows the slide alone: an earlier note goes. note("") show() pump() + // The Present tap also asks the shell for the whole screen + // (window.fullscreen), within the 5 seconds a press counts for. + // Where it can't (a host with no window service, such as card-host, + // or a refusal), the show fills the window as before, and the answer + // is not shown. + host.request("window.fullscreen", {on: true}, fn(r){}) }) } fn present_tab(){ @@ -3190,7 +3198,10 @@ fn present_next(){ if p.step >= optional(item, "steps", 0) && p.i + 1 < p.order.len() { want.push({item: p.order[p.i + 1] step: 0}) } for i w in want { let k = present_key(w.item, w.step) - if optional(p.pics, k, nil) == nil && optional(p.failed, k, nil) == nil { + // A picture drawn for a smaller stage (before the window went full + // screen) is drawn again; it shows, stretched, until then. + let stale = optional(p.pics, k, nil) == nil || optional(p.px, k, 0) != stage_px() + if stale && optional(p.failed, k, nil) == nil { present_render(t, w.item, w.step) return true } @@ -3231,13 +3242,17 @@ fn present_notes(t, item){ fn present_render(t, item, step){ let p = st.present let k = present_key(item, step) + let px = stage_px() busy_on() - engine("view", {doc: t.doc slide: item.slide width: stage_px() step: step}, fn(a){ + engine("view", {doc: t.doc slide: item.slide width: px step: step}, fn(a){ st.busy = false if st.present != p { return } let src = nil if a.is_ok { src = read_png(optional(a.data, "path", "")) } - if src != nil { p.pics[k] = src } else { p.failed[k] = true } + if src != nil { + p.pics[k] = src + p.px[k] = px + } else { p.failed[k] = true } let now = present_item() if now != nil && present_key(now, p.step) == k { render_present() } later_pump() @@ -3304,6 +3319,21 @@ fn stop_present(){ if t != nil && item != nil { go_slide(t, item.slide) } pump() } +// The shell's word that the window's full screen started or ended: when Esc, +// ⌘F or another window ends it, Present ends too. One that starts after +// Present already ended goes straight back. +fn on_app_fullscreen(on){ + st.screen_full = on + if on { screen_back() } else { stop_present() } +} +// The full screen Present asked for goes back once Present is over, however +// it ended: Exit, the corner control or the end of the show (show() asks +// after every change). +fn screen_back(){ + if !optional(st, "screen_full", false) || st.present != nil { return } + st.screen_full = false + host.request("window.fullscreen", {on: false}, fn(r){}) +} // ---- playback ------------------------------------------------------------------- // One clip plays at a time: in the editor, the chosen clip, from its control @@ -4004,6 +4034,7 @@ fn show_regions(){ fn show(){ // Another tab, view or screen stops a clip (playback). clip_check() + screen_back() let t = cur_tab() let presenting = st.present != nil let deck = !presenting && t != nil && t.kind == "deck" diff --git a/apps/quickdeck/tests/ui.py b/apps/quickdeck/tests/ui.py index a3c4d226..d87f1a32 100644 --- a/apps/quickdeck/tests/ui.py +++ b/apps/quickdeck/tests/ui.py @@ -39,7 +39,9 @@ shell a deck made from an outline, then edited: text in place, a format, a chart inserted and its data changed, a layout, a theme, the sorter (duplicate, hide, a section), notes, undo and redo, save, export - (.pptx, PDF and pictures), Present and its notes, light, then dark + (.pptx, PDF and pictures), Present and its notes (the show takes + the window full screen, window.fullscreen, and gives it back: by + Exit in the light pass, by Esc in the dark one), light, then dark through the shell's own style menu; in the light pass the inspector's states follow real changes and their undo: a box's anchor and autofit, a chart element, a table option, the colour set @@ -53,8 +55,9 @@ (.cache/media/., checked against the clip's own bytes) and the Video widget plays it where the system has a player (a Mac: two grabs a second apart differ) or says "Playback isn't - available on this system" (Linux), in the editor and in Present, - light, then dark; a closed deck's clips leave the cache. On a Mac + available on this system" (Linux), in the editor and in Present + (which takes the window full screen, the clip playing on through + it), light, then dark; a closed deck's clips leave the cache. On a Mac it never plays the sound (the runs open no audio device) Shell runs hold a lock folder, so one hidden shell runs on this machine at a @@ -454,6 +457,25 @@ def wait_until(check, failure, timeout=30.0): raise AssertionError(failure) +def window_rect(app): + """The whole window, in points: what a full-screen window covers.""" + w, h = app.size() + return [0, 0, w, h] + + +def same_rect(a, b): + return len(a) == len(b) and all(abs(x - y) < 1.5 for x, y in zip(a, b)) + + +def full_screen(app): + """Wait until the app covers the whole window: Present's tap asked the + shell for full screen (window.fullscreen). A hidden shell never asks the + host window itself, so the window is the screen here.""" + wait_until(lambda: same_rect(app.area(), window_rect(app)), + f"the app did not go full screen: {app.area()} in {window_rect(app)}") + return app.area() + + def slide_rect(app): """The canvas's slide picture: [x, y, w, h] in points (the largest picture right of the navigator and left of the inspector).""" @@ -927,6 +949,24 @@ def stage_rect(app): return max(imgs, key=lambda r: r[2] * r[3]) +def stage_settled(app, timeout=20.0): + """Present's slide picture once it stops moving: in a shell the show goes + full screen just after it starts, and the stage grows with the window.""" + deadline = time.monotonic() + timeout + last, same = None, 0 + while time.monotonic() < deadline: + try: + rect = stage_rect(app) + except AssertionError: + rect = None + same = same + 1 if rect is not None and rect == last else 0 + if same >= 3: + return rect + last = rect + time.sleep(0.4) + raise AssertionError(f"Present's slide never settled; last {last}") + + def box_on(rect, box): """A clip's box (slide points) on screen, in the slide picture `rect`.""" x, y, w, h = rect @@ -1141,11 +1181,13 @@ def shell_playback(app, out, dark, home): app.find("Paused.", "Label", timeout=10) app.tap("Stop") still_open(app, "the editor's playback") - # Present: the video plays by itself (a native deck keeps autoplay). + # Present: the video plays by itself (a native deck keeps autoplay), and + # keeps playing as the show takes the window full screen. app.tap("Present", "Button") app.find("Exit", "Button", timeout=60) + full_screen(app) app.images(1, timeout=60) - stage = box_on(stage_rect(app), LAUNCH_FILM) + stage = box_on(stage_settled(app), LAUNCH_FILM) if LINUX: wait_until(lambda: stage_note(app) == NO_PLAYER, "the show's clip never said it can't play", timeout=40) app.grab(out, f"{p}p2-present-unavailable") @@ -1189,10 +1231,11 @@ def shell_playback(app, out, dark, home): app.find("Starts when it's tapped.", "Label", timeout=30) app.tap("Present", "Button") app.find("Exit", "Button", timeout=60) + full_screen(app) app.images(1, timeout=60) time.sleep(2.0) assert stage_note(app) is None, f"a .pptx clip started by itself: {stage_note(app)}" - stage = box_on(stage_rect(app), LAUNCH_FILM) + stage = box_on(stage_settled(app), LAUNCH_FILM) app.click(stage[0] + stage[2] / 2, stage[1] + stage[3] / 2) if LINUX: wait_until(lambda: stage_note(app) == NO_PLAYER, "the tapped clip never said it can't play", timeout=40) @@ -1336,14 +1379,27 @@ def shell_journey(app, out, dark): app.grab(out, f"{p}08-export") app.scroll(-3000) app.tap("Export", "Button") - # present + # present: the tap takes the whole screen (window.fullscreen); the light + # pass leaves by Exit, the dark one by Esc, the shell's key in full + # screen (a script gets no keys), and the window comes back either way. go_to(app, 1) + windowed = app.area() app.tap("Present", "Button") app.find("Exit", "Button", timeout=60) + full = full_screen(app) app.images(1, timeout=60) app.grab(out, f"{p}09-present") app.click(app.area()[2] / 2, app.area()[3] / 2) - app.tap("Exit", "Button") + if dark: + app.key("Escape") + else: + app.tap("Exit", "Button") + app.find("Present", "Button", timeout=30) + app.gone("Exit") + wait_until(lambda: same_rect(app.area(), windowed), + f"the window did not come back after Present: {app.area()}, was {windowed}") + (out / f"{p}present-full-screen.json").write_text(json.dumps( + {"window": windowed, "full_screen": full, "after": app.area(), "left_by": "Escape" if dark else "Exit"}, indent=1)) def states_follow(app, out):