Parent topic doc: Enabling NumPy & Python in LibreOffice — venv bridge, =PY(), shared kernel, competitive context, and user-facing Monaco summary (§3 Monaco).
Architectural design for the LibrePythonista-style Monaco editor in WriterAgent.
Status (2026-06): Phase 2A + Phase 3 + 2E shipped. Calc Edit Python in Cell… (menubar + cell right-click) and Run Python Script… Monaco both use a persistent pywebview child in the user venv. Dual save modes, editable Data: range textbox, document-attached scripts, init script editor, save feedback, WM-close lifecycle, stderr drain, full-traceback failure dialogs, and automatic LibreOffice theme sync (Monaco + toolbar chrome) are implemented. Open: Phase 2B syntax validate, 2C range picker, 2D Jedi (stub only today), 2F Flatpak spawn, sheet-level Python cell list. See plugin/scripting/, plugin/calc/python/editor.py, and plugin/framework/appearance.py.
=PY() is not localized: The Calc add-in always registers the English function name PYTHON (programmatic python). Formulas stored by Calc must use that token in getFormula() / FormulaLocal. A localized alias (e.g. a translated function name) is a bug in add-in registration — do not add FormulaOpCodeMapper workarounds in the editor or formula parser.
Session 1 fixes (post-MVP): venv path required (no LibreOffice embedded Python for the editor); resolve_venv_python tries bin/python, bin/python3, and bin/python3.*; ready is sent only after window.events.loaded / shown (not before webview.start()); child uses a WSGI static server in editor_main.py that serves all editor HTML/JS/CSS and Monaco vs/ assets from packages installed in the configured venv (rocher); probe/save failures show child stderr + Python tracebacks via editor_ipc.py.
Dual save modes: Monaco always edits stripped Python source (inline =PY("…") code is parsed on load; code-only cells use getString). Toolbar checkbox Save without =PY() (save_as_plain) writes cell.setString(code) only — for code-in-a-cell workflows where another cell runs =PY($A$1; …). Default Save wraps =PY("…") / =PY("…") via setFormula, preserving existing data-range suffixes; the Data: textbox is disabled while Save without =PY() is checked. Plain save shows status Saved without =PY(). Opening =PY($A$1; …) on the formula cell remains blocked; edit the code storage cell instead.
The editor is a separate native window in the user's configured Python venv. It talks to LibreOffice over stdin/stdout (length-prefixed pickle protocol 5), not TCP sockets.
| Component | Module | Role |
|---|---|---|
| Editor launcher | plugin/scripting/editor_host.py |
Require Settings venv, probe import webview, Popen child |
| Editor diagnostics | plugin/scripting/editor_ipc.py |
Msgbox text: stderr + traceback.format_exception |
| Editor bridge | plugin/scripting/editor_host.py |
Pipe reader thread; UNO on main thread via QueueExecutor |
| Editor process | plugin/scripting/editor_main.py |
pywebview + Monaco (venv only) |
| Calc integration | plugin/calc/python/editor.py, python_formula_edit.py, python_editor_context_menu.py |
Active cell =PY() load/save; cell context menu |
| Protocol | plugin/scripting/editor_ipc.py |
!I length + pickle protocol 5 |
| Frontend (runtime) | rocher in configured venv |
index.html, editor.js, scripts_manager.js, style.css, Monaco vs/ — served by child WSGI (editor_main.py); not bundled in the OXT |
| Frontend (dev source) | plugin/contrib/scripting/assets/editor/ |
In-repo copies of WriterAgent shell files (edit here; installed/served via venv rocher) |
Menu: org.extension.writeragent:scripting.edit_python_cell in extension/Addons.xcu (Calc menubar). Cell right-click uses python_editor_context_menu.py (XContextMenuInterceptor — LibreOffice has no static Addons.xcu merge point for Calc cell popups).
Same framing as worker_harness.py (struct.pack("!I", …) + pickle protocol 5).
type |
Direction | Purpose |
|---|---|---|
ready |
child → LO | GUI up (window.events.loaded or shown); safe to send load |
load |
LO → child | Initial code (stripped Python only—never =PY()), optional title, data_binding, plain_text_label (checkbox label; Calc default Save without =PY()), optional save_as_plain (checkbox state: off for inline =PY(), on for code-only cells, off when empty), ui (localized toolbar/status — see Localization) |
save |
child → LO | User saved; includes code, optional save_as_plain (default false), optional data_binding (range text for formula suffix; ignored when save_as_plain) |
saved / error |
LO → child | Apply result in UI; saved may include save_as_plain and status_ok_text (e.g. Saved without =PY().) |
closed / cancel |
either | Tear down session |
- Pipe reader:
run_in_background(editor-pipe-reader). - Never call UNO from the reader thread. Use
execute_on_main_threadforsave(setFormula,calculateAll). - MCP uses the same
QueueExecutor/AsyncCallbackpattern.
- GUI thread:
js_api(notify_save,notify_cancel,poll_messages). - Pipe thread: read stdin only; push
load/saved/errorto_ui_queue. - Never call
evaluate_js()from the pipe thread (GTK/WebView2 deadlock). JS pollspoll_messages()~80ms and updates Monaco.
- Settings → Python →
scripting.python_venv_path: must point at the venv where you runuv pip install pywebview. The Monaco editor does not use LibreOffice’s embedded Python. pywebviewand GUI backends in that venv. For robust cross-platform support (especially in isolated venvs on Linux/Python 3.14), the following stack is verified:uv pip install pywebview PyQt6 PyQt6-WebEngine qtpy
- Why:
pywebviewrequires a GUI driver. While it can use system GTK, a venv often cannot see system bindings.PyQt6+WebEngineprovides a self-contained Chromium-based browser engine.qtpyis a mandatory shim for thepywebviewQt driver.
- Why:
- Linux GUI: child inherits
DISPLAY,WAYLAND_DISPLAY,XDG_RUNTIME_DIR,DBUS_SESSION_BUS_ADDRESS,LD_LIBRARY_PATHfrom the LO process. OptionalWRITERAGENT_PYWEBVIEW_GUI=qt|gtkforeditor_main.py. - Monaco / editor UI assets: all static files (
index.html,editor.js,scripts_manager.js,style.css, and Monacovs/) are served from therocherpackage in the configured venv. The OXT does not bundle editor HTML/JS/CSS or Monaco assets. Runuv pip install pywebview rocherin the configured venv. jedi(session 2+): optional, persistentEnvironmentin child — see below.
Note: Run Python Script… opens Monaco when the configured venv has pywebview (Python syntax highlighting, Run button, editor stays open after execution). If pywebview is unavailable, it falls back to the native multiline dialog in python_runner.py. That legacy dialog is modeless by default (scripting.native_run_script_modeless in writeragent.json; set false for the classic modal dialog). To force the native LO dialog instead of Monaco, set scripting.force_internal_script_editor to true in writeragent.json (default false). Calc Edit Python in Cell… has no native fallback: when that flag is set it shows a msgbox explaining that Monaco is required; when pywebview is missing it explains how to fix the configured venv.
The Monaco shell (toolbar, status line, script picker, prompt()/confirm() text) is localized via GNU gettext on the LibreOffice host — same domain as the rest of WriterAgent (writeragent).
- Catalog:
plugin/scripting/editor_ui_strings.py— all user-visible Englishmsgids wrapped in_(). - Injection:
launch_monaco_editor()callsenrich_monaco_load_message()on every outboundload(including multi-cell reload), attaching auidict of pre-translated strings alongsidetheme. - Frontend:
editor.jsandscripts_manager.jsreadload.uiviawindow.waEditorUi.t()/fmt()— no gettext in JS. - Parity: Toolbar labels reuse the same msgids as
PythonScriptDialog.xdlandpython_runner_ui.pywhere wording matches. - Calc cell save modes:
plain_text_label→ Save without =PY(); plain-save status →ui.saved_plain/ Saved without =PY(). (LaTeX mode reuses the IPC keyplain_text_labelfor a different checkbox — seeeditor_ui_strings.py.)
Out of scope: Monaco’s built-in editor UI (Find/Replace, internal context menus) — that would require monaco-nls locale bundles.
| Feature | Notes |
|---|---|
| Syntax validation | Debounced compile() on LO main thread; squiggles in Monaco |
| Range picker | GlobalCalcRangeSelector via pipe + main thread (deferred; editable textbox for data ranges shipped first) |
| Theme sync | LO VCL → vs / vs-dark + toolbar chrome (shipped in 2E via appearance.py + editor.js) |
| Flatpak/Snap spawn | flatpak-spawn --host |
| Formula bar button | Optional |
| Jedi completions | Stub shipped in editor_jedi.py + get_completions in JS; Phase 2D adds debounce, background thread, Settings hint |
- Run Jedi only in the editor child, not over the pipe on every key.
- One
jedi.create_environment(venv_path)per window lifetime. - Target: sub-10ms after warm-up.
plugin/
├── calc/
│ ├── python_editor.py # Menu entry, launch bridge
│ ├── python_editor_context_menu.py # Calc cell right-click entry
│ └── python_formula_edit.py # Parse/rebuild =PY() formulas
├── contrib/
│ └── scripting/
│ └── assets/editor/ # Dev source only (runtime: venv rocher)
│ ├── index.html
│ ├── editor.js
│ ├── scripts_manager.js # Run Python Script picker (Sample + My Scripts)
│ └── style.css
└── scripting/
├── document_scripts.py # scripts_list IPC + document-attached scripts
├── editor_host.py # Spawn, PersistentEditor, session launch
├── editor_ipc.py # Pickle protocol 5 + failure formatting
└── editor_main.py # pywebview child entry (+ JediSession)
tests/
├── calc/
│ ├── test_python_formula_edit.py
│ ├── test_python_editor_save_modes.py
│ ├── test_python_editor_multi_cell.py
│ └── test_python_editor_context_menu.py
└── scripting/
├── test_editor_host.py
├── test_editor_ipc.py
├── test_editor_main_closed.py
└── test_editor_jedi.py
- Create or pick a venv;
uv pip install pywebview(and any packages your=PY()code needs). - WriterAgent Settings → Python: set Python venv path to that directory (not merely “venv active” in a terminal).
make deploy, restart LibreOffice, open Calc.- Select any cell (empty or
=PY("result = 1")). - WriterAgent → Edit Python in Cell… — Monaco window should open.
- Edit, Save — cell should become/update
=PY("…")and recalc; toolbar status line shows green Status: Saved. and stays until the next action. - Close the editor with the window X (not Cancel), reopen immediately — should not show “already open.”
- Edit cell A, Save, select cell B, run Edit Python in Cell… again — editor reloads B’s code (no blocking dialog).
- Right-click a cell — Edit Python in Cell… should appear at the bottom of the cell context menu.
- On save error (if reproducible), toolbar shows red error text and the editor stays open.
- Run Python Script… (Writer/Calc/Draw): with venv + pywebview, opens Monaco with colored Python, Run / Save / Close buttons (no Data: / Save without =PY() controls). Run executes and inserts result; Save persists script to config only; Close hides the editor. Without pywebview, the plain multiline dialog appears (no error msgbox).
- Run Python Script… script picker: save scratchpad content via Save while Sample is selected; switch to a My Scripts entry — editor changes; switch back to Sample — scratchpad content must reload (not a no-op). Delete on Sample clears the scratchpad.
- Theme follows LO: Change LibreOffice appearance (Tools ▸ Options ▸ LibreOffice ▸ Appearance or system dark mode with LO on "System"). Re-open editor (cell or Run Python Script). Toolbar must use matching dark/light colors; Monaco must use
vs-darkvsvs; no white-on-white or black-on-black. Switching between cells re-applies current theme. Check both light and dark. - Status copyable: After Run (Monaco) or Run in the native fallback dialog, the status line text must be selectable and copyable (drag-select or Ctrl+A, then Ctrl+C). Paste into another app to confirm.
If it fails: the msgbox should include child stderr and a Python traceback. Also check writeragent_debug.log under the LO user profile (writeragent.json directory). Common causes: wrong venv path in Settings, pywebview not installed in that venv, or missing display/GTK backend on Linux.
Session 1 proves the pipe + subprocess + Monaco spine. The work below is ordered by user-visible value per risk, not by file layout. Each phase should ship with tests and a short manual checklist before piling on the next.
Goal: Make the existing flow feel finished, not prototype.
| Task | Detail | Status |
|---|---|---|
| Window lifecycle | Wire window.events.closed (pywebview) to send closed; bridge clears session when child exits. |
Done |
| Save feedback | Green status on saved; red status on error with message; editor stays open. Status line is always visible (Status: Ready initially; last message persists). |
Done |
| Context menu | Calc cell right-click via python_editor_context_menu.py (XContextMenuInterceptor; same dispatch URL as menubar). |
Done |
| stderr logging | Continuous stderr drain thread in editor_bridge.py (editor-stderr-drain); lines logged at debug; tail kept for failure msgboxes. |
Done |
| Multi-cell reload | Editor open on cell A → Save → select B → menu again sends fresh load (callbacks retargeted; Save without =PY() / save_as_plain reflects whether B is inline =PY() vs code-only). |
Done |
Protocol: no new message types required.
Tests: tests/scripting/test_editor_main_closed.py; optional UNO smoke “open editor on fixture sheet” only if stable in CI.
Goal: Red squiggles for invalid Python before Save, without blocking the GUI thread in the child.
Flow:
- Monaco
onDidChangeModelContentdebounced (~400ms) in JS. - Child sends
{"type": "validate", "code": "…"}on stdin (GUI thread is fine for writes). - LO pipe reader receives
validate→execute_on_main_thread(_compile_check, code). _compile_checkusescompile(code, "<editor>", "exec"); onSyntaxError, return{type: "validate_result", ok: false, line, col, message}; else{ok: true}.- LO writes result to child stdin; pipe thread →
_ui_queue→poll_messages→ Monacoeditor.setModelMarkersor deltaDecorations.
Why LO for compile: matches what Calc will eventually run in the venv worker; catches nothing Jedi would miss for syntax-level errors. Keep validation syntax-only (no imports execution).
Protocol additions:
type |
Direction |
|---|---|
validate |
child → LO |
validate_result |
LO → child |
Pitfall: flooding LO with validate while user types fast — debounce in JS and drop stale responses (sequence id in message).
Tests: pure-Python tests for _compile_check; no LO required.
Goal: “Insert range” inserts Sheet1.A1:B10 (or active sheet shorthand) at the cursor — WriterAgent style, not LibrePythonista’s lp("…") unless we add a helper later.
Flow:
- Toolbar button in
editor.js→api.request_range(). - Child writes
{"type": "pick_range"}. - LO main thread:
GlobalCalcRangeSelector(LibrePythonista-style modal range pick; merged-cell geometry viaget_cell_geometry), format range withaddress_utils, reply{"type": "range_result", "text": "A1:B10"}or cancel. - Child queues
range_result→ JS inserts ateditor.getPosition().
Threading: selector and all UNO on main thread only; child blocks in API method with threading.Event until result (same pattern as MCP execute_on_main_thread with timeout). Cap wait at 120s.
UX: While picker is open, Monaco can stay open behind LO modal — document z-order quirk on Wayland.
Tests: @native_test in tests/calc/test_python_editor_uno.py — open Calc, stub or real selector if automatable; at minimum test range formatting helpers.
Goal: IntelliSense that feels instant after warm-up.
New module: plugin/scripting/editor_main.py — imported only from editor_main.py (not from LO).
EditorSession.start()
→ JediSession.create(venv_python_path) # once
on debounced complete (child GUI thread pool or worker thread)
→ Script(full_source, environment=env).complete(line, col)
→ format Completion items for Monaco
→ ui_queue.put({type: "completions", items: [...]})
Rules (non-negotiable):
- One
jedi.create_environmentper editor window. - Never recreate
Environmentper keystroke. jedi.Scriptper request is OK; environment reuse is what buys sub-10ms.- Do not RPC every keypress to LO for Jedi.
Monaco: register monaco.languages.registerCompletionItemProvider('python', …) in editor.js; provider calls exposed api.complete(line, column, fullText) which runs Jedi on a background thread in the child (not pipe thread) and returns when done — still must not touch evaluate_js off-thread; return value via pywebview expose is synchronous from JS’s view if we block expose (acceptable for 150ms debounce) or use async expose pattern if pywebview version supports it.
Optional Phase 2D+: LO sends {"type": "calc_symbols", "names": ["Sheet1", …]} once on load for sheet/tab completion — only if Jedi alone is insufficient.
Deps: document pip install jedi next to pywebview in Settings helper text.
Tests: unit tests with fixed source snippets and mocked Environment if needed; manual benchmark log for cold vs warm complete latency.
Shipped (2026-06). See implementation in plugin/framework/appearance.py, editor_host.py, editor.js, style.css, and tests/framework/test_appearance.py. The detailed design below is retained as the historical spec.
Implemented:
- Central
get_monaco_theme_info()+get_style_window()(shared with chatget_theme_colorsfor debt reduction). - Theme payload injected on every
load(cell switch / reopen / Run Python Script) → fresh detection from active window'sStyleSettings. monaco.editor.setTheme()+body.dark/body.light+ matching CSS for toolbar chrome.- Unit tests + updated host tests. Works for both editor surfaces and "no document" Run Python Script case.
Goal (original): The editor (Monaco code area + native toolbar chrome in the pywebview window) automatically adopts the active LibreOffice light/dark appearance (and ideally surface colors) with no manual toggle, no separate setting, and no hard-coded assumption. It derives the theme at open time from the running LO instance and can be extended to follow live changes.
Current state: editor.js:377 hard-codes theme: "vs". style.css hard-codes light grays (#ffffff, #f3f3f3, #333). No theme key is sent in any load message from python_editor.py or python_runner.py. Existing heuristic lives only in chat UI.
Success criteria
- Open editor while LO is Light (or System + light desktop) → toolbar light, Monaco
vs, readable text. - Open (or switch cell) while LO is Dark → toolbar dark, Monaco
vs-dark. - Same behavior for Edit Python in Cell… and Run Python Script… (and any future init-script editor).
- Colors derived live from the active document/frame's window (no global singleton assumption).
- CSS chrome (toolbar, status, inputs, script picker, buttons) updates for contrast.
- Unit tests cover the detector with mocked
StyleSettings. - Manual checklist passes on at least one Linux + one Windows build.
- No new user-facing "Editor theme" combobox.
Create or centralize a detector. To reduce tech debt, extract the luminance logic from plugin/chatbot/rich_text.py:get_theme_colors into a shared place (recommended: plugin/framework/appearance.py or plugin/scripting/editor_theme.py for narrow scope).
Core extraction:
def get_lo_style_window(doc=None, style_window=None, ctx=None):
"""Return a window that has .StyleSettings (or None)."""
...
def get_lo_theme_info(*, doc=None, style_window=None, ctx=None) -> dict[str, Any]:
"""Return dict suitable for IPC:
{
"monaco": "vs" | "vs-dark",
"is_dark": bool,
# Optional richer palette (hex strings or 0xRRGGBB ints)
"bg": 0x1e1e1e,
"fg": 0xd4d4d4,
"toolbar_bg": ...,
"accent": ...,
}
"""
# Use FieldColor / DialogColor + luminance (0.2126*R + 0.7152*G + 0.0722*B)
# < 128 → dark
...
return {"monaco": "vs-dark" if dark else "vs", "is_dark": dark, ...}Adapt the exact luminance + DialogColor darkening trick already proven for chat.
Call sites get a window via the same pattern used for chat:
- From doc:
doc.getCurrentController().getFrame().getContainerWindow() - Or the desktop frame when no doc (Run Python Script from menu).
- Fallback to safe light values.
In launch paths (no duplication):
plugin/calc/python/editor.py:_launch_editor_with_code— after resolving doc/cellplugin/scripting/python_runner.py:_run_python_monaco- Any init script launcher
Always do:
theme = get_lo_theme_info(doc=doc, ctx=ctx)
load_msg["theme"] = theme
load_msg["monaco_theme"] = theme["monaco"] # convenienceKeep the computation on the LO main thread (inside the executor.execute or before send).
Add subscription in editor_host.py (next to the existing config:changed handler for venv) so that if a future "appearance changed" event is available we can push updates.
On load:
{
"type": "load",
...,
"theme": {
"monaco": "vs-dark",
"is_dark": true,
"bg": 0x1e1e1e,
"toolbar_bg": 0x252526
}
}Optional future push (while editor is open):
{ "type": "theme", "monaco": "...", "is_dark": true, ... }scripts_list etc. never carry theme.
Update the "Protocol evolution summary" table at the bottom of the doc.
In editor.js:
- After
monaco.editor.create(...)(or insideapplyLoadMessagefor the firstload):if (msg.theme) { var t = msg.theme.monaco || (msg.theme.is_dark ? "vs-dark" : "vs"); monaco.editor.setTheme(t); applyChromeTheme(msg.theme); }
- Implement
applyChromeTheme(tinfo):document.body.classList.toggle("dark", !!tinfo.is_dark)document.body.classList.toggle("light", !tinfo.is_dark)- Optionally set CSS custom properties on
:rootor directly style a few elements (toolbar, select, input) from the palette if richer data is present. - Re-apply on any subsequent
"theme"message delivered via the poll queue.
Add support in the pipe dispatch of editor_main.py if needed (current generic handleScriptsManagerMessage + editor.js poll path already works for extra fields on known messages and new type:"theme").
Update initial create to still use "vs" as safe default; the load will correct it immediately.
- Add class-based rules:
body.dark { background: #1e1e1e; color: #cccccc; } body.dark #toolbar { background: #252526; border-bottom-color: #1e1e1e; } body.dark #data-binding-input, body.dark #script-select { ... dark input styles ... } body.dark .status-ok { color: #3fb950; } /* dark variants for script buttons, focus rings, etc. */
- Convert repeated color literals to CSS vars where easy (
--wa-toolbar-bg,--wa-border). - Keep light as the no-class default for backward compat.
- Test high-contrast if LO reports it (map to
hc-light/hc-black).
Use the script-manager colors already present (some GitHub-inspired) and supply .dark overrides for them.
After binary works:
- Use richer palette from
StyleSettings(FaceColor, ButtonTextColor, etc.). - On the JS side call
monaco.editor.defineTheme('lo-dark', { base: 'vs-dark', inherit: true, rules: [...], colors: { 'editor.background': '#...', 'editor.foreground': ..., 'keyword': '...' } })thensetTheme('lo-dark'). - Pick 6–8 token colors that give Python a pleasant LO-native feel without inventing a full color scheme.
- Toolbar chrome should use the same accent for focus rings / selection if available.
Defer until the simple vs/vs-dark + chrome class is solid and tested.
"Automatically" implies reacting when the user changes LibreOffice → Options → Appearance (or OS theme while LO is "System").
- Minimum (automatic enough for most users): every
loadmessage (cell switch, reopen) carries a fresh theme computed at send time. - Nice-to-have: push a
{"type":"theme", ...}when we notice a change.- Hook
global_event_busfor"config:changed"(some appearance keys may surface). - Or install a one-time listener via
com.sun.star.frame.theGlobalEventBroadcasterif a suitable event exists for style settings. - Fallback: a cheap 5–10s poll inside the PersistentEditor reader (only while a session is active) that compares a signature (e.g. FieldColor) and sends delta.
- Hook
- Child must handle the update without resetting editor content or cursor (just
setTheme+applyChromeTheme).
Document the chosen approach and any platform caveats (Wayland vs X11 theme propagation can be delayed).
- Extract / add
get_lo_theme_info+ unit test (pure, no UNO). - Wire into the two load builders; always include
"theme". - JS + CSS changes so that dark works on first open. Manual smoke: switch LO appearance, restart LO (or just the editor), verify.
- Update
applyLoadMessage/ poll path for re-apply. - Add support for a pushed
"theme"message (host can send it later). - Add integration-level test:
test_editor_host.pyor newtest_appearance.pythat spies the sent load and assertsthemekey present with sensible values (mock the StyleSettings object). - Extend manual test checklist in §7 of this doc.
- Update docs (this file + one sentence in
enabling_numpy_in_libreoffice.md).
Unit test example sketch (no LO needed):
def test_get_lo_theme_info_dark_from_field_color():
mock_win = MagicMock()
mock_win.StyleSettings.FieldColor = 0x2d2d2d # darkish
mock_win.StyleSettings.DialogColor = 0x1e1e1e
info = get_lo_theme_info(style_window=mock_win)
assert info["is_dark"] is True
assert info["monaco"] == "vs-dark"Add corresponding light case and fallback test.
UNO / native tests: optional, only if we can drive Options change in a test runner (rare).
- No document open (Run Python Script from Start Center or menu) → fall back to desktop window or safe default.
- LO in high-contrast mode → map or stay with vs/vs-dark.
- "System" preference: the
StyleSettingsvalues already reflect what LO chose; we don't need to read the setting key separately. - Qt WebEngine / GTK pywebview backends: colors are controlled 100% in the web content; native titlebar may follow OS but content follows us.
- Color ints are 0xRRGGBB (matches existing chat code).
- Fallback values must be light (current hard-coded) so a broken detector never produces unreadable white-on-white.
- Update title bar? pywebview title is text only; we cannot easily theme the OS frame from inside.
plugin/scripting/editor_theme.py(new, narrow) orplugin/framework/appearance.py(debt reduction)plugin/scripting/editor_host.py(load enrichment + change listener)plugin/calc/python/editor.pyplugin/scripting/python_runner.pyplugin/contrib/scripting/assets/editor/editor.jsplugin/contrib/scripting/assets/editor/style.csstests/framework/test_appearance.py(new) + updates totest_editor_host.py(historical plan text)docs/python-monaco-editor-dev-plan.md(this)docs/enabling_numpy_in_libreoffice.md(status line)
Keep changes small. Reuse the poll / queue path — no new threads.
This phase is intentionally after 2A (UX solid) and can be done in parallel with parts of 2D (Jedi) because it touches a different surface (presentation, not language service).
Goal: Popen from inside Flatpak can reach the host venv.
Port spawn helpers from LibrePythonista (see analysis doc): detect sandbox, wrap invocation with flatpak-spawn --host or snap equivalent. Centralize in editor_launcher.py beside existing resolve_venv_python.
Tests: hard to automate; maintain a manual matrix in this doc (Flatpak LO + host venv path).
Priority: bump up if user reports are mostly Flatpak; otherwise after 2A–2D.
| Item | Rationale | Status |
|---|---|---|
| Run Python Script… → Monaco | Reuse bridge with load from last_python_script_* config keys; Run persists config and executes (not formula save). Falls back to native dialog when pywebview unavailable. Shared launcher: editor_host.py. Script picker UI: scripts_manager.js + document_scripts.py. |
Done |
| Sample scratchpad reload | Sample in the picker is the personal scratchpad (last_python_script_*), not a saved script. Initial open loads via load.code; switching back to Sample after picking My Scripts must reload scratchpad text (native XDL dialog already did this; Monaco initially did not). |
Done |
| Formula bar button | Needs LO UI extension research (Calc input line customization). High effort; do after context menu. Optional: double-click cell → editor when menu path is insufficient. Touch python_editor.py, Addons.xcu. |
|
| Excel-style accelerators | Wire Ctrl+Alt+Shift+F9 → Reset Python Session; optional Ctrl+Alt+Shift+P → Edit Python in Cell… per enabling_numpy §6 shortcuts. Touch Accelerators.xcu. |
|
| Tier-2 document store | enabling_numpy_in_libreoffice.md Tier 2 (formula key + side store) is a separate product decision — do not mix with Monaco until formula-in-cell workflow is stable. |
|
| Core extension split | Keep all editor code in plugin/scripting/ + thin plugin/calc/python/editor.py per ROADMAP.md Phase 3–4 so a future core OXT can ship =PY() + editor without the LLM stack. |
|
| Viz plot polish | Plot anchor/z-order, replace-existing-chart, UNO e2e for =PYTHON() image insert — Visualization. |
Symptom: In Run Python Script… Monaco, the dropdown shows Sample at the top (personal scratchpad), then My Scripts / helper sections. First open worked; selecting another script and clicking Sample again did nothing — the editor kept the other script’s text.
Root cause: scripts_manager.js builds Sample as <option value="">Sample</option>. onDropdownChange only called editor.setValue when the name matched scriptIndex (named scripts). Empty value fell through to a no-op. The native fallback in python_runner_ui.py correctly loads get_config_str(ctx, config_key) when Sample is selected.
Fix (no new IPC message types):
| Layer | Change |
|---|---|
| LO → child | build_scripts_list_message() adds sample_code: get_config_str(ctx, resolve_run_script_config_key(doc)). Sent on every request_scripts / _send_scripts_list (same path as section refresh). |
| Child JS | Track module-level sampleCode from load.code (run_script), scripts_list.sample_code, and saved while currentOrigin === "sample". On Sample select (value === ""), editor.setValue(sampleCode). Clear sampleCode when user confirms scratchpad delete. |
| Tests | test_build_scripts_list_message_includes_sample_code; test_persistent_editor_dispatches_script_actions asserts sample_code on scripts_list. |
Parity note: Sample remains a reserved scratchpad label (see enabling_numpy_in_libreoffice.md). A user script literally named "Sample" under My Scripts is a separate <option value="Sample"> — pre-existing ambiguity in the native list too.
Session 1: ready | load | save | saved | error | closed | cancel
Phase 2B: + validate | validate_result
Phase 2C: + pick_range | range_result
Phase 2D: + completions (child-internal, optional calc_symbols from LO)
Phase 2E: load.theme (and optional pushed "theme") field (no new required top-level type)
Phase 3: load.mode / save_label / show_plain_text / show_data_binding / status_ok_text / load.ui (no new IPC types)
scripts_list.sample_code field (scratchpad text for Sample picker entry)
load.selected_script_name + scripts_list.selected_script_name (picker sync)
select_script (child → LO: persist last_python_script_name_* on dropdown change)
Consider a top-level seq: int on all messages once 2B is in place so async validate/range responses never apply out of order.
| Layer | What to add |
|---|---|
| Unit | validate compile helper; Jedi completion formatting (no LO); scripts_list.sample_code in test_document_scripts.py |
| Integration | subprocess test: spawn editor_main.py, write ready/load, read responses (headless skip if no display); optional probe test against a fixture venv with pywebview |
| UNO | range picker happy path; optional “edit cell → save → recalc” one-shot |
| Manual | checklist in §7 extended per phase; Flatpak row when 2F ships |
flowchart TD
A[2A UX hardening] --> B[2B Syntax validate]
A --> C[2C Range picker]
B --> D[2D Jedi completions]
C --> D
D --> E[2E Theme sync]
E --> F[2F Flatpak spawn]
note right of E: auto-detect from LO StyleSettings + chrome CSS
F --> G[3 Broader surfaces]
Rationale: 2A reduces support burden immediately. 2B and 2C are the two features users ask for after “it opens.” Jedi (2D) depends on stable editor loop and should not compete with range picker for main-thread LO time. Theme sync (2E) is high-visibility polish that benefits from a solid UX base (loads, saves, status) but is otherwise independent. Flatpak and Phase 3 after core editing is trusted.
- Auto-close on Save? LP keeps editor open; WriterAgent today shows “Saved.” — default stay open; optional setting later.
- Multiple editor windows? Session singleton forbids two — enough for now; multi-cell edit is rare.
- Monaco / editor assets: all static UI files (
index.html, JS, CSS, and Monacovs/) come fromrocherin the configured venv (uv pip install rocher). The OXT ships none of them. Refresh by upgradingrocherin the venv. In-repo dev copies live underplugin/contrib/scripting/assets/editor/. - Validate in child with
ast.parseinstead of LO? Faster but diverges from workercompilemode — prefer LO for consistency unless latency forces child-side AST-only pass first.
- Session 1 (done): native LO + configured venv with pywebview: menubar Edit Python in Cell…, edit any selected Calc cell, Save updates
=PY()and recalc; failures show full tracebacks. - Phase 2A (done): context menu, stderr drain, multi-cell reload, persistent child reuse.
- Phase 2E (done): automatic theme (LO light/dark drives Monaco
vs/vs-dark+ full toolbar chrome via shared appearance detector). - Later: syntax squiggles (2B), range picker button (2C), Jedi finish (2D), Flatpak spawn (2F).
make testgreen; typecheck clean; no UNO calls off main thread in bridge code paths.
| Topic | Behavior |
|---|---|
| Cell selection | Uses sheet controller selection (python_editor.py), same idea as Calc extend/edit |
| Empty / non-PYTHON cells | Editor opens; Save (default) writes =PY("code") / =PY("code"); with Save without =PY() checked, writes raw script via setString |
| Save without =PY() | Checkbox (save_as_plain): checked → setString only, Data: disabled; unchecked → rebuild =PY("…") formula with optional data suffix. User strings in editor_ui_strings.py. |
| Load source | Inline PYTHON → stripped code; code-only cell → getString(); Monaco never shows =PY() |
| Data ranges | Editable toolbar textbox (data_binding on load/save); written into =PY("code"; …) suffix via python_formula_edit.py; single range → data, multiple comma/semicolon-separated → data_list |
| Formula strings | Reads getFormula(), FormulaLocal, Formula; normalizes leading =, array braces, smart quotes |
Unparsed PYTHON (e.g. =PY(A1; B1)) |
Blocked with msgbox — cannot safely preserve data args |
| Single session | One persistent child process; multi-cell reload retargets save callbacks and sends load (assumes user saved before switching). WM close hides window and clears session; process stays warm. |
| Child stderr | editor-stderr-drain thread logs lines at debug; read_stderr_tail() uses ring buffer for failure dialogs. |
Child sys.path |
editor_main.py bootstraps repo root so plugin.scripting.editor_protocol imports |
| Save errors to UI | Bridge sends error + traceback to child; red toolbar status (Phase 2A) |
| Formula function name | Always English PYTHON; localized tokens are a bug, not supported |
| Topic | Behavior |
|---|---|
| Scratchpad (Sample) | Personal-only; stored in last_python_script_writer / _calc / _draw via resolve_run_script_config_key. Dropdown value is ""; label is Sample. |
| Initial load | load.code and load.selected_script_name from resolve_run_script_selection() (last_python_script_name_* → script body). scripts_list repeats selected_script_name; JS syncs the dropdown without overwriting editor text on first populate. |
| Picker selection persist | User changes dropdown → select_script IPC → set_config(last_python_script_name_*) (same as native XDL _ScriptSelectListener). |
| Switch to named script | onDropdownChange loads from scriptIndex[name].code (from scripts_list.sections). |
| Switch back to Sample | Reload sampleCode from latest scripts_list.sample_code (or last Save while Sample selected). |
| Save while Sample | Save → notify_save_script → set_config(config_key, code); JS updates sampleCode on saved. |
| Delete while Sample | Confirms clear; notify_save_script(""); clears editor and sampleCode. |
| Named script Save | Intercepted in scripts_manager.js → save_script IPC (user or document origin). |
Feasibility analysis for replacing Monaco/pywebview with an embedded Writer document (hidden-doc HTML import / paste patterns from the RichTextControl chat sidebar, or a visible frame in a modal dialog) as the Python code editing surface. Preserved here for future reference.
- Formula parser (
python_formula_edit.py) — purely string-level=PY()decomposition/reconstruction. No changes needed. - Cell resolution (
python_editor.py:_get_active_calc_cell,_load_cell_editor_code) — finding the active cell and extracting initial code. - Save logic (
python_editor.py:_apply_cell_save,_apply_formula_save,build_editor_formula_save) — writing code back to the cell. - Auto-imports (
venv_sandbox.py:apply_auto_imports) — reusable with any completion backend.
editor_main.py,editor_bridge.py,editor_protocol.py— the entire subprocess + IPC pipe layer.editor_launcher.py/editor_session_launch.py— venv probing and process spawn.plugin/contrib/scripting/assets/editor/— dev source for WriterAgent shell files; runtime assets from venvrocher.- The pywebview dependency (and the requirement for a configured venv just to open the editor).
- The persistent child process lifecycle, stderr drain thread, and warm-reuse logic.
| Task | Notes |
|---|---|
| Hidden Writer doc for formatted paste | Hidden-doc + copy pattern in rich_text_control.py / append_rich_text in rich_text.py |
| Loading code into it | text.setString(code) |
| Extracting code from it | text.getString() |
| Monospace font | Set CharFontName = "Liberation Mono" on the Standard paragraph style |
| Save/Cancel buttons | Standard XDL dialog hosting the embedded Writer frame + toolbar area |
| Theme-aware background | get_theme_colors() from rich_text.py |
| No subprocess/IPC | Direct in-process calls; huge architectural simplification |
| Task | Notes |
|---|---|
| Python syntax highlighting | Use stdlib tokenize module to map token types → Writer character styles (keyword=blue, string=green, comment=gray, etc.). Apply via XTextCursor + CharColor. |
| Re-tokenization on edit | Attach an XModifyListener; debounce and re-tokenize only changed lines. Key insight: the realtime grammar checker's proofreader callback (grammar_proofread_text.py) already solves this exact pattern — paragraph-level change detection, background processing, and applying results back to ranges. The same XProofreadListener / work-queue architecture could drive syntax coloring with minimal adaptation. |
| Performance | Re-styling the entire document on every keystroke will flicker/lag. Incremental (per-paragraph) re-tokenization + the grammar queue's dedup/supersede pattern keeps it fast. |
| Code aesthetics | Writer's default paragraph spacing, line spacing, and text boundaries are prose-oriented. Needs tuning: zero ParaTopMargin/ParaBottomMargin, tight line spacing, hidden boundaries. |
| Task | Notes |
|---|---|
| Autocompletion UI | Writer has no built-in autocomplete dropdown for arbitrary content. Options: (a) custom positioned XListBox popup near the cursor, (b) a floating XDL dialog with a list, (c) abuse Writer's own AutoComplete word list (limited). Jedi can run in-process (no pipe needed) but needs a background thread to avoid blocking the main thread. |
| Line numbers | No built-in mechanism for an embedded Writer doc. Would need a side panel or paragraph-prefix hack. |
| Bracket matching | Must be implemented from scratch — scan for matching ()[]{} and apply a highlight character style. |
| Auto-indent | Writer's autocorrect is prose-oriented. Would need a XKeyListener intercepting Enter/Tab to insert appropriate whitespace based on the previous line's indentation and trailing :. |
| Tab → spaces | Writer tab inserts a tab stop. Need to intercept Tab key and insert 4 spaces instead. |
| Block selection / multi-cursor | Not possible in Writer. |
| The shutdown crash | Sidebar-parented embedded Writer (removed from chat) had VCL exit crashes. A modal dialog hosting an embedded frame has explicit lifecycle and may avoid that; the shipped chat path uses hidden Writer docs only (no nested swriter in the panel). |
| Monaco (current) | Writer-embedded | |
|---|---|---|
| Syntax highlighting | Excellent (built-in Python tokenizer) | DIY via tokenize + CharColor (medium effort, likely inferior) |
| Autocompletion | Full Monaco CompletionProvider | Custom popup from scratch (hard) |
| Code editing UX | Professional IDE-grade | Prose editor repurposed for code (workable but not great) |
| Dependency | Requires pywebview + venv | Zero external deps |
| Architecture | Subprocess + IPC (complex) | In-process (simple) |
| Shutdown | Clean (separate process) | Inherits VCL lifecycle issues (unless modal dialog) |
| Startup time | ~2s first launch (warm after) | Instant (in-process) |
| Bundle size | Zero in OXT; all editor static assets via rocher in user venv |
Zero |
- If pywebview proves too fragile across distros/Flatpak/Wayland.
- If the goal shifts to "lightweight quick-edit" rather than "IDE-grade editor" (e.g., a modal dialog for short snippets that doesn't need full autocomplete).
- If the grammar proofreader's infrastructure is already mature enough that wiring it for syntax highlighting is low marginal effort.
For a minimal viable Writer-based editor (no autocomplete, basic syntax highlighting only), the effort is moderate — perhaps 3–5 days given the sidebar embedding is already proven and the grammar queue pattern exists. For feature parity with Monaco (autocomplete, bracket matching, line numbers), the effort is 2–4 weeks and the result would still feel inferior to a real code editor. The sweet spot may be a hybrid: use the Writer-embedded approach for the "Run Python Script" quick-edit dialog (where full IDE features aren't critical), and keep Monaco for the Calc cell editor where users write longer code.
Status: Shipped (2026-05). Named scripts stored in document UserDefinedProperties as WriterAgentDocumentPythonScripts (UTF-8 JSON envelope). The Monaco script picker shows My Scripts (personal library in writeragent.json) and This Document as separate sections. Attach / Copy to My Scripts in Monaco; read-only documents fall back to the personal library.
Implementation: document_scripts.py, editor_host.py IPC, scripts_manager.js. Tests: test_document_scripts.py, test_document_scripts_uno.py.
User workflow and feature context: enabling_numpy_in_libreoffice.md §3 Monaco and §7 document-attached scripts.