././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1786437029.750629 formiko-2.0.0/0000775000175000017500000000000015236556646012167 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9632962 formiko-2.0.0/AUTHORS0000664000175000017500000000050215152030611013204 0ustar00mcbigmcbig[developers] Ondřej Tůma Ben Burrill Fr_Dae Jason Dong Jeremy Bicha Sabri Ünal Vincent Bermel [artists] Petr Šimčík ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9632962 formiko-2.0.0/COPYING0000664000175000017500000000276015152030611013177 0ustar00mcbigmcbigBSD Licence ----------- Copyright (c) 2016, Formiko Team. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786432732.2509716 formiko-2.0.0/ChangeLog0000664000175000017500000001651315236546334013740 0ustar00mcbigmcbigChangelog ========= Version 2.0.0 * Use system font settings (GNOME gsettings) for editor and preview * Editor (GtkSourceView) uses system monospace font and size * Renderer (WebKit) uses system document font and monospace font * Font settings are applied dynamically on change * Fix RST ``include`` directive with relative paths when opening files from a subdirectory * Add custom directives (``formiko/directives.py``): * ``.. mdinclude::`` — include and inline-render a Markdown file (requires m2r2; works in both RST and Markdown documents) * ``.. file-tree::`` — render a directory tree as a bullet list * Options: ``:depth:`` (default 2), ``:include:``, ``:exclude:``, ``:links:`` * Add tests for all custom directives * Inherit source file extension when opening extension-less links to non-existent files (e.g. link from ``doc.md`` to ``page`` creates ``page.md`` instead of ``page``) * Tab support (Gtk.Notebook): * Each open file lives in its own tab within a single window * Open new tab via Ctrl+T * Close tab via Ctrl+W or the tab close button; last tab closes the window * Navigate tabs with Ctrl+Tab / Ctrl+Shift+Tab and Ctrl+Page_Down / Ctrl+Page_Up * Tab bar is auto-hidden when only one tab is open * Opening a file into an empty unmodified tab reuses it instead of adding a new tab * Files already open in a tab are activated instead of reopened * Tab title shows an asterisk (*) for unsaved changes * Tab tooltip shows the full file path, or "Untitled Document (Draft)" for unsaved documents * Per-tab state: file, parser/format, word and character count, window title and formatting toolbar * Global state: writer, style, view mode (editor/preview/both), split orientation and position * Unsaved-changes dialog uses Save / Discard / Cancel (GNOME HIG) and activates the affected tab before showing * Tab keyboard shortcuts added to the Keyboard Shortcuts dialog * Improve About dialog (Adw.AboutDialog): * Add debug info via ``_build_debug_info()``: * Flatpak detection (``/.flatpak-info``) * Runtime versions of GTK, Adw, GLib, GtkSource, WebKit * Python interpreter version * Installed Python package versions: docutils, docutils-tinyhtmlwriter, jsonpath-ng, m2r2, pynvim, Pygments * All user preferences (preview, parser, writer, editor settings); custom stylesheet path shown only as ````/```` * Debug info exposed via Troubleshooting section with Copy / Save action * Add ``issue_url`` pointing to the GitHub issue tracker, which adds a "Report an Issue" button per GNOME HIG * Active window preferences passed to ``about_dialog()`` from ``on_about()`` * Code refactoring * Remove deprecated `recommonmark` * Neovim support (formiko-vim) * Add new `vte` dependency * Fix #1: Fix Vim support on Wayland * JSON preview: * Add collapsible folding view * Add JSON path filter * Use `docutils` HTML5 Writer instead of deprecated `docutils-html5-writer` * Fix #42: Prevent "reload from storage" popup during editing * Fix #45: Fix `pip3 install` error * Migrate from GTK3 to GTK4 * Replace WebKit2 (4.x) with WebKit (6.0) * Replace GtkSource 4 with GtkSource 5 * Replace GtkSpell with libspelling 1 * Replace VTE 2.91 (GTK3) with VTE 3.91 (GTK4) * Fix #53: Replace unmaintained `m2r` with `m2r2` * Add sidebar toggle: Folder list can now be toggled via a top-left icon (#34) * Add Formatting Toolbox (#16) * Auto-bullet completion (configurable in settings) * Tab key indentation for bullets (configurable in settings) * Show file directory in window title * Update Editor/Preview/Both toggle buttons to use icons * Add JSON folding button * Add support for scrolling to anchors within documents * Add standard keyboard shortcuts for common application actions Version 1.5.0 * fix local url with href anchor * M2R convertor support for another MarkDown parsing way * check_version command for version check in all files in repository * bad parser in config fix * desktop icon fix * period check/save fix * quit dialog fix * dialogs use `Document` word * tab width fix * search error fix (issue #7) * searching refactoring * don't crash application when vim window failed or formiko icon not found * right working with formatting and extensions by mime type * right margin settings (issue 36) * dark theme renderer support (issue #18) * tab_width from ini config file fix (issue #28) * upgrade SourceView to version 4 Version 1.4.3 * cz.zeropage.Formiko application ID fix Version 1.4.2 * save_as window fix * AppStream metadata Version 1.4.0 * man pages * words and characters count live info * Recommonmark (MarkDown) support with colored code * change preview - set paned position fix * GTK Warning fix (debian bug #933622) * SearchBar and searching text in editor * searching text in preview * creating backup file before write changes to new one * refresh preview action / button * opening links in system from preview, known file types open in new formiko window * preview auto scroll * fixing positing in preview only mode * preview/editor/both with shortcuts and store in cache * edited file change time check Version 1.3.0 * spell check support * display white chars switch * highlighting current line switch * printing document output * shortcuts dialog Version 1.2.1 * fix SIGSEGV - gui threads conflict * fix window cache settings * min size of editor and preview when both widgets are visible * python2 fix Version 1.2.0 * vim with working application menu * realy disabled accels for vim * toggle buttons to on/off editor and preview * tabs setting in status bar * preferences are save automatical * auto indent is switchable * showing line numbers is switchable * right margin is switchable * text wrapping is switchable Version 1.1.0 * GTK Version check * add accels only for SourceView editor * export document as HTML * using WebKit2 engine * Python 2.x encoding fixies * links, images and media urls showen in popup bar * JSON preview * PEP writer fix * only one application menu * info for Debian and NetBSD users * SourceView editor settings: - tab width - usinng spaces intead of tabs - 80 chars length line visualization - periodic saving file Version 1.0.2 * Formiko Vim entry point Version 1.0.1 * New 1.0.1 release * Adding ChangeLog file * fix vim editing on empty line at and of file * fix file path when application server is running Version 1.0.0 * stable version 1.0.0 * right source distribution * desktop integration * litle bit fixies * application icon from petr simcik * final clean code * Small fixies from TODO: * html preview mode * clean text and new version * file type detection * working MarkDown support with gui * user preferences * modern Gtk+3 appearance (Menu) * opening file for sourceview editor * sourceview reading from and saving to files * base sourceview version * separating code to modules * preview mode * using Gtk.Application instead of GObject.GObject * full working * python3 support * crashing tread does crash gtk * using Gtk+3 * installable project * first based function ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9632962 formiko-2.0.0/MANIFEST.in0000664000175000017500000000037515152030611013702 0ustar00mcbigmcbiginclude *.py include MANIFEST.in include README.rst include COPYING include AUTHORS include ChangeLog include formiko.desktop include formiko-vim.desktop include formiko.rst include formiko-vim.rst graft icons graft formiko/data global-exclude *~ *.swp ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7505817 formiko-2.0.0/PKG-INFO0000644000175000017500000001641015236556646013264 0ustar00mcbigmcbigMetadata-Version: 2.4 Name: formiko Version: 2.0.0 Summary: Formiko is reStructuredText and MarkDown editor and live previewer. Author-email: Ondrej Tuma License-Expression: BSD-3-Clause Project-URL: Homepage, https://formiko.zeropage.cz Keywords: doc,html,rst,docutils,md,markdown,editor Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: X11 Applications :: GTK Classifier: Intended Audience :: Developers Classifier: Intended Audience :: End Users/Desktop Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Documentation Classifier: Topic :: Software Development :: Documentation Classifier: Topic :: Text Editors :: Documentation Classifier: Topic :: Text Editors :: Text Processing Classifier: Topic :: Text Processing Classifier: Topic :: Text Processing :: Markup Classifier: Topic :: Text Processing :: Markup :: HTML Classifier: Topic :: Utilities Description-Content-Type: text/x-rst License-File: COPYING License-File: AUTHORS Requires-Dist: docutils>=0.12 Requires-Dist: jsonpath-ng Provides-Extra: extra Requires-Dist: pynvim; extra == "extra" Requires-Dist: m2r2; extra == "extra" Requires-Dist: Pygments; extra == "extra" Requires-Dist: docutils-tinyhtmlwriter; extra == "extra" Requires-Dist: docutils-htmlwriter; extra == "extra" Requires-Dist: docutils-html5-writer; extra == "extra" Dynamic: license-file Formiko ======= :author: Ondřej Tůma :contributors: `The Formiko Team `_ Formiko is a reStructuredText and MarkDown editor and live previewer. It is written in Python with Gtk4, GtkSourceView and WebKit. It uses Docutils and a MarkDown-to-reStructuredText converter. If you want to **donate** to the development, you can do so via the `paypal link `_. Features: --------- * GtkSourceView based editor with syntax highlighting * optional Vim editor support * vertical or horizontal window splitting * preview mode with auto scroll * periodic save file * json and html preview * spell check * linked file opening It supports these parsers and writers: * Docutils reStructuredText parser - https://www.docutils.org * MarkDown to reStructuredText convertor (M2R2) - https://github.com/crossnox/m2r2 * Docutils HTML4, HTML5, S5/HTML slide show and PEP HTML writer - http://docutils.sourceforge.net * Tiny HTML writer - https://github.com/ondratu/docutils-tinyhtmlwriter Custom directives ~~~~~~~~~~~~~~~~~ Formiko registers additional directives that are available in both reStructuredText and MarkDown (via M2R2) documents. ``.. file-tree:: `` Renders a directory tree rooted at *path* (relative to the source file) as a bullet list. Options: ``:depth: `` Maximum display depth. Default is ``2``. ``:include: `` Show only files matching the glob pattern (e.g. ``*.md``). Directories with no matching files are hidden automatically. ``:exclude: `` Hide files matching the glob pattern. Applied after ``:include:``. ``:links:`` Turn file names into hyperlinks pointing to the files, with paths relative to the source document. Example:: .. file-tree:: docs :depth: 3 :include: *.rst :links: ``.. mdinclude:: `` Includes a Markdown file and converts it to reStructuredText inline. Requires `m2r2 `_. The directive is intended for Markdown files, but works in RST documents as well. Options: ``:start-line: `` Start reading the file from line *n*. ``:end-line: `` Stop reading the file at line *n*. Example:: .. mdinclude:: CONTRIBUTING.md :start-line: 3 Vim support ~~~~~~~~~~~ Formiko has Neovim editor support aka ``formiko-vim`` command. This runs `Neovim `_ editor in Vte.Terminal. Requirements: ------------- * Python 3 * GTK 4 * gobject-introspection * PyGObject * WebKitGTK 6.x * GtkSourceView 5.x * libspelling 1.x * gir files for all Gtk libraries * vte - neovim support * docutils - reStructuredText support * jsonpath-ng - JSON Search path support recommended: ~~~~~~~~~~~~ * m2r2 - converting MarkDown to reStructuredText * Pygments - syntax color in html output code blocks * docutils-tinyhtmlwriter - Tiny HTML Writer **formiko-vim**: * vte - Terminal emulator widget * neovim - heavily refactored vim fork * pynvim - Python3 library for scripting Neovim processes development: ~~~~~~~~~~~~ * pygobject-stubs Installation ------------ Flatpak ~~~~~~~ Formiko exists in the Flathub repository as cz.zeropage.Formiko. If you are new with Flatpak, see `setup guide `_. .. code:: sh # add Flathub repository as root flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo # install Formiko as user flatpak install flathub cz.zeropage.Formiko # run Formiko flatpak run cz.zeropage.Formiko Debian based ~~~~~~~~~~~~ Debian-based distributions sometimes use versions in package names. Here is an example for the Debian Forky version. If you use a different version, your gtksource or webkit2 may have a different version name. .. code:: sh # python3, gtk4 etc are in dependencies apt install python3-pip python3-gi python3-docutils python3-jsonpath-ng \ python3-pygments gir1.2-gtksource-5 gir1.2-webkit-6.0 \ gir1.2-spelling-1 gir1.2-adw pip3 install formiko --break-system-packages # languages apt install hunspell-en-us # or other language you want # neovim apt install gir1.2-vte-3.91 neovim python3-pynvim NetBSD ~~~~~~ **Broken at this moment - the WebKit with GTK4 and the libspelling packages are not available at this moment** Installation process can be different for each BSD releases. It's about which Python release is default. By this, you can change ``py313`` to your right version. NetBSD use pkgsrc, so some binaries are stored in ``/usr/pkg/bin`` directory. Formiko call neovim directly. If you want to use neovim version with pkgsrc, you must fix ``VIM_PATH`` variable in ``formiko/vim.py`` file. .. code:: sh # python3.13 is in dependencies, as is gtk4 pkgin install py313-pip py313-gobject3 py313-docutils gtksourceview5 \ libadwaita librsvg webkit-gtk pip3.6 install jsonpath-ng formiko # optionally pkgin install neovim pynvim pip3.6 install m2r2 docutils-tinyhtmlwriter FreeBSD ~~~~~~~ Installation process can be different for each BSD releases. It's about which Python release is default. By this, you can change ``py311`` to your right version. On FreeBSD you must install all these packages: .. code:: sh pkg install py311-pygobject py311-docutils py311-pygments py311-pip \ gtksourceview5 webkit2-gtk_60 libspelling py311-jsonpath-ng \ libadwaita pip-3.11 install formiko **Optionally** .. code:: sh # MarkDown, and tiny writer support pip-3.11 install m2r2 docutils-tinyhtmlwriter # languages pkg install en-hunspell # or other language you want # neovim pkg install vte3 neovim py311-pynvim py311-typing-extensions ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1774098249.429585 formiko-2.0.0/README.rst0000664000175000017500000001347615157513511013653 0ustar00mcbigmcbigFormiko ======= :author: Ondřej Tůma :contributors: `The Formiko Team `_ Formiko is a reStructuredText and MarkDown editor and live previewer. It is written in Python with Gtk4, GtkSourceView and WebKit. It uses Docutils and a MarkDown-to-reStructuredText converter. If you want to **donate** to the development, you can do so via the `paypal link `_. Features: --------- * GtkSourceView based editor with syntax highlighting * optional Vim editor support * vertical or horizontal window splitting * preview mode with auto scroll * periodic save file * json and html preview * spell check * linked file opening It supports these parsers and writers: * Docutils reStructuredText parser - https://www.docutils.org * MarkDown to reStructuredText convertor (M2R2) - https://github.com/crossnox/m2r2 * Docutils HTML4, HTML5, S5/HTML slide show and PEP HTML writer - http://docutils.sourceforge.net * Tiny HTML writer - https://github.com/ondratu/docutils-tinyhtmlwriter Custom directives ~~~~~~~~~~~~~~~~~ Formiko registers additional directives that are available in both reStructuredText and MarkDown (via M2R2) documents. ``.. file-tree:: `` Renders a directory tree rooted at *path* (relative to the source file) as a bullet list. Options: ``:depth: `` Maximum display depth. Default is ``2``. ``:include: `` Show only files matching the glob pattern (e.g. ``*.md``). Directories with no matching files are hidden automatically. ``:exclude: `` Hide files matching the glob pattern. Applied after ``:include:``. ``:links:`` Turn file names into hyperlinks pointing to the files, with paths relative to the source document. Example:: .. file-tree:: docs :depth: 3 :include: *.rst :links: ``.. mdinclude:: `` Includes a Markdown file and converts it to reStructuredText inline. Requires `m2r2 `_. The directive is intended for Markdown files, but works in RST documents as well. Options: ``:start-line: `` Start reading the file from line *n*. ``:end-line: `` Stop reading the file at line *n*. Example:: .. mdinclude:: CONTRIBUTING.md :start-line: 3 Vim support ~~~~~~~~~~~ Formiko has Neovim editor support aka ``formiko-vim`` command. This runs `Neovim `_ editor in Vte.Terminal. Requirements: ------------- * Python 3 * GTK 4 * gobject-introspection * PyGObject * WebKitGTK 6.x * GtkSourceView 5.x * libspelling 1.x * gir files for all Gtk libraries * vte - neovim support * docutils - reStructuredText support * jsonpath-ng - JSON Search path support recommended: ~~~~~~~~~~~~ * m2r2 - converting MarkDown to reStructuredText * Pygments - syntax color in html output code blocks * docutils-tinyhtmlwriter - Tiny HTML Writer **formiko-vim**: * vte - Terminal emulator widget * neovim - heavily refactored vim fork * pynvim - Python3 library for scripting Neovim processes development: ~~~~~~~~~~~~ * pygobject-stubs Installation ------------ Flatpak ~~~~~~~ Formiko exists in the Flathub repository as cz.zeropage.Formiko. If you are new with Flatpak, see `setup guide `_. .. code:: sh # add Flathub repository as root flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo # install Formiko as user flatpak install flathub cz.zeropage.Formiko # run Formiko flatpak run cz.zeropage.Formiko Debian based ~~~~~~~~~~~~ Debian-based distributions sometimes use versions in package names. Here is an example for the Debian Forky version. If you use a different version, your gtksource or webkit2 may have a different version name. .. code:: sh # python3, gtk4 etc are in dependencies apt install python3-pip python3-gi python3-docutils python3-jsonpath-ng \ python3-pygments gir1.2-gtksource-5 gir1.2-webkit-6.0 \ gir1.2-spelling-1 gir1.2-adw pip3 install formiko --break-system-packages # languages apt install hunspell-en-us # or other language you want # neovim apt install gir1.2-vte-3.91 neovim python3-pynvim NetBSD ~~~~~~ **Broken at this moment - the WebKit with GTK4 and the libspelling packages are not available at this moment** Installation process can be different for each BSD releases. It's about which Python release is default. By this, you can change ``py313`` to your right version. NetBSD use pkgsrc, so some binaries are stored in ``/usr/pkg/bin`` directory. Formiko call neovim directly. If you want to use neovim version with pkgsrc, you must fix ``VIM_PATH`` variable in ``formiko/vim.py`` file. .. code:: sh # python3.13 is in dependencies, as is gtk4 pkgin install py313-pip py313-gobject3 py313-docutils gtksourceview5 \ libadwaita librsvg webkit-gtk pip3.6 install jsonpath-ng formiko # optionally pkgin install neovim pynvim pip3.6 install m2r2 docutils-tinyhtmlwriter FreeBSD ~~~~~~~ Installation process can be different for each BSD releases. It's about which Python release is default. By this, you can change ``py311`` to your right version. On FreeBSD you must install all these packages: .. code:: sh pkg install py311-pygobject py311-docutils py311-pygments py311-pip \ gtksourceview5 webkit2-gtk_60 libspelling py311-jsonpath-ng \ libadwaita pip-3.11 install formiko **Optionally** .. code:: sh # MarkDown, and tiny writer support pip-3.11 install m2r2 docutils-tinyhtmlwriter # languages pkg install en-hunspell # or other language you want # neovim pkg install vte3 neovim py311-pynvim py311-typing-extensions ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772624973.4989839 formiko-2.0.0/check_css.py0000775000175000017500000000273515152016115014453 0ustar00mcbigmcbig#!/usr/bin/env python3 """CSS validator: checks brace balance and detects truncated rules.""" import re import sys def validate(path: str) -> list[str]: errors = [] source = open(path, encoding="utf-8").read() # Strip comments and string literals before counting clean = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) clean = re.sub(r'"[^"]*"|\'[^\']*\'', "", clean) opens = clean.count("{") closes = clean.count("}") if opens != closes: errors.append(f"unbalanced braces: {opens} '{{' vs {closes} '}}'") # Detect rules without a closing brace (selector line immediately followed # by another selector or end-of-file without a matching }) # Simple heuristic: find { not followed eventually by } depth = 0 for i, ch in enumerate(clean): if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth < 0: line = clean[:i].count("\n") + 1 errors.append(f"unexpected '}}' at line {line}") depth = 0 if depth != 0: errors.append(f"unclosed rule block (depth={depth} at end of file)") return errors def main() -> int: ok = True for path in sys.argv[1:]: errs = validate(path) if errs: for e in errs: print(f"{path}: {e}") ok = False else: print(f"{path}: OK") return 0 if ok else 1 if __name__ == "__main__": sys.exit(main()) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7484841 formiko-2.0.0/formiko/0000775000175000017500000000000015236556646013635 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1786432791.136483 formiko-2.0.0/formiko/__init__.py0000664000175000017500000000041115236546427015736 0ustar00mcbigmcbig"""Formiko application package.""" __version__ = "2.0.0" __copyright__ = "(c) 2026" __author__ = "Ondřej Tůma " __url__ = "https://formiko.zeropage.cz" __comment__ = ( "Formiko is reStructuredText and MarkDown editor and live previewer." ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1774478426.3347971 formiko-2.0.0/formiko/__main__.py0000664000175000017500000000174315161062132015707 0ustar00mcbigmcbig"""Formiko module / main support.""" import sys from contextlib import suppress from signal import SIGINT, signal from gi import require_version require_version("Gtk", "4.0") require_version("Gdk", "4.0") require_version("Adw", "1") require_version("GtkSource", "5") require_version("Pango", "1.0") require_version("WebKit", "6.0") with suppress(ValueError): require_version("Spelling", "1") with suppress(ValueError): require_version("Vte", "3.91") # pylint: disable = wrong-import-position from formiko.application import Application # noqa: E402 def handler_exit(*_): """Signal handler.""" sys.exit(1) def main(): """Snadard main function.""" signal(SIGINT, handler_exit) app = Application() return app.run(sys.argv) def main_vim(): """Extra main for vim version.""" signal(SIGINT, handler_exit) app = Application(application_id="cz.zeropage.Formiko.vim") return app.run(sys.argv) if __name__ == "__main__": sys.exit(main()) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1774606096.9410024 formiko-2.0.0/formiko/application.py0000664000175000017500000002004415161453421016472 0ustar00mcbigmcbig"""Gtk.Application implementation.""" from os.path import join from traceback import print_exc from gi import get_required_version from gi.repository import Adw from gi.repository.Gio import ApplicationFlags, SimpleAction from gi.repository.GLib import ( LogLevelFlags, OptionArg, OptionFlags, VariantType, log_default_handler, ) from gi.repository.Gtk import Application as GtkApplication # noqa: F401 from formiko.dialogs import TraceBackDialog, about_dialog from formiko.editor import EditorType from formiko.shortcuts import ShortcutsWindow from formiko.window import AppWindow # pylint: disable = unused-argument class Application(Adw.Application): """Formiko Application.""" def __init__(self, application_id="cz.zeropage.Formiko"): """Initor.""" super().__init__( application_id=application_id, flags=ApplicationFlags.HANDLES_COMMAND_LINE, ) self.add_main_option( "preview", ord("p"), OptionFlags.NONE, OptionArg.NONE, "Preview only", None, ) self.add_main_option( "vim", ord("v"), OptionFlags.NONE, OptionArg.NONE, "Use vim as editor", None, ) self.add_main_option( "source-view", ord("s"), OptionFlags.NONE, OptionArg.NONE, "Use SourceView as editor (default)", None, ) def do_startup(self): """'do_startup' application handler.""" Adw.Application.do_startup(self) action = SimpleAction.new("new-window", None) action.connect("activate", self.on_new_window) self.add_action(action) action = SimpleAction.new("shortcuts", None) action.connect("activate", self.on_shortcuts) self.add_action(action) action = SimpleAction.new("about", None) action.connect("activate", self.on_about) self.add_action(action) action = SimpleAction.new("traceback", VariantType.new("s")) action.connect("activate", self.on_traceback) self.add_action(action) action = SimpleAction.new("quit", None) action.connect("activate", self.on_quit) self.add_action(action) self.set_accels_for_action("app.shortcuts", ["question"]) def do_activate(self): """'do_activate' application handler.""" self.new_window(None) def do_command_line(self, command_line): """'do_command_line' application handler.""" options = command_line.get_options_dict() arguments = command_line.get_arguments()[1:] last = arguments[-1:][0] if arguments else "" if options.contains("vim"): log_default_handler( "Application", LogLevelFlags.LEVEL_WARNING, "Use formiko-vim instead", None, ) editor_type = EditorType.VIM elif options.contains("source-view"): log_default_handler( None, LogLevelFlags.LEVEL_WARNING, "Use formiko instead", None, ) editor_type = EditorType.SOURCE else: editor_type = EditorType.SOURCE if self.get_application_id() == "cz.zeropage.Formiko.vim": editor_type = EditorType.VIM if editor_type == EditorType.VIM and not get_required_version("Vte"): log_default_handler( None, LogLevelFlags.LEVEL_CRITICAL, "Vim version needs Vte 2.91 gir!", None, ) return 1 if editor_type == EditorType.SOURCE: # vim have disabled accels for conflict itself self.set_accels() if options.contains("preview") and last and last != "-": self.new_window( EditorType.PREVIEW, join(command_line.get_cwd(), last), ) elif last and last[0] != "-": self.new_window(editor_type, join(command_line.get_cwd(), last)) else: self.new_window(editor_type) return 0 def on_quit(self, action, *params): """'quit' action handler.""" self.quit() def on_new_window(self, action, *params): """'new-window' action handler.""" self.new_window( getattr( self.get_active_window(), "editor_type", EditorType.SOURCE, ), ) def on_shortcuts(self, action, param): """'shortcuts' action handler.""" win = ShortcutsWindow( getattr( self.get_active_window(), "editor_type", EditorType.SOURCE, ), ) self.add_window(win) win.present() def on_about(self, action, param): """'about' action handler.""" win = self.get_active_window() prefs = getattr(win, "preferences", None) dialog = about_dialog(prefs) dialog.present(win) def on_traceback(self, action, param): """'traceback' action handler.""" dialog = TraceBackDialog(self.get_active_window(), param.get_string()) dialog.present() def new_window(self, editor_type: EditorType, file_name=""): """Create new application window.""" try: win = AppWindow(editor_type, file_name) self.add_window(win) win.present() except Exception: # pylint: disable=broad-exception-caught print_exc() def set_accels(self): """Pair keyboard shorts to actions.""" self.set_accels_for_action("app.new-window", ["n"]) self.set_accels_for_action("app.quit", ["q"]) self.set_accels_for_action("win.open-document", ["o"]) self.set_accels_for_action("win.save-document", ["s"]) self.set_accels_for_action( "win.save-document-as", ["s"], ) self.set_accels_for_action( "win.export-document-as", ["e"], ) self.set_accels_for_action("win.print-document", ["p"]) self.set_accels_for_action("win.close-window", ["w"]) self.set_accels_for_action("win.new-tab", ["t"]) self.set_accels_for_action( "win.next-tab", ["Tab", "Page_Down"], ) self.set_accels_for_action( "win.prev-tab", ["Tab", "Page_Up"], ) self.set_accels_for_action("win.find-in-document", ["f"]) self.set_accels_for_action("win.find-next-match", ["g"]) self.set_accels_for_action( "win.find-previous-match", ["g"], ) self.set_accels_for_action("win.refresh-preview", ["r"]) self.set_accels_for_action("win.toggle-sidebar", ["F9"]) self.set_accels_for_action("win.show-editor", ["e"]) self.set_accels_for_action("win.show-preview", ["p"]) self.set_accels_for_action("win.show-both", ["b"]) # Formatting actions (SOURCE editor only) self.set_accels_for_action("fmt.bold-text", ["b"]) self.set_accels_for_action("fmt.italic-text", ["i"]) self.set_accels_for_action( "fmt.strikethrough-text", ["x"], ) self.set_accels_for_action("fmt.insert-link", ["k"]) self.set_accels_for_action("fmt.code-text", ["c"]) self.set_accels_for_action("fmt.blockquote", ["q"]) self.set_accels_for_action("fmt.bullet", ["b"]) self.set_accels_for_action("fmt.ordered", ["n"]) for _level in range(1, 7): self.set_accels_for_action( f"fmt.header-{_level}", [f"{_level}"], ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7490423 formiko-2.0.0/formiko/data/0000775000175000017500000000000015236556646014546 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9632962 formiko-2.0.0/formiko/data/AUTHORS0000664000175000017500000000050215152030611015563 0ustar00mcbigmcbig[developers] Ondřej Tůma Ben Burrill Fr_Dae Jason Dong Jeremy Bicha Sabri Ünal Vincent Bermel [artists] Petr Šimčík ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/formiko/data/jsonfold.css0000664000175000017500000000115015152030611017043 0ustar00mcbigmcbigbody{font-family:monospace} .jblock{margin-left:1em} .jblock>.jtoggler{ cursor:pointer; margin-right:0.2em; display:inline-block; width:1em; } .jblock.collapsed>.children{display:none} .jtoggler::before{content:"\25bc";display:block} .jblock.collapsed>.jtoggler::before{content:"\25b6"} .jkey{color:#922} .jstr{color:#070} .jnum{color:#164} .jbool{color:#00f} .jnull{color:#c00} .jhighlight { background-color: #fff59d; } @media (prefers-color-scheme: dark){ .jkey{color:#f88} .jstr{color:#9f9} .jnum{color:#9cf} .jbool{color:#6af} .jnull{color:#f99} .jhighlight { background-color: #455A64; } } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/formiko/data/jsonfold.js0000664000175000017500000000066015152030611016674 0ustar00mcbigmcbigdocument.addEventListener('click', (e) => { const toggler = e.target.closest('.jtoggler'); if (!toggler) return; const block = toggler.parentElement; const collapsed = block.classList.toggle('collapsed'); if (e.altKey) { block.querySelectorAll('.jblock').forEach((el) => { if (el === block) return; if (collapsed) el.classList.add('collapsed'); else el.classList.remove('collapsed'); }); } }); ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1774533262.882358 formiko-2.0.0/formiko/debug_stubs.py0000664000175000017500000004050415161235217016501 0ustar00mcbigmcbig"""Minimal stub widgets for debugging widget-freeze issues. FORMIKO_STUB_EDITOR values: 1 or "tv" – plain GtkTextView (no GtkSource at all) [freeze gone] "gsv" – GtkSource.View + GtkSource.Buffer, minimal config (no language/syntax, no gutter, no spell, no CSS) "gsv_lang" – GtkSource.View + GtkSource.Buffer WITH syntax language (adds syntax highlighting, tests GtkSource.Buffer validation) "gsv_gutter"– like "gsv_lang" but also enables line-number gutter (adds GtkSourceGutter child widgets) "gsv_full" – GtkSource.View with all real SourceView features EXCEPT spell checker and CSS font (closest to real minus those) FORMIKO_STUB_RENDERER values: 1 – Gtk.DrawingArea (label) instead of WebKitWebView Both can be set at the same time. Binary-search intent: gsv → no freeze? → GtkSource.Buffer validation is the cause gsv_lang → freeze? → syntax/language highlighting is the cause gsv_gutter → freeze? → gutter child widgets are the cause """ from os.path import basename import gi gi.require_version("Gtk", "4.0") gi.require_version("GtkSource", "5") from gi.repository import GLib, GObject, Gtk, GtkSource # --------------------------------------------------------------------------- # Shared mixin: common interface that DocumentPage expects from any editor. # --------------------------------------------------------------------------- class _StubEditorMixin(Gtk.ScrolledWindow): """Base for all stub editors: shared interface for DocumentPage.""" __gsignals__ = { "file-type": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "scroll-changed": (GObject.SignalFlags.RUN_LAST, None, (float,)), } def _init_common(self, inner_view, text_buffer): self.__file_path = "" self.__changes = 0 self.source_view = inner_view self.text_buffer = text_buffer self.text_buffer.connect("changed", self._on_buf_changed) adj = self.get_vadjustment() adj.connect("value-changed", self._on_scroll) @property def changes(self): return self.__changes @property def text(self): start, end = self.text_buffer.get_bounds() return self.text_buffer.get_text(start, end, False) @property def file_path(self): return self.__file_path @property def file_name(self): return basename(self.__file_path) if self.__file_path else "" @property def is_modified(self): return self.text_buffer.get_modified() @property def position(self): adj = self.get_vadjustment() upper = adj.get_upper() - adj.get_page_size() return adj.get_value() / upper if upper > 0 else 0.0 def read_from_file(self, file_name): self.__file_path = file_name try: with open(file_name, encoding="utf-8", errors="replace") as fh: content = fh.read() except OSError: content = "" self.text_buffer.set_text(content) self.text_buffer.set_modified(False) self.__changes += 1 ext = file_name.rsplit(".", 1)[-1] if "." in file_name else "" self.emit("file-type", ext) # no-op stubs for methods called by EditorActionGroup/DocumentPage def inc_changes(self, *_): self.__changes += 1 # noqa: E704 def set_list_features_enabled(self, _e): pass # noqa: E704 def change_mime_type(self, _p): pass # noqa: E704 def set_period_save(self, _v): pass # noqa: E704 def set_check_spelling(self, *_): pass # noqa: E704 def set_auto_bullet(self, _v): pass # noqa: E704 def set_tab_indent_bullet(self, _v): pass # noqa: E704 def set_spaces_instead_of_tabs(self, _v): pass # noqa: E704 def set_text_wrapping(self, _v): pass # noqa: E704 def set_white_chars(self, _v): pass # noqa: E704 def _on_buf_changed(self, _buf): self.__changes += 1 def _on_scroll(self, adj): upper = adj.get_upper() - adj.get_page_size() self.emit("scroll-changed", adj.get_value() / upper if upper > 0 else 0.0) # --------------------------------------------------------------------------- # Stub variant: plain GtkTextView (FORMIKO_STUB_EDITOR=1 or "tv") # --------------------------------------------------------------------------- class StubEditor(_StubEditorMixin): """GtkScrolledWindow + plain GtkTextView. No GtkSource at all.""" def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) buf = Gtk.TextBuffer() view = Gtk.TextView(buffer=buf, hexpand=True, vexpand=True) self.set_child(view) self._init_common(view, buf) # --------------------------------------------------------------------------- # Stub variant: GtkSource.View + bare GtkSource.Buffer (no lang, no gutter) # FORMIKO_STUB_EDITOR=gsv # Tests: does GtkSource.View itself (with empty Source buffer) freeze? # --------------------------------------------------------------------------- class StubEditorGSV(_StubEditorMixin): """GtkScrolledWindow + GtkSource.View, minimal config, NO language/gutter.""" def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) GtkSource.init() buf = GtkSource.Buffer() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) self.set_child(view) self._init_common(view, buf) # --------------------------------------------------------------------------- # Stub variant: GtkSource.View + GtkSource.Buffer WITH syntax language # FORMIKO_STUB_EDITOR=gsv_lang # Tests: does syntax highlighting (language / GtkSourceEngine) freeze? # --------------------------------------------------------------------------- class StubEditorGSVLang(_StubEditorMixin): """GtkSource.View + GtkSource.Buffer with RST syntax language set.""" def __init__(self, _win, preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) GtkSource.init() lm = GtkSource.LanguageManager.get_default() lang = lm.get_language("restructuredtext") or lm.get_language("markdown") buf = GtkSource.Buffer.new_with_language(lang) if lang else GtkSource.Buffer() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) self.set_child(view) self._init_common(view, buf) # --------------------------------------------------------------------------- # Stub variant: gsv_lang + line-number gutter enabled # FORMIKO_STUB_EDITOR=gsv_gutter # Tests: do the GtkSourceGutter child widgets cause the freeze? # --------------------------------------------------------------------------- class StubEditorGSVGutter(_StubEditorMixin): """GtkSource.View + language + show_line_numbers gutter enabled.""" def __init__(self, _win, preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) GtkSource.init() lm = GtkSource.LanguageManager.get_default() lang = lm.get_language("restructuredtext") or lm.get_language("markdown") buf = GtkSource.Buffer.new_with_language(lang) if lang else GtkSource.Buffer() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) view.set_show_line_numbers(True) self.set_child(view) self._init_common(view, buf) # --------------------------------------------------------------------------- # Stub variant: near-real SourceView but WITHOUT spell/CSS font setup # FORMIKO_STUB_EDITOR=gsv_full # Tests: does spell checker / CssProvider setup matter? # --------------------------------------------------------------------------- class StubEditorGSVFull(_StubEditorMixin): """GtkSource.View with language, gutter, highlight — without spell/CSS.""" def __init__(self, _win, preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) GtkSource.init() lm = GtkSource.LanguageManager.get_default() lang = lm.get_language("restructuredtext") or lm.get_language("markdown") buf = GtkSource.Buffer.new_with_language(lang) if lang else GtkSource.Buffer() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) view.set_show_line_numbers(True) view.set_highlight_current_line(True) view.set_auto_indent(True) view.set_tab_width(4) self.set_child(view) self._init_common(view, buf) # --------------------------------------------------------------------------- # Granular variants — binary search which gsv_full setting prevents freeze # --------------------------------------------------------------------------- def _make_gsv_lang_buf(): """Helper: GtkSource.Buffer with RST/markdown language.""" GtkSource.init() lm = GtkSource.LanguageManager.get_default() lang = lm.get_language("restructuredtext") or lm.get_language("markdown") return GtkSource.Buffer.new_with_language(lang) if lang else GtkSource.Buffer() class StubEditorGSVLangHCL(_StubEditorMixin): """gsv_lang + highlight_current_line=True. FORMIKO_STUB_EDITOR=gsv_lang_hcl""" def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) buf = _make_gsv_lang_buf() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) view.set_highlight_current_line(True) self.set_child(view) self._init_common(view, buf) class StubEditorGSVLangAI(_StubEditorMixin): """gsv_lang + auto_indent=True. FORMIKO_STUB_EDITOR=gsv_lang_ai""" def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) buf = _make_gsv_lang_buf() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) view.set_auto_indent(True) self.set_child(view) self._init_common(view, buf) class StubEditorGSVLangTW(_StubEditorMixin): """gsv_lang + tab_width=4. FORMIKO_STUB_EDITOR=gsv_lang_tw""" def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) buf = _make_gsv_lang_buf() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) view.set_tab_width(4) self.set_child(view) self._init_common(view, buf) class StubEditorGSVLangNoHL(_StubEditorMixin): """gsv_lang but highlight_syntax=False. FORMIKO_STUB_EDITOR=gsv_lang_nohl""" def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) buf = _make_gsv_lang_buf() buf.set_highlight_syntax(False) view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) self.set_child(view) self._init_common(view, buf) # --------------------------------------------------------------------------- # Order-of-operations tests: BEFORE vs AFTER set_child() # # Real SourceView does: create view → set_child() → set properties # Working stubs do: create view → set properties → set_child() # # Test if the ORDER is the cause. # --------------------------------------------------------------------------- class StubEditorGSVLangAIAfter(_StubEditorMixin): """gsv_lang + auto_indent=True set AFTER set_child (like real SourceView). FORMIKO_STUB_EDITOR=gsv_lang_ai_after If this freezes by typing (unlike gsv_lang_ai which does not), ORDER matters. """ def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) buf = _make_gsv_lang_buf() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) self.set_child(view) # ← set_child FIRST (like real SourceView) view.set_auto_indent(True) # ← property AFTER (like real SourceView) self._init_common(view, buf) class StubEditorGSVLangCSS(_StubEditorMixin): """gsv_lang + CSS monospace font added BEFORE set_child (like real SourceView). FORMIKO_STUB_EDITOR=gsv_lang_css Tests whether the CssProvider with monospace font alone causes the freeze. Real SourceView adds CSS before set_child, then sets auto_indent after. """ def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) buf = _make_gsv_lang_buf() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) # Add monospace font CSS (same as real SourceView does before set_child) css = Gtk.CssProvider() css.load_from_string("textview { font-family: monospace; font-size: 11pt; }") view.get_style_context().add_provider( css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) self.set_child(view) self._init_common(view, buf) class StubEditorGSVRealOrder(_StubEditorMixin): """Mimics real SourceView init order: CSS before set_child, props after. FORMIKO_STUB_EDITOR=gsv_real_order If this freezes like the real app, init order is confirmed as the root cause. """ def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) buf = _make_gsv_lang_buf() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) # CSS before set_child (real SourceView order) css = Gtk.CssProvider() css.load_from_string("textview { font-family: monospace; font-size: 11pt; }") view.get_style_context().add_provider( css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) self.set_child(view) # ← set_child first # Properties after set_child (real SourceView order) view.set_tab_width(8) view.set_auto_indent(True) view.set_show_line_numbers(False) view.set_highlight_current_line(False) self._init_common(view, buf) class StubEditorGSVRealOrderColor(_StubEditorMixin): """gsv_real_order + GtkSource.StyleScheme applied after set_child. FORMIKO_STUB_EDITOR=gsv_real_color Tests whether _apply_color_scheme() (text_buffer.set_style_scheme) after set_child re-introduces the typing freeze. If this freezes, move the color scheme setup to before set_child in the real SourceView. """ def __init__(self, _win, _preferences, _action_name=None): super().__init__(hexpand=True, vexpand=True) buf = _make_gsv_lang_buf() view = GtkSource.View.new_with_buffer(buf) view.set_hexpand(True) view.set_vexpand(True) css = Gtk.CssProvider() css.load_from_string("textview { font-family: monospace; font-size: 11pt; }") view.get_style_context().add_provider( css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) self.set_child(view) view.set_tab_width(8) view.set_auto_indent(True) view.set_show_line_numbers(False) view.set_highlight_current_line(False) # Apply color scheme AFTER set_child (same as real SourceView) manager = GtkSource.StyleSchemeManager.get_default() scheme = manager.get_scheme("Adwaita") if scheme: buf.set_style_scheme(scheme) self._init_common(view, buf) class StubRenderer(Gtk.Overlay): """Minimal renderer using Gtk.DrawingArea instead of WebKitWebView.""" def __init__(self, _win, parser="rst", writer="html4", style=""): super().__init__(hexpand=True, vexpand=True) self.src = None self.file_name = None self.pos = 0 self.position = 0.0 label = Gtk.Label( label="[stub renderer – WebKit disabled]", hexpand=True, vexpand=True, ) self.set_child(label) def set_parser(self, _parser): pass def set_writer(self, _writer): pass def set_style(self, _style): pass def set_tab_width(self, _width): pass def render(self, _src, _file_name, _pos=0): pass def render_output(self): pass def scroll_to_position(self, _position): pass ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1774606221.0547693 formiko-2.0.0/formiko/dialogs.py0000664000175000017500000004425715161453615015632 0ustar00mcbigmcbig"""Formiko dialog widgets.""" import contextlib import sys from importlib import metadata from importlib.resources import files from os.path import splitext from traceback import print_exc from gi.repository import Adw, Gio, GLib, Gtk from gi.repository import GtkSource as GtkSourceModule from gi.repository import WebKit as WebKitModule from gi.repository.GtkSource import LanguageManager from gi.repository.Pango import AttrFontDesc, AttrList, FontDescription from formiko import ( __author__, __comment__, __copyright__, __url__, __version__, ) from formiko.format_utils import parse_link default_manager = LanguageManager.get_default() LANG_BY_EXT = { ".rst": default_manager.get_language("rst"), ".md": default_manager.get_language("markdown"), ".m2r": default_manager.get_language("markdown"), # parser compatibility ".html": default_manager.get_language("html"), ".htm": default_manager.get_language("html"), ".json": default_manager.get_language("json"), } #: Extensions shown in the "Markup & Text" open-dialog filter. _MARKUP_EXTENSIONS = frozenset({".rst", ".md", ".markdown", ".txt", ".text"}) #: Exact file names (no extension) shown in the "Markup & Text" filter. KNOWN_EXACT_NAMES = frozenset({ "INSTALL", "AUTHORS", "AUTHOR", "LICENSE", "LICENCE", "COPYING", "README", "TODO", "HOWTO", "CHANGES", "CHANGELOG", "ChangeLog", "NEWS", "CONTRIBUTORS", "NOTICE", "CREDITS", "BUGS", "HACKING", }) #: All extensions recognised across every open-dialog filter (lowercase). KNOWN_EXTENSIONS = _MARKUP_EXTENSIONS | frozenset(LANG_BY_EXT) def is_known_file(name: str) -> bool: """Return True if *name* matches any filter used in the open dialog.""" ext = splitext(name)[1].lower() return ext in KNOWN_EXTENSIONS or name in KNOWN_EXACT_NAMES def load_authors(): """Parse formiko/AUTHORS and return a dict of section → list of entries.""" sections = {} current = None try: content = ( files("formiko.data") .joinpath("AUTHORS") .read_text(encoding="utf-8") ) for line in content.splitlines(): _line = line.strip() if _line.startswith("[") and line.endswith("]"): current = line[1:-1] sections[current] = [] elif _line and current is not None: sections[current].append(line) except Exception: # pylint: disable=broad-exception-caught print_exc() return sections def run_dialog(dialog): """Run a GTK4 file-chooser dialog synchronously using a nested loop.""" result = [Gtk.ResponseType.NONE] loop = GLib.MainLoop() def on_response(_, response): result[0] = response loop.quit() handler = dialog.connect("response", on_response) dialog.present() loop.run() dialog.disconnect(handler) return result[0] def run_alert_dialog(dialog, parent): """Run an Adw.AlertDialog synchronously; returns the string response id.""" result = [dialog.get_close_response()] loop = GLib.MainLoop() def on_response(_, response): result[0] = response loop.quit() handler = dialog.connect("response", on_response) dialog.present(parent) loop.run() dialog.disconnect(handler) return result[0] def _build_debug_info(prefs=None): """Collect library and package versions for bug reports.""" from os.path import exists as path_exists def gv(mod): return f"{mod.MAJOR_VERSION}.{mod.MINOR_VERSION}.{mod.MICRO_VERSION}" is_flatpak = path_exists("/.flatpak-info") lines = [ f"Flatpak: {'yes' if is_flatpak else 'no'}", "", "GTK libraries:", f" GTK: {gv(Gtk)}", f" Adw: {gv(Adw)}", f" GLib: {gv(GLib)}", f" GtkSource: {gv(GtkSourceModule)}", f" WebKit: {gv(WebKitModule)}", "", f"Python: {sys.version.split()[0]}", "", "Python packages:", ] pkgs = ( "docutils", "docutils-tinyhtmlwriter", "jsonpath-ng", "m2r2", "pynvim", "Pygments", ) for pkg in pkgs: with contextlib.suppress(metadata.PackageNotFoundError): lines.append(f" {pkg}: {metadata.version(pkg)}") if prefs is not None: e = prefs.editor preview_name = ( "horizontal" if prefs.preview == Gtk.Orientation.HORIZONTAL else "vertical" ) lines += [ "", "Settings:", f" preview: {preview_name}", f" auto_scroll: {prefs.auto_scroll}", f" parser: {prefs.parser}", f" writer: {prefs.writer}", f" custom_style: {prefs.custom_style}", f" style: " f"{'' if prefs.style else ''}", f" period_save: {e.period_save}", f" check_spelling: {e.check_spelling}", f" spell_lang: {e.spell_lang or ''}", f" spaces_instead_of_tabs: {e.spaces_instead_of_tabs}", f" tab_width: {e.tab_width}", f" auto_indent: {e.auto_indent}", f" line_numbers: {e.line_numbers}", f" right_margin: {e.right_margin}", f" right_margin_value: {e.right_margin_value}", f" current_line: {e.current_line}", f" text_wrapping: {e.text_wrapping}", f" white_chars: {e.white_chars}", f" auto_bullet: {e.auto_bullet}", f" tab_indent_bullet: {e.tab_indent_bullet}", ] return "\n".join(lines) def about_dialog(prefs=None): """Create About Formiko dialog.""" authors = load_authors() return Adw.AboutDialog( application_name="Formiko", application_icon="formiko", version=__version__, copyright=__copyright__ + " The Formiko Team", comments=__comment__, website=__url__, license_type=Gtk.License.BSD_3, developer_name=__author__.split(" <", maxsplit=1)[0], developers=authors.get("developers", [__author__]), artists=authors.get("artists", []), issue_url="https://github.com/ondratu/formiko/issues/new", debug_info=_build_debug_info(prefs), debug_info_filename="formiko-debug-info.txt", ) class UnsavedChangesDialog(Adw.AlertDialog): """Dialog shown when closing a document with unsaved changes.""" def __init__(self, file_name): name = f"`{file_name}`" if file_name else "Untitled document" super().__init__( heading="Save Changes?", body=f"Document {name} contains unsaved changes." " Changes that are not saved will be permanently lost.", ) self.add_response("discard", "Discard") self.add_response("cancel", "Cancel") self.add_response("save", "Save") self.set_response_appearance( "discard", Adw.ResponseAppearance.DESTRUCTIVE, ) self.set_response_appearance( "save", Adw.ResponseAppearance.SUGGESTED, ) self.set_default_response("save") self.set_close_response("cancel") class TraceBackDialog(Adw.AlertDialog): """Showing traceback dialog.""" def __init__(self, traceback): super().__init__(heading="Traceback error", body="") self.add_response("ok", "OK") self.set_close_response("ok") label = Gtk.Label(label=traceback, selectable=True) attrs = AttrList() attrs.insert( AttrFontDesc.new(FontDescription.from_string("Monospace")), ) label.set_attributes(attrs) scroll = Gtk.ScrolledWindow( min_content_width=500, min_content_height=200, ) scroll.set_child(label) self.set_extra_child(scroll) class FileNotFoundDialog(Adw.AlertDialog): """File not found error dialog.""" def __init__(self, filename): super().__init__( heading="File not found", body=f"Document `{filename}` not found.", ) self.add_response("ok", "OK") self.set_close_response("ok") class FileChangedDialog(Adw.AlertDialog): """File changed info dialog.""" def __init__(self, file_name): super().__init__( heading="File changed", body=f"Document `{file_name}` was changed.\n" "Do you want to reload from storage?", ) self.add_response("no", "No") self.add_response("yes", "Reload") self.set_default_response("yes") self.set_close_response("no") def open_file_dialog( parent, title, filters, default_filter, initial_folder=None, callback=None, ): """Open Gtk.FileDialog for selecting a file. Call *callback(path)* on accept. """ dialog = Gtk.FileDialog( title=title, filters=filters, default_filter=default_filter, ) if initial_folder: dialog.set_initial_folder(Gio.File.new_for_path(initial_folder)) def _finish(d, result): try: gfile = d.open_finish(result) except GLib.Error: return if gfile and callback: path = gfile.get_path() if not path: try: path, _ = GLib.filename_from_uri(gfile.get_uri()) except (GLib.Error, TypeError): path = "" callback(path or "") dialog.open(parent, None, _finish) def _apply_initial_name(dialog, name, default_suffix): """Set initial filename in a save dialog, appending suffix if missing.""" if name and default_suffix and "." not in name: name = f"{name}.{default_suffix}" if name: dialog.set_current_name(name) def _connect_filter_extension_updater(dialog, filter_suffixes): """Connect notify::filter to keep the filename extension in sync.""" def _on_filter_changed(d, _pspec): f = d.get_filter() if f is None: return new_suffix = filter_suffixes.get(f.get_name(), "") current = d.get_current_name() or "" if not current: return base = current.rsplit(".", 1)[0] if "." in current else current d.set_current_name(f"{base}.{new_suffix}" if new_suffix else base) dialog.connect("notify::filter", _on_filter_changed) def save_file_dialog( parent, title, filters, default_filter, default_suffix=None, filter_suffixes=None, initial_folder=None, initial_name=None, callback=None, ): """Open a save dialog and call *callback(path)* on accept. *default_suffix* — extension appended when the saved name has none. *filter_suffixes* — ``{filter_name: suffix}`` dict for live extension updates. """ dialog = Gtk.FileChooserDialog( title=title, transient_for=parent, action=Gtk.FileChooserAction.SAVE, ) dialog.add_button("_Cancel", Gtk.ResponseType.CANCEL) dialog.add_button("_Save", Gtk.ResponseType.ACCEPT) dialog.set_default_response(Gtk.ResponseType.ACCEPT) for i in range(filters.get_n_items()): dialog.add_filter(filters.get_item(i)) if default_filter: dialog.set_filter(default_filter) if initial_folder: dialog.set_current_folder(Gio.File.new_for_path(initial_folder)) _apply_initial_name(dialog, initial_name or "", default_suffix) if filter_suffixes: _connect_filter_extension_updater(dialog, filter_suffixes) def _on_response(d, response): if response == Gtk.ResponseType.ACCEPT: gfile = d.get_file() if gfile and callback: path = gfile.get_path() or "" if ( path and default_suffix and "." not in path.rsplit("/", 1)[-1] ): path += "." + default_suffix callback(path) d.destroy() dialog.connect("response", _on_response) dialog.present() def make_filter_markup_text(): """Return Gtk.FileFilter for markup and plain-text documents.""" f = Gtk.FileFilter() f.set_name("Markup & Text files") for ext in _MARKUP_EXTENSIONS: f.add_pattern(f"*{ext}") for name in KNOWN_EXACT_NAMES: f.add_pattern(name) return f def make_filter_all(): """Return Gtk.FileFilter for all files.""" f = Gtk.FileFilter() f.set_name("All File Types") f.add_pattern("*") return f def build_css_filters(): """Return (Gio.ListStore, default_filter) for CSS stylesheet picker.""" css = Gtk.FileFilter() css.set_name("Stylesheet file (*.css)") css.add_mime_type("text/css") css.add_pattern("*.css") store = Gio.ListStore.new(Gtk.FileFilter) store.append(css) store.append(make_filter_all()) return store, css def _make_lang_filter(lang_key): """Return Gtk.FileFilter for a GtkSource language (open dialogs).""" lang = LANG_BY_EXT[lang_key] f = Gtk.FileFilter() f.set_name(lang.get_name()) for p in lang.get_globs(): f.add_pattern(p) return f def build_open_filters(): """Return (Gio.ListStore, default_filter) for Gtk.FileDialog open.""" markup = make_filter_markup_text() txt = Gtk.FileFilter() txt.set_name("Plain text") txt.add_pattern("*.txt") store = Gio.ListStore.new(Gtk.FileFilter) for f in ( markup, _make_lang_filter(".rst"), _make_lang_filter(".md"), txt, _make_lang_filter(".html"), _make_lang_filter(".json"), make_filter_all(), ): store.append(f) return store, markup def _make_save_filter(name, suffixes): """Return Gtk.FileFilter with add_suffix().""" f = Gtk.FileFilter() f.set_name(name) for s in suffixes: f.add_suffix(s) return f def build_save_filters(lang_id): """Return (store, default_filter, default_suffix, filter_suffixes). *lang_id* is the GtkSource language id (e.g. 'rst', 'markdown', 'html'). *filter_suffixes* maps filter name → primary suffix for live filename updates. """ rst_f = _make_save_filter(LANG_BY_EXT[".rst"].get_name(), ["rst"]) md_f = _make_save_filter(LANG_BY_EXT[".md"].get_name(), ["md", "markdown"]) html_f = _make_save_filter( LANG_BY_EXT[".html"].get_name(), ["html", "htm"], ) json_f = _make_save_filter(LANG_BY_EXT[".json"].get_name(), ["json"]) txt_f = _make_save_filter("Plain text", ["txt"]) filter_suffixes = { rst_f.get_name(): "rst", md_f.get_name(): "md", html_f.get_name(): "html", json_f.get_name(): "json", txt_f.get_name(): "txt", } lang_to_filter = { "rst": (rst_f, "rst"), "markdown": (md_f, "md"), "html": (html_f, "html"), "json": (json_f, "json"), "text": (txt_f, "txt"), } default, suffix = lang_to_filter.get(lang_id, (rst_f, "rst")) store = Gio.ListStore.new(Gtk.FileFilter) for f in (rst_f, md_f, html_f, json_f, txt_f, make_filter_all()): store.append(f) return store, default, suffix, filter_suffixes def build_export_filters(parser): """Return tuple for export. Returns (store, default_filter, default_suffix, filter_suffixes) """ if parser == "json": default = _make_save_filter(LANG_BY_EXT[".json"].get_name(), ["json"]) suffix = "json" else: default = _make_save_filter( LANG_BY_EXT[".html"].get_name(), ["html", "htm"], ) suffix = "html" filter_suffixes = {default.get_name(): suffix} store = Gio.ListStore.new(Gtk.FileFilter) store.append(default) store.append(make_filter_all()) return store, default, suffix, filter_suffixes class InsertLinkDialog(Adw.Dialog): """Two-field dialog for inserting a hyperlink. *selected_text* and *parser* are used to pre-fill the fields via :func:`~formiko.format_utils.parse_link`. For HTML, the Text row is hidden because the inner text is always the URL itself. Pressing Enter in the URL entry or clicking *Insert* calls *on_insert(text, url)*. Closing / cancelling does nothing. """ def __init__(self, on_insert, selected_text: str = "", parser: str = ""): super().__init__(title="Insert Link") self._on_insert = on_insert self._parser = parser self.set_content_width(400) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box.append(Adw.HeaderBar()) content = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=12, margin_top=12, margin_bottom=12, margin_start=12, margin_end=12, ) group = Adw.PreferencesGroup() self._text_row = Adw.EntryRow(title="Text") group.add(self._text_row) self._url_row = Adw.EntryRow(title="URL") self._url_row.connect("entry-activated", self._confirm) group.add(self._url_row) content.append(group) btn_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=6, halign=Gtk.Align.END, ) cancel_btn = Gtk.Button(label="Cancel") cancel_btn.connect("clicked", lambda _: self.close()) insert_btn = Gtk.Button(label="Insert") insert_btn.add_css_class("suggested-action") insert_btn.connect("clicked", self._confirm) btn_box.append(cancel_btn) btn_box.append(insert_btn) content.append(btn_box) box.append(content) self.set_child(box) # Pre-fill from selection (detect existing link markup) link_text, url = parse_link(selected_text, parser) self._text_row.set_text(link_text) self._url_row.set_text(url) def _confirm(self, *_): url = self._url_row.get_text().strip() if not url: return self._on_insert(self._text_row.get_text(), url) self.close() def present_and_focus(self, parent): """Present the dialog focused on the URL entry.""" self.present(parent) self._url_row.grab_focus() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1774001129.9251194 formiko-2.0.0/formiko/directives.py0000664000175000017500000001660015157215752016343 0ustar00mcbigmcbig"""Custom docutils directives and RST parser classes for formiko.""" import fnmatch import os from typing import ClassVar from docutils import nodes from docutils.parsers.rst import Directive from docutils.parsers.rst import Parser as RstParser from docutils.parsers.rst import directives as rst_directives from formiko.utils import Undefined try: from docutils_tinyhtml import Writer as TinyWriter # type: ignore[import] except ImportError: class TinyWriter(Undefined): # type: ignore[no-redef] """Not imported TinyWriter.""" try: from m2r2 import MdInclude # type: ignore[import] from m2r2 import convert as m2r_convert class _M2RConfig: no_underscore_emphasis = False m2r_parse_relative_links = False m2r_anonymous_references = False m2r_disable_inline_math = False m2r_use_mermaid = False class _M2REnv: config = _M2RConfig() class StandaloneMdInclude(MdInclude): """MdInclude with standalone docutils support. (no Sphinx env required). """ def run(self): """Call ``MdInclude.run`` with local env.""" settings = self.state.document.settings if not hasattr(settings, "env"): settings.env = _M2REnv() return super().run() rst_directives.register_directive("mdinclude", StandaloneMdInclude) class Mark2Resturctured(RstParser): """Converting from MarkDown to reStructuredText before parse.""" def parse(self, inputstring, document): """Create RST from MD first and call than parse.""" return super().parse(m2r_convert(inputstring), document) except ImportError: class Mark2Resturctured(Undefined): # type: ignore[no-redef] """Not imported Mark2Resturctured.""" class HtmlPreview: """Dummy html preview class.""" class FileTreeDirective(Directive): """RST directive that renders a directory tree as a bullet list. Usage:: .. file-tree:: path/to/dir :depth: 3 :include: *.md :links: """ required_arguments = 1 optional_arguments = 0 option_spec: ClassVar = { "depth": rst_directives.nonnegative_int, "include": rst_directives.unchanged, "exclude": rst_directives.unchanged, "links": rst_directives.flag, } def run(self): """Build and return bullet_list nodes for the directory tree.""" source = self.state_machine.input_lines.source( self.lineno - self.state_machine.input_offset - 1, ) source_dir = os.path.dirname(os.path.abspath(source)) path = os.path.normpath( os.path.join(source_dir, self.arguments[0]), ) if not os.path.isdir(path): return [ self.state_machine.reporter.error( f"file-tree: directory not found:" f" {self.arguments[0]!r}", line=self.lineno, ), ] depth = self.options.get("depth", 2) include_pattern = self.options.get("include", None) exclude_pattern = self.options.get("exclude", None) links = "links" in self.options tree = self._build_tree( path, include_pattern, exclude_pattern, depth, 0, source_dir, links, ) return [tree] if tree else [] def _file_matches(self, name, include_pattern, exclude_pattern): """Return True if *name* passes the include/exclude filters.""" if include_pattern and not fnmatch.fnmatch(name, include_pattern): return False return not ( exclude_pattern and fnmatch.fnmatch(name, exclude_pattern) ) def _has_visible_file(self, dirpath, include_pattern, exclude_pattern): """Return True if *dirpath* contains at least one file passing filters. Only scans the immediate children (used when at depth limit). """ try: for entry in os.scandir(dirpath): if ( not entry.name.startswith(".") and entry.is_file() and self._file_matches( entry.name, include_pattern, exclude_pattern, ) ): return True except PermissionError: pass return False def _make_file_item(self, entry, source_dir, links): """Return a list_item node for a file entry.""" item = nodes.list_item() para = nodes.paragraph() if links: rel_path = os.path.relpath(entry.path, source_dir) para += nodes.reference(refuri=rel_path, text=entry.name) else: para += nodes.Text(entry.name) item += para return item def _make_dir_item( self, entry, include_pattern, exclude_pattern, depth, current_depth, source_dir, links, ): """Return a list_item node for a directory entry, or None to skip.""" at_depth_limit = current_depth + 1 >= depth if at_depth_limit: # Can't recurse further; when a filter is active, check whether # the dir has at least one matching immediate file. if (include_pattern or exclude_pattern) and not ( self._has_visible_file( entry.path, include_pattern, exclude_pattern, ) ): return None subtree = None else: subtree = self._build_tree( entry.path, include_pattern, exclude_pattern, depth, current_depth + 1, source_dir, links, ) if subtree is None: # no visible children after filtering return None item = nodes.list_item() item += nodes.paragraph(text=entry.name) if subtree is not None: item += subtree return item def _build_tree( self, dirpath, include_pattern, exclude_pattern, depth, current_depth, source_dir, links, ): """Recursively build a bullet_list node for *dirpath*. Returns ``None`` when the resulting list would be empty. """ if current_depth >= depth: return None try: entries = sorted( os.scandir(dirpath), key=lambda e: (not e.is_dir(), e.name.lower()), ) except PermissionError: return None items = [] for entry in entries: if entry.name.startswith("."): continue if entry.is_dir(follow_symlinks=False): item = self._make_dir_item( entry, include_pattern, exclude_pattern, depth, current_depth, source_dir, links, ) elif self._file_matches( entry.name, include_pattern, exclude_pattern, ): item = self._make_file_item(entry, source_dir, links) else: item = None if item is not None: items.append(item) if not items: return None bullet_list = nodes.bullet_list() bullet_list.extend(items) return bullet_list rst_directives.register_directive("file-tree", FileTreeDirective) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1774599979.9847155 formiko-2.0.0/formiko/document_page.py0000664000175000017500000002662215161437454017021 0ustar00mcbigmcbig"""Per-tab document widget bundling editor, renderer and associated state.""" import re import threading from os import stat from os.path import basename, dirname, splitext from traceback import print_exc from gi import get_required_version from gi.repository import GLib, GObject, Gtk from formiko.editor import EditorType from formiko.editor_actions import EditorActionGroup from formiko.formatting_actions import FormattingActionGroup from formiko.renderer import EXTS, Renderer from formiko.sourceview import SourceView from formiko.user import UserPreferences, View from formiko.widgets import ImutableDict if get_required_version("Vte"): from formiko.vim import VimEditor RE_WORD = re.compile(r"([\w]+)", re.U) RE_CHAR = re.compile(r'[\w \t\.,\?\(\)"\']', re.U) class DocumentPage(Gtk.Box): """Per-tab widget: editor + renderer + per-file state.""" __gsignals__ = ImutableDict({ # Emitted when file name/path or modified state changes. "doc-state-changed": (GObject.SignalFlags.RUN_FIRST, None, ()), # Emitted after each render cycle with updated word/char counts. "words-count-changed": ( GObject.SignalFlags.RUN_FIRST, None, (int, int), ), }) def __init__(self, window, editor_type: EditorType, file_name=""): super().__init__( orientation=Gtk.Orientation.VERTICAL, vexpand=True, hexpand=True, ) self._window = window self.editor_type = editor_type # Per-tab preferences - we only track parser here; the rest is global. self.preferences = UserPreferences() self._last_changes = 0 self._vim_title = "" self.running = True self._words_count = 0 self._chars_count = 0 self._create_renderer() if editor_type != EditorType.PREVIEW: self._create_editor_layout(file_name) else: self.paned = None self._preview_file = file_name ext = splitext(file_name)[1] if file_name else "" parser = EXTS.get(ext, self.preferences.parser) self.preferences.parser = parser self.renderer.set_parser(parser) self.append(self.renderer) GLib.timeout_add(200, self._check_in_thread) def _create_renderer(self): win_prefs = self._window.preferences self.renderer = Renderer( self._window, parser=self.preferences.parser, writer=win_prefs.writer, ) self.renderer.set_vexpand(True) self.renderer.set_hexpand(True) if win_prefs.custom_style and win_prefs.style: self.renderer.set_style(win_prefs.style) self.renderer.set_tab_width(self.preferences.editor.tab_width) def _create_editor_layout(self, file_name): ext = splitext(file_name)[1] if file_name else "" initial_parser = EXTS.get(ext, self.preferences.parser) self.preferences.parser = initial_parser self.renderer.set_parser(initial_parser) if self.editor_type == EditorType.VIM: self.editor = VimEditor(self._window, file_name) else: self.editor = SourceView( self._window, self.preferences, "editor.spell-lang", ) self.editor_actions = EditorActionGroup( self.editor, self.renderer, self.preferences, ) if self.editor_type == EditorType.SOURCE: self.fmt_actions = FormattingActionGroup( self.editor, self.renderer, initial_parser, self.preferences, ) self.editor.set_list_features_enabled( initial_parser in ("rst", "md", "m2r"), ) self.editor.connect("file-type", self._on_file_type) self.editor.connect("scroll-changed", self._on_scroll_changed) if self.editor_type == EditorType.SOURCE: self.editor.text_buffer.connect( "modified-changed", self._on_modified_changed, ) if file_name: self.editor.read_from_file(file_name) win_cache = self._window.cache self.paned = Gtk.Paned( orientation=self._window.preferences.preview, position=win_cache.paned, vexpand=True, hexpand=True, ) self.paned.set_start_child(self.editor) self.paned.set_resize_start_child(True) self.paned.set_shrink_start_child(False) self.paned.set_end_child(self.renderer) self.paned.set_resize_end_child(True) self.paned.set_shrink_end_child(False) view = self._window.cache.view if view == View.EDITOR: self.renderer.set_visible(False) elif view == View.PREVIEW: self.editor.set_visible(False) self.append(self.paned) def load_file(self, file_name): """Load *file_name* into this empty, unmodified tab.""" self.editor.read_from_file(file_name) self._last_changes = self.editor.changes # read_from_file emits 'file-type' synchronously (before set_text), # which triggers set_parser → idle_add(do_render) with the new parser. # That pending idle is the one render we want. Set renderer.src # directly here so the idle finds the correct content when it runs, # without queuing a second do_render via renderer.render(). text = self.editor.text self.renderer.src = text self.renderer.file_name = self.editor.file_path self.renderer.pos = self.editor.position self._words_count = sum(1 for _ in RE_WORD.finditer(text)) self._chars_count = sum(1 for _ in RE_CHAR.finditer(text)) def refresh(self): """Force an immediate re-render.""" self._check_in_thread(True) def _on_file_type(self, _widget, ext): parser = EXTS.get(ext, self.preferences.parser) self.preferences.parser = parser self.renderer.set_parser(parser) if self.editor_type == EditorType.SOURCE: self.fmt_actions.set_parser(parser) self.editor.change_mime_type(parser) self.editor.set_list_features_enabled( parser in ("rst", "md", "m2r"), ) if hasattr(self._window, "file_browser") and self.file_path: directory = dirname(self.file_path) if directory: self._window.file_browser.set_directory(directory) self._window.on_active_tab_parser_changed(self, parser) self.emit("doc-state-changed") def _on_modified_changed(self, _buf): self.emit("doc-state-changed") def _on_scroll_changed(self, _widget, position): if self._window.preferences.auto_scroll: self.renderer.scroll_to_position(position) @property def words_count(self): """Word count, updated each render cycle.""" return self._words_count @property def chars_count(self): """Character count, updated each render cycle.""" return self._chars_count @property def file_path(self): """Opened file path, or the preview file path.""" if self.editor_type != EditorType.PREVIEW: return self.editor.file_path return self._preview_file @property def file_name(self): """Basename of the opened file.""" if self.editor_type != EditorType.PREVIEW: return self.editor.file_name return basename(self._preview_file) if self._preview_file else "" @property def is_modified(self): """True when the document has unsaved changes.""" if self.editor_type != EditorType.PREVIEW: return self.editor.is_modified return False @property def parser(self): """Current parser name.""" return self.preferences.parser def _check_in_thread(self, force=False): """Periodic refresh dispatcher.""" if not self.running: return False if self.editor_type == EditorType.VIM: threading.Thread( target=self._refresh_from_vim, args=(force,), daemon=True, ).start() elif self.editor_type == EditorType.SOURCE: GLib.idle_add(self._refresh_from_source, force) else: GLib.idle_add(self._refresh_from_file, force) return False def _refresh_from_source(self, force=False): """Refresh the renderer from the SourceView buffer.""" try: last_changes = self.editor.changes if force or last_changes > self._last_changes: self._last_changes = last_changes text = self.editor.text self._words_count = sum(1 for _ in RE_WORD.finditer(text)) self._chars_count = sum(1 for _ in RE_CHAR.finditer(text)) self.renderer.render( text, self.editor.file_path, self.editor.position, ) self.emit( "words-count-changed", self._words_count, self._chars_count, ) GLib.timeout_add(100, self._check_in_thread) except BaseException: # pylint: disable=broad-exception-caught print_exc() def _refresh_from_vim(self, force=False): """Refresh the renderer from the Vim buffer (background thread).""" another_file = False try: file_name = self.editor.file_name file_path = self.editor.file_path if not self.running: return if file_name != self._vim_title: self._vim_title = file_name another_file = True GLib.idle_add(self.emit, "doc-state-changed") if not self.running: return last_changes = self.editor.get_vim_changes() if force or last_changes > self._last_changes or another_file: self._last_changes = last_changes if not self.running: return lines = self.editor.get_vim_lines() if not self.running: return buff = self.editor.get_vim_get_buffer(lines) if not self.running: return pos = self.editor.get_vim_scroll_pos(lines) self.renderer.render(buff, file_path, pos) GLib.timeout_add(100, self._check_in_thread) except BaseException: # pylint: disable=broad-exception-caught print_exc() def _refresh_from_file(self, force=False): """Refresh the renderer from disk (PREVIEW mode).""" try: last_changes = stat(self._preview_file).st_ctime if force or last_changes > self._last_changes: self._last_changes = last_changes with open(self._preview_file, encoding="utf-8") as src: buff = src.read() self.renderer.render( buff, self._preview_file, self.renderer.position, ) except BaseException: # pylint: disable=broad-exception-caught print_exc() GLib.timeout_add(500, self._check_in_thread) def stop(self): """Stop the refresh loop.""" self.running = False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/formiko/editor.py0000664000175000017500000000023215152030611015442 0ustar00mcbigmcbig"""Editor interface.""" from enum import Enum class EditorType(Enum): """Editor type.""" SOURCE = "source" VIM = "vim" PREVIEW = None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/formiko/editor_actions.py0000664000175000017500000002004115152030611017162 0ustar00mcbigmcbig"""Actions for sourceview editor.""" from gi.repository.Gio import SimpleAction, SimpleActionGroup from gi.repository.GLib import Variant, VariantType class EditorActionGroup(SimpleActionGroup): """Edtior Action group.""" def __init__(self, editor, renderer, preferences): super().__init__() self.editor = editor self.renderer = renderer self.preferences = preferences self.editor_pref = preferences.editor self.create_toggle_action( "period-save-toggle", self.editor_pref.period_save, self.on_period_save, ) self.create_toggle_action( "check-spelling-toggle", self.editor_pref.check_spelling, self.on_check_spelling, ) self.create_toggle_action( "auto-bullet-toggle", self.editor_pref.auto_bullet, self.on_auto_bullet, ) self.create_toggle_action( "tab-indent-bullet-toggle", self.editor_pref.tab_indent_bullet, self.on_tab_indent_bullet, ) action = SimpleAction.new("spell-lang", VariantType("s")) action.connect("activate", self.on_spell_lang) self.add_action(action) self.create_toggle_action( "use-spaces-toggle", self.editor_pref.spaces_instead_of_tabs, self.on_use_spaces, ) self.create_stateful_action( "tab-width", "i", self.editor_pref.tab_width, self.on_tab_width, ) self.create_toggle_action( "auto-indent-toggle", self.editor_pref.auto_indent, self.on_auto_indent, ) self.create_toggle_action( "line-numbers-toggle", self.editor_pref.line_numbers, self.on_line_numbers, ) self.create_toggle_action( "right-margin-toggle", self.editor_pref.right_margin, self.on_right_margin, ) self.create_stateful_action( "right-margin-value", "d", self.editor_pref.right_margin_value, self.on_right_margin_value, ) self.create_toggle_action( "current-line-toggle", self.editor_pref.current_line, self.on_current_line, ) self.create_toggle_action( "text-wrapping-toggle", self.editor_pref.text_wrapping, self.on_text_wrapping, ) self.create_toggle_action( "white-chars-toggle", self.editor_pref.white_chars, self.on_white_chars, ) def create_stateful_action(self, name, _type, default_value, method): """Create stateful action helper.""" action = SimpleAction.new_stateful( name, VariantType.new(_type), Variant(_type, default_value), ) action.connect("change-state", method) self.add_action(action) def create_toggle_action(self, name, default_value, method): """Create boolean toggle action (no parameter type, activate-based).""" action = SimpleAction.new_stateful( name, None, Variant("b", default_value), ) action.connect("activate", method) self.add_action(action) def on_period_save(self, action, _): """Save period save preferences.""" period_save = not action.get_state().get_boolean() action.set_state(Variant("b", period_save)) self.editor_pref.period_save = period_save self.editor.set_period_save(period_save) self.preferences.save() def on_check_spelling(self, action, _): """Set spell check preferences.""" check_spelling = not action.get_state().get_boolean() action.set_state(Variant("b", check_spelling)) self.editor_pref.check_spelling = check_spelling self.editor.set_check_spelling( check_spelling, self.editor_pref.spell_lang, ) self.preferences.save() def on_auto_bullet(self, action, _): """Save auto bullet completion preferences.""" auto_bullet = not action.get_state().get_boolean() action.set_state(Variant("b", auto_bullet)) self.editor_pref.auto_bullet = auto_bullet self.editor.set_auto_bullet(auto_bullet) self.preferences.save() def on_tab_indent_bullet(self, action, _): """Save Tab key indentation for bullets preferences.""" tab_indent_bullet = not action.get_state().get_boolean() action.set_state(Variant("b", tab_indent_bullet)) self.editor_pref.tab_indent_bullet = tab_indent_bullet self.editor.set_tab_indent_bullet(tab_indent_bullet) self.preferences.save() def on_spell_lang(self, _, param): """Save right spell lang preferences.""" self.editor_pref.spell_lang = param.get_string() self.preferences.save() def on_use_spaces(self, action, _): """Save space vs tabs preferences.""" use_spaces = not action.get_state().get_boolean() action.set_state(Variant("b", use_spaces)) self.editor_pref.spaces_instead_of_tabs = use_spaces self.editor.set_spaces_instead_of_tabs(use_spaces) self.preferences.save() def on_tab_width(self, action, param): """Save tab width preferences.""" width = param.get_int32() if width != self.editor_pref.tab_width: action.set_state(Variant("i", width)) self.editor_pref.tab_width = width self.editor.source_view.set_tab_width(width) self.renderer.set_tab_width(width) self.preferences.save() def on_auto_indent(self, action, _): """Save auto indent preferences.""" auto_indent = not action.get_state().get_boolean() action.set_state(Variant("b", auto_indent)) self.editor_pref.auto_indent = auto_indent self.editor.source_view.set_auto_indent(auto_indent) self.preferences.save() def on_line_numbers(self, action, _): """Save line number showing.""" line_numbers = not action.get_state().get_boolean() action.set_state(Variant("b", line_numbers)) self.editor_pref.line_numbers = line_numbers self.editor.source_view.set_show_line_numbers(line_numbers) self.preferences.save() def on_right_margin(self, action, _): """Save right margin preferences.""" right_margin = not action.get_state().get_boolean() action.set_state(Variant("b", right_margin)) self.editor_pref.right_margin = right_margin self.editor.source_view.set_show_right_margin(right_margin) self.preferences.save() def on_right_margin_value(self, _, param): """Save right margin value preferences.""" margin_value = int(param.get_double()) self.editor_pref.right_margin_value = margin_value self.editor.source_view.set_right_margin_position(margin_value) self.preferences.save() def on_current_line(self, action, _): """Save highlighting current file preferences.""" current_line = not action.get_state().get_boolean() action.set_state(Variant("b", current_line)) self.editor_pref.current_line = current_line self.editor.source_view.set_highlight_current_line(current_line) self.preferences.save() def on_text_wrapping(self, action, _): """Save text wrapping mode preferences.""" text_wrapping = not action.get_state().get_boolean() action.set_state(Variant("b", text_wrapping)) self.editor_pref.text_wrapping = text_wrapping self.editor.set_text_wrapping(text_wrapping) self.preferences.save() def on_white_chars(self, action, _): """Save showing white chars preferences.""" white_chars = not action.get_state().get_boolean() action.set_state(Variant("b", white_chars)) self.editor_pref.white_chars = white_chars self.editor.set_white_chars(white_chars) self.preferences.save() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1774010174.995525 formiko-2.0.0/formiko/filebrowser.py0000664000175000017500000000605715157237477016542 0ustar00mcbigmcbig"""File browser sidebar widget.""" from os import listdir from os.path import basename, isdir, join from gi.repository import GObject, Gtk from gi.repository.GLib import UserDirectory, get_user_special_dir from formiko.dialogs import is_known_file from formiko.widgets import ImutableDict class FileListBoxRow(Gtk.ListBoxRow): """A single row in the file browser list representing one file. Displays the file name left-aligned and stores the full path in :attr:`file_path` for use when the row is activated. """ def __init__(self, name: str, directory: str): label = Gtk.Label(label=name, xalign=0, halign=Gtk.Align.START) super().__init__() self.set_child(label) self.file_path = join(directory, name) class FileBrowser(Gtk.Box): """Sidebar showing filtered file list for the current directory.""" __gsignals__ = ImutableDict( { "file-activated": (GObject.SignalFlags.RUN_FIRST, None, (str,)), }, ) def __init__(self): super().__init__(orientation=Gtk.Orientation.VERTICAL, hexpand=False) self.add_css_class("sidebar") self._directory = "" self._dir_label = Gtk.Label() self._dir_label.add_css_class("caption") self._dir_label.add_css_class("dim-label") self.append(self._dir_label) self.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) scroll = Gtk.ScrolledWindow( hscrollbar_policy=Gtk.PolicyType.NEVER, vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, vexpand=True, ) self._list_box = Gtk.ListBox( selection_mode=Gtk.SelectionMode.SINGLE, activate_on_single_click=True, ) self._list_box.add_css_class("navigation-sidebar") self._list_box.connect("row-activated", self._on_row_activated) scroll.set_child(self._list_box) self.append(scroll) docs = get_user_special_dir(UserDirectory.DIRECTORY_DOCUMENTS) if docs and isdir(docs): self.set_directory(docs) def set_directory(self, directory): """Switch the browser to show contents of *directory*.""" if not directory or directory == self._directory: return self._directory = directory self._refresh() def _refresh(self): """Reload the file list from the current directory.""" while child := self._list_box.get_first_child(): self._list_box.remove(child) self._dir_label.set_text( basename(self._directory) or self._directory, ) self._dir_label.set_tooltip_text(self._directory) try: names = sorted( name for name in listdir(self._directory) if is_known_file(name) ) except OSError: return for name in names: row = FileListBoxRow(name, self._directory) self._list_box.append(row) def _on_row_activated(self, _list_box, row): self.emit("file-activated", row.file_path) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/formiko/format_utils.py0000664000175000017500000003634715152030611016704 0ustar00mcbigmcbig"""Pure-Python helpers for text formatting (no GTK dependency). Low-level building blocks ~~~~~~~~~~~~~~~~~~~~~~~~~ ``_strip_markers`` / ``_wrap_markers`` Atomic operations: detect + strip (or add) a ``(before, after)`` marker pair around a piece of text. ``_strip_other_block`` Strip the first matching *block-level* marker from a line — used by every block-formatting function to remove a conflicting format before applying the new one. Public API ~~~~~~~~~~ ``compute_toggle_format`` Inline (selection-based) formatting: bold, italic, strikethrough, code. ``compute_toggle_line_format`` Simple line-level toggle (blockquote). ``compute_toggle_line_exclusive`` Exclusive line-level toggle (headers H1-H6). ``compute_toggle_rst_header`` RST-specific heading underline management. ``compute_toggle_bullet`` / ``compute_toggle_ordered`` List-item toggles with blank-line separator logic. """ import re as _re # ------------------------------------------------------------------- # Regex for ordered-list prefix (``1. ``, ``12. ``, …). # Placed at module level so every function can use it. # ------------------------------------------------------------------- _RE_ORDERED_ITEM = _re.compile(r"^(\d+)\. ") # =================================================================== # Building blocks # =================================================================== def _split_indent(text: str) -> "tuple[str, str]": """Split *text* into ``(indent, rest)`` where *indent* is leading space.""" rest = text.lstrip(" \t") return text[: len(text) - len(rest)], rest def _strip_markers( text: str, before: str, after: str, ) -> "tuple[str, bool]": """Strip *before*/*after* markers from *text* if present. :returns: ``(stripped_text, True)`` when markers were found and removed, ``(text, False)`` otherwise. """ has_before = text.startswith(before) has_after = (not after) or text.endswith(after) if has_before and has_after: result = text[len(before):] if after: result = result[: len(result) - len(after)] return result, True return text, False def _wrap_markers(text: str, before: str, after: str) -> str: """Add *before*/*after* markers around *text*.""" if after: return before + text + after return before + text def _strip_other_block( text: str, variants: "tuple | list" = (), strip_ordered: bool = False, ) -> str: r"""Strip the first matching block-level marker from *text*. Processing order (first match wins): 1. Ordered-list regex ``\d+. `` (when *strip_ordered*). 2. Each ``(before, after)`` pair in *variants*. """ if strip_ordered: m = _RE_ORDERED_ITEM.match(text) if m: return text[m.end():] for v_before, v_after in variants: stripped, matched = _strip_markers(text, v_before, v_after) if matched: return stripped return text # =================================================================== # Inline formatting # =================================================================== def build_known_formats(*markup_dicts): """Collect all (before, after) pairs per parser from markup dicts. Returns a ``{parser: [(before, after), ...]}`` dict where each list is sorted by decreasing length of the opening marker, so that longer markers (e.g. ``**``) are tested before shorter ones (e.g. ``*``) during stripping. :param markup_dicts: ``{parser: (before, after)}`` dicts. """ result = {} for d in markup_dicts: for parser, fmt in d.items(): if not callable(fmt): result.setdefault(parser, []).append(fmt) for lst in result.values(): lst.sort(key=lambda f: -len(f[0])) return result def compute_toggle_format( buf_text: str, sel_start: int, sel_end: int, before: str, after: str, known_formats: list, ) -> tuple: """Compute the result of toggling an inline formatting marker. Examines the characters inside and immediately outside the selection ``[sel_start, sel_end]`` for any of the *known_formats* markers. Longer markers are tested first so that ``**`` is never confused with a pair of ``*`` markers. :returns: ``(eff_start, eff_end, result, inner_start, inner_end)`` """ sel_text = buf_text[sel_start:sel_end] sorted_formats = sorted(known_formats, key=lambda f: -len(f[0])) eff_start = sel_start eff_end = sel_end inner_text = sel_text had_format = None for fmt_before, fmt_after in sorted_formats: # Case 1: markers inside the selection inner, found = _strip_markers(sel_text, fmt_before, fmt_after) if found: inner_text = inner had_format = (fmt_before, fmt_after) break # Case 2: markers just outside the selection if ( sel_start >= len(fmt_before) and sel_end + len(fmt_after) <= len(buf_text) ): pre = buf_text[sel_start - len(fmt_before): sel_start] post = buf_text[sel_end: sel_end + len(fmt_after)] if pre == fmt_before and post == fmt_after: eff_start = sel_start - len(fmt_before) eff_end = sel_end + len(fmt_after) inner_text = sel_text had_format = (fmt_before, fmt_after) break if had_format == (before, after): result = inner_text before_len = 0 else: result = before + inner_text + after before_len = len(before) return ( eff_start, eff_end, result, before_len, before_len + len(inner_text), ) # =================================================================== # Line-level (block) formatting # =================================================================== def compute_toggle_line_format( line_text: str, before: str, after: str = "", all_block_variants: "tuple | list" = (), strip_ordered: bool = False, ) -> str: r"""Toggle a simple block marker (e.g. blockquote). If the line already has *before*/*after*, strip them (toggle off). Otherwise strip any conflicting block format first, then apply. :param line_text: Line text without the trailing newline. :param before: Prefix marker (e.g. ``"> "``). :param after: Suffix marker (e.g. ``""``). :param all_block_variants: Other ``(before, after)`` pairs to strip before applying the new format. :param strip_ordered: Strip a ``\d+. `` prefix first. """ stripped, had = _strip_markers(line_text, before, after) if had: return stripped return _wrap_markers( _strip_other_block( line_text, all_block_variants, strip_ordered, ), before, after, ) def compute_toggle_line_exclusive( line_text: str, before: str, after: str, all_variants: list, extra_strip_variants: "tuple | list" = (), strip_ordered: bool = False, ) -> str: r"""Toggle exclusive line-level formatting (e.g. header levels). First strips non-exclusive block formats (*extra_strip_variants*, ordered prefix), then looks for any variant in *all_variants*. If the active variant matches *before*/*after* exactly it is toggled off; otherwise the active variant is replaced. :param line_text: Line text without the trailing newline. :param before: Opening marker to apply. :param after: Closing marker (empty for prefix-only). :param all_variants: Mutually exclusive ``(before, after)`` pairs (e.g. all six header levels for a given parser). :param extra_strip_variants: Non-exclusive variants to strip first (blockquote, bullet list). :param strip_ordered: Strip a ``\d+. `` prefix first. """ current = _strip_other_block( line_text, extra_strip_variants, strip_ordered, ) for v_before, v_after in all_variants: stripped, matched = _strip_markers(current, v_before, v_after) if matched: if v_before == before and v_after == after: return stripped # toggle off same level current = stripped break return _wrap_markers(current, before, after) # =================================================================== # RST heading underline # =================================================================== #: RST underline characters for heading levels 1-6. RST_HEADER_CHARS = '=-^"~.' def compute_toggle_rst_header( line_text: str, next_line: "str | None", underline_char: str, known_chars: str = RST_HEADER_CHARS, ) -> "tuple[str | None, bool]": """Compute the RST heading underline operation. :param line_text: Title line text (no trailing newline). :param next_line: Line immediately after the title, or ``None``. :param underline_char: Character for the underline (``"="`` etc.). :param known_chars: All recognised underline characters. :returns: ``(new_underline, had_underline)``. """ if not line_text.strip(): return None, False target = underline_char * len(line_text) def _is_underline(text: str) -> bool: return ( bool(text) and len(set(text)) == 1 and text[0] in known_chars and len(text) >= len(line_text) ) had = next_line is not None and _is_underline(next_line) if had: if next_line[0] == underline_char: # type: ignore[index] return None, True # same level → toggle off return target, True # different level → replace return target, False # no underline → add # =================================================================== # List-item formatting (bullet / ordered) # =================================================================== def compute_toggle_bullet( line_text: str, prev_line_text: "str | None", before: str, after: str = "", all_block_variants: "tuple | list" = (), strip_ordered: bool = False, ) -> "tuple[str, bool]": r"""Toggle bullet-list formatting on a single line. Leading indentation is always preserved so that nested lists work correctly. RST blockquote indentation is therefore **not** removed. * **Toggle off** — if the line (after its indent) starts with *before*/*after*, strip those markers; keep indent. * **Toggle on** — strip conflicting block formats from the non-indent portion, then prepend the bullet marker after the indent. A blank separator line is requested when this is the first item in a new list. :param line_text: Current line text (no trailing newline). :param prev_line_text: Previous line text, or ``None``. :param before: Prefix marker (e.g. ``"- "``). :param after: Suffix marker (e.g. ``""``). :param all_block_variants: Block markers to strip on toggle-on. :param strip_ordered: Also strip ``\d+. `` prefix. :returns: ``(new_line_text, insert_blank_before)``. """ indent, rest = _split_indent(line_text) # Toggle off stripped_rest, had = _strip_markers(rest, before, after) if had: return indent + stripped_rest, False # Toggle on current = _strip_other_block( rest, all_block_variants, strip_ordered=strip_ordered, ) new_text = indent + _wrap_markers(current, before, after) _, prev_rest = _split_indent(prev_line_text or "") insert_blank = ( prev_line_text is not None and bool(prev_line_text.strip()) and not prev_rest.startswith(before) ) return new_text, insert_blank def compute_toggle_ordered( line_text: str, prev_line_text: "str | None", after: str = "", all_block_variants: "tuple | list" = (), auto_number: bool = False, ) -> "tuple[str, bool]": r"""Toggle ordered (numbered) list item formatting. Leading indentation is always preserved so that nested lists work correctly. Toggle-off is detected by the ``\d+\.\s`` regex (after the indent). On toggle-on the item number is derived from the previous line when *auto_number* is ``True`` (RST), otherwise ``1`` is used (MD). For HTML, reuse :func:`compute_toggle_bullet` with ``
  • `` markers instead. :param line_text: Current line text (no trailing newline). :param prev_line_text: Previous line text, or ``None``. :param after: Suffix marker (empty for MD/RST). :param all_block_variants: Block markers to strip on toggle-on. :param auto_number: Derive number from previous line (RST). :returns: ``(new_line_text, insert_blank_before)``. """ indent, rest = _split_indent(line_text) # Toggle off m = _RE_ORDERED_ITEM.match(rest) if m: stripped = rest[m.end():] if after and stripped.endswith(after): stripped = stripped[: len(stripped) - len(after)] return indent + stripped, False # Toggle on current = _strip_other_block(rest, all_block_variants) number = 1 if auto_number and prev_line_text: _, prev_rest = _split_indent(prev_line_text) pm = _RE_ORDERED_ITEM.match(prev_rest) if pm: number = int(pm.group(1)) + 1 before = f"{number}. " new_text = indent + _wrap_markers(current, before, after) _, prev_rest = _split_indent(prev_line_text or "") prev_is_ordered = bool( prev_line_text and _RE_ORDERED_ITEM.match(prev_rest), ) insert_blank = ( prev_line_text is not None and bool(prev_line_text.strip()) and not prev_is_ordered ) return new_text, insert_blank # =================================================================== # Link formatting # =================================================================== def compute_link(text: str, url: str, parser: str) -> str: """Return formatted link markup for *parser*. For HTML: uses *text* as inner content when provided, falls back to *url*. For RST/Markdown: returns bare *url* when *text* is empty. """ if parser == "html": inner = text.strip() or url return f'{inner}' if not text.strip(): return url if parser == "m2r": return f"[{text}]({url})" if parser == "rst": return f"`{text} <{url}>`_" return url def parse_link(selected_text: str, parser: str) -> "tuple[str, str]": """Try to parse *selected_text* as an already-formatted link. Returns ``(link_text, url)``. If the text is not recognised as a link, returns ``(selected_text, "")`` so the caller can pre-fill the *Text* field with the selection and leave *URL* empty. If the text looks like a bare URL it is placed in the *URL* field instead. """ stripped = selected_text.strip() if parser == "m2r": m = _re.match(r"^\[([^\]]*)\]\(([^)]+)\)$", stripped) if m: return m.group(1), m.group(2) elif parser == "rst": m = _re.match(r"^`([^<]+) <([^>]+)>`_$", stripped) if m: return m.group(1).strip(), m.group(2) elif parser == "html": m = _re.match( r'^]*>([^<]*)$', stripped, ) if m: return m.group(2), m.group(1) # Bare URL → pre-fill URL field only if _re.match(r"^https?://\S+$", stripped): return "", stripped return selected_text, "" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/formiko/formatting_actions.py0000664000175000017500000003441315152030611020056 0ustar00mcbigmcbig"""Formatting action group for the SOURCE editor.""" from typing import ClassVar from gi.repository import Gio, Gtk from formiko.dialogs import InsertLinkDialog from formiko.format_utils import ( RST_HEADER_CHARS, build_known_formats, compute_link, ) from formiko.widgets import IconButton #: Parsers that support inline and block formatting. FORMATTING_PARSERS = frozenset(("rst", "m2r", "html")) class FormattingActionGroup(Gio.SimpleActionGroup): """Action group for inline and block text formatting. Instantiate once and insert into the window with:: window.insert_action_group("fmt", group) Buttons in the UI use ``action_name="fmt.bold-text"`` etc. Call :meth:`set_parser` whenever the active parser changes so that actions are enabled only for supported formats. """ # ------------------------------------------------------------------ inline _MARKUP_BOLD: ClassVar = { "rst": ("**", "**"), "m2r": ("**", "**"), "html": ("", ""), } _MARKUP_ITALIC: ClassVar = { "rst": ("*", "*"), "m2r": ("*", "*"), "html": ("", ""), } _MARKUP_STRIKETHROUGH: ClassVar = { "rst": (":del:`", "`"), "m2r": ("~~", "~~"), "html": ("", ""), } _MARKUP_CODE: ClassVar = { "rst": ("``", "``"), "m2r": ("`", "`"), "html": ("", ""), } # Per-parser list of all known *inline* markers (used for stripping). # Block-level markers are intentionally excluded. _KNOWN_FORMATS: ClassVar = build_known_formats( _MARKUP_BOLD, _MARKUP_ITALIC, _MARKUP_STRIKETHROUGH, _MARKUP_CODE, ) # (action-name, icon, tooltip, markup-dict) for each inline button _INLINE_DEFS: ClassVar = ( ("bold-text", "format-text-bold-symbolic", "Bold", _MARKUP_BOLD), ( "italic-text", "format-text-italic-symbolic", "Italic", _MARKUP_ITALIC, ), ( "strikethrough-text", "format-text-strikethrough-symbolic", "Strikethrough", _MARKUP_STRIKETHROUGH, ), ( "code-text", "format-text-code-symbolic", "Inline Code", _MARKUP_CODE, ), ) # ------------------------------------------------------------------ block # Block markup values may be a plain ``(before, after)`` tuple or a # callable ``(editor_pref) -> (before, after)`` for formats whose markers # depend on editor settings (e.g. RST indentation). _MARKUP_BLOCKQUOTE: ClassVar = { "rst": lambda pref: ( " " * pref.tab_width if pref.spaces_instead_of_tabs else "\t", "", ), "m2r": ("> ", ""), "html": ("
    ", "
    "), } # (action-name, icon, tooltip, markup-dict) for each block button _BLOCK_DEFS: ClassVar = ( ( "blockquote", "format-text-blockquote-symbolic", "Blockquote", _MARKUP_BLOCKQUOTE, ), ) def __init__(self, editor, renderer, parser, preferences): """Create all formatting actions. :param editor: The :class:`~formiko.sourceview.SourceView` instance. :param renderer: The :class:`~formiko.renderer.Renderer` instance used to query the active parser via ``renderer.get_parser()``. :param parser: The initial parser name (used to set enabled state). :param preferences: The :class:`~formiko.user.UserPreferences` object; its ``.editor`` attribute is consulted for RST indent settings. """ super().__init__() self._editor = editor self._renderer = renderer self._editor_pref = preferences.editor is_markup = parser in FORMATTING_PARSERS self._actions: dict[str, Gio.SimpleAction] = {} for name, _icon, _tooltip, markup_dict in self._INLINE_DEFS: action = Gio.SimpleAction.new(name, None) action.connect("activate", self._make_inline_handler(markup_dict)) action.set_enabled(is_markup) self.add_action(action) self._actions[name] = action for name, _icon, _tooltip, markup_dict in self._BLOCK_DEFS: action = Gio.SimpleAction.new(name, None) action.connect("activate", self._make_block_handler(markup_dict)) action.set_enabled(is_markup) self.add_action(action) self._actions[name] = action # Header actions: one per level (1-6) for reliable keyboard shortcuts for level in range(1, 7): action = Gio.SimpleAction.new(f"header-{level}", None) action.connect( "activate", lambda _a, _p, lvl=level: self._on_header_level(lvl), ) action.set_enabled(is_markup) self.add_action(action) self._actions[f"header-{level}"] = action # Bullet-list action bullet_action = Gio.SimpleAction.new("bullet", None) bullet_action.connect("activate", self._on_bullet) bullet_action.set_enabled(is_markup) self.add_action(bullet_action) self._actions["bullet"] = bullet_action # Ordered (numbered) list action ordered_action = Gio.SimpleAction.new("ordered", None) ordered_action.connect("activate", self._on_ordered) ordered_action.set_enabled(is_markup) self.add_action(ordered_action) self._actions["ordered"] = ordered_action # Insert-link action link_action = Gio.SimpleAction.new("insert-link", None) link_action.connect("activate", self._on_insert_link) link_action.set_enabled(is_markup) self.add_action(link_action) self._actions["insert-link"] = link_action # ------------------------------------------------------------------ # Public API def set_parser(self, parser: str) -> None: """Enable or disable all formatting actions for *parser*.""" is_markup = parser in FORMATTING_PARSERS for action in self._actions.values(): action.set_enabled(is_markup) @staticmethod def create_bar() -> Gtk.Box: """Return a :class:`Gtk.Box` containing the full formatting toolbar. Inline buttons and block buttons are placed in separate linked groups with a visual separator between them. The caller inserts the returned box into the UI. This is a static method so that the bar can be built before the action group instance is registered — GTK resolves ``fmt.*`` actions lazily. """ G = FormattingActionGroup # noqa: N806 outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) inline_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) inline_box.add_css_class("linked") for name, icon, tooltip, _ in G._INLINE_DEFS: # noqa: SLF001 inline_box.append( IconButton( symbol=icon, tooltip=tooltip, action_name=f"fmt.{name}", focus_on_click=False, ), ) inline_box.append( IconButton( symbol="insert-link-symbolic", tooltip="Insert Link", action_name="fmt.insert-link", focus_on_click=False, ), ) outer.append(inline_box) outer.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) block_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) block_box.add_css_class("linked") # Simple block buttons (blockquote, …) for name, icon, tooltip, _ in G._BLOCK_DEFS: # noqa: SLF001 block_box.append( IconButton( symbol=icon, tooltip=tooltip, action_name=f"fmt.{name}", focus_on_click=False, ), ) # Header dropdown button header_menu = Gio.Menu() for level in range(1, 7): header_menu.append(f"Header {level}", f"fmt.header-{level}") header_btn = Gtk.MenuButton( icon_name="format-text-larger-symbolic", tooltip_text="Header level", menu_model=header_menu, focus_on_click=False, ) block_box.append(header_btn) block_box.append( IconButton( symbol="view-list-bullet-symbolic", tooltip="Bullet list", action_name="fmt.bullet", focus_on_click=False, ), ) block_box.append( IconButton( symbol="view-list-ordered-symbolic", tooltip="Ordered list", action_name="fmt.ordered", focus_on_click=False, ), ) outer.append(block_box) return outer # ------------------------------------------------------------------ # Private helpers def _make_inline_handler(self, markup_dict): """Return an activate callback for an inline format action.""" def handler(_action, *_params): parser = self._renderer.get_parser() before, after = markup_dict[parser] known = self._KNOWN_FORMATS.get(parser, []) self._editor.toggle_format(before, after, known) return handler def _make_block_handler(self, markup_dict): """Return an activate callback for a block format action.""" def handler(_action, *_params): parser = self._renderer.get_parser() fmt = markup_dict[parser] if callable(fmt): before, after = fmt(self._editor_pref) else: before, after = fmt if parser == "rst": # RST blockquote is indentation; strip bullet/ordered self._editor.toggle_line_format( before, after, all_block_variants=(("- ", ""),), strip_ordered=True, ) elif parser == "m2r": self._editor.toggle_line_format( before, after, all_block_variants=self._MD_BLOCK_VARIANTS, strip_ordered=True, ) else: # html self._editor.toggle_line_format( before, after, all_block_variants=self._HTML_BLOCK_VARIANTS, strip_ordered=True, ) return handler @staticmethod def _header_markup(parser: str, level: int) -> "tuple[str, str]": """Return ``(before, after)`` for the given header *level*.""" if parser == "m2r": return "#" * level + " ", "" # html return f"", f"" def _on_header_level(self, level: int) -> None: """Activate callback for the ``header-N`` actions.""" parser = self._renderer.get_parser() if parser == "rst": self._editor.toggle_rst_header(RST_HEADER_CHARS[level - 1]) else: before, after = self._header_markup(parser, level) all_variants = [ self._header_markup(parser, lvl) for lvl in range(1, 7) ] if parser == "m2r": extra = (("> ", ""), ("- ", "")) else: # html extra = (("
    ", "
    "), ("
  • ", "
  • ")) self._editor.toggle_line_exclusive( before, after, all_variants, extra_strip_variants=extra, strip_ordered=True, ) # Known block-level prefixes (used for cross-format stripping). # Ordered: strips these when toggling *on* (regex handles bullet→ordered). # Bullet/header/blockquote: strip each other via all_block_variants / # extra_strip_variants and strip_ordered=True. _MD_BLOCK_VARIANTS: ClassVar = ( ("###### ", ""), ("##### ", ""), ("#### ", ""), ("### ", ""), ("## ", ""), ("# ", ""), ("> ", ""), ("- ", ""), ) _HTML_BLOCK_VARIANTS: ClassVar = tuple( [(f"", f"") for n in range(1, 7)] + [("
    ", "
    "), ("
  • ", "
  • ")], ) def _on_bullet(self, _action, *_params) -> None: """Activate callback for the ``bullet`` action.""" parser = self._renderer.get_parser() if parser == "rst": self._editor.toggle_bullet( "- ", "", (), needs_blank=True, strip_ordered=True, ) elif parser == "m2r": self._editor.toggle_bullet( "- ", "", self._MD_BLOCK_VARIANTS, needs_blank=True, strip_ordered=True, ) else: # html self._editor.toggle_bullet( "
  • ", "
  • ", self._HTML_BLOCK_VARIANTS, needs_blank=False, ) def _on_ordered(self, _action, *_params) -> None: """Activate callback for the ``ordered`` action.""" parser = self._renderer.get_parser() if parser == "rst": self._editor.toggle_ordered( all_block_variants=(("- ", ""),), needs_blank=True, auto_number=True, ) elif parser == "m2r": self._editor.toggle_ordered( all_block_variants=self._MD_BLOCK_VARIANTS, needs_blank=True, ) else: # html — numbered list items use the same
  • as bullet self._editor.toggle_bullet( "
  • ", "
  • ", self._HTML_BLOCK_VARIANTS, needs_blank=False, ) def _on_insert_link(self, _action, *_params) -> None: """Activate callback for the ``insert-link`` action.""" parser = self._renderer.get_parser() selected = self._editor.get_selected_text() # Capture offsets now — selection will be lost when dialog grabs focus start_off, end_off = self._editor.get_selection_offsets() def do_insert(text, url): link = compute_link(text, url, parser) self._editor.insert_link_text(link, start_off, end_off) root = self._editor.get_root() dlg = InsertLinkDialog(do_insert, selected, parser) dlg.present_and_focus(root) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/formiko/json_preview.py0000664000175000017500000003156715152030611016705 0ustar00mcbigmcbig"""JSON preview with folding, expanding, and highlighting in WebKit.""" from __future__ import annotations import contextlib from concurrent.futures import ThreadPoolExecutor from html import escape from importlib.resources import files from json import dumps, loads from typing import Any from gi.repository import GLib, Gtk from gi.repository.WebKit import LoadEvent, WebView from jsonpath_ng.exceptions import JsonPathParserError from jsonpath_ng.ext import parse as json_parse JS_EXPAND_HIGHLIGHT = r""" const highlights = __HIGHLIGHTS__; const expands = __EXPANDS__; document .querySelectorAll('.jblock') .forEach(el => el.classList.add('collapsed') ); expands.forEach(p => { const el = document.querySelector(`[data-jpath="${p}"]`); if (el) el.classList.remove('collapsed'); }); highlights.forEach(p => { const el = document.querySelector(`[data-jpath="${p}"]`); if (el) el.classList.add('jhighlight'); }); """ JS_EXPAND_ALL = """ document.querySelectorAll('.jblock').forEach( el => el.classList.remove('collapsed') ); """ JS_COLLAPSE_ALL = """ document.querySelectorAll('.jblock:not([data-jpath=""])').forEach( el => el.classList.add('collapsed') ); """ _EXECUTOR = ThreadPoolExecutor(max_workers=2) def compute_jsonpath_view(json_data, expression: str | None): # noqa: C901 """Return (data, highlights, expands, expr) for given JSONPath expression. - highlights: list[str] of matched node paths - expands: set[str] of all paths to expand (ancestors + matched + descendants) - expr: the original expression ('' if none/blank) """ if not expression or not expression.strip(): return json_data, [], {""}, "" try: expr = json_parse(expression) matches = expr.find(json_data) except JsonPathParserError: raise except Exception as e: msg = "Filter error" raise JsonPathParserError(msg) from e def collect_descendant_paths(val, base_path=""): paths = {base_path} if isinstance(val, dict): for k, v in val.items(): child = f"{base_path}.{k}" if base_path else k paths |= collect_descendant_paths(v, child) elif isinstance(val, list): for i, v in enumerate(val): child = f"{base_path}.[{i}]" if base_path else f"[{i}]" paths |= collect_descendant_paths(v, child) return paths highlights: list[str] = [] expands: set[str] = {""} # include root so the tree opens from top for m in matches: # expand ancestors current = m while current: p = str(current.full_path) if p == "$": p = "" expands.add(p) current = current.context # expand matched + descendants p = str(m.full_path) p = "" if p == "$" else p expands |= collect_descendant_paths(m.value, p) highlights.append(p) return json_data, highlights, expands, expression class JSONPreview: """Manage JSON parsing, filtering, and rendering. Provides a collapsible and highlighted HTML preview. """ def __init__(self, collapse_lines: int | None = None) -> None: self.collapse_lines = collapse_lines self._css: str | None = None self._js: str | None = None self._json_data: Any = None # These are set externally by the caller (e.g., Renderer) self.webview: WebView | None = None self._win: Gtk.Window | None = None self._tab_width = 2 self.filter_callback = None # optional callback: (expr, match_count) # -------------------------- Public API --------------------------------- def to_html(self, text: str, tab_width: int = 2) -> str: """Parse JSON text and return the initial full HTML representation. The parsed data is stored for later filtering. A one-shot ``load-changed`` handler is registered so that *jsonfold.js* is injected via :meth:`inject_fold_js` after the renderer loads the returned HTML — necessary because ``enable-javascript-markup`` is disabled for XSS protection. """ self._json_data = loads(text) self._tab_width = tab_width self._schedule_fold_injection() return self._generate_html(self._json_data) def inject_fold_js(self, webview: WebView) -> None: """Inject jsonfold.js into the current page via evaluate_javascript. Called from ``load-changed`` handlers instead of relying on the inline `` """ JS_SCROLL = """ window.scrollTo( 0, (document.documentElement.scrollHeight-window.innerHeight)*%f); """ JS_POSITION = """ window.scrollY/(document.documentElement.scrollHeight-window.innerHeight) """ MARKUP = """ %s """ class Renderer(Overlay): """Renderer widget, mainly based on Webkit.""" def __init__(self, win, parser="rst", writer="html4", style=""): super().__init__() self.fgcolor = "#000" self.bgcolor = "#fff" self.linkcolor = "#000" self.font_family = "sans-serif" self.font_size_px = 16 # WebKit default (≈ 12pt at 96 DPI) self.mono_family = "monospace" self.mono_size_px = 13 # WebKit default for monospace self.webview = WebView() self.webview.connect("mouse-target-changed", self.on_mouse) self.webview.connect("context-menu", self.on_context_menu) self.webview.connect("load-changed", self.on_load_changed) self.webview.connect("decide-policy", self.on_decide_policy) Adw.StyleManager.get_default().connect( "notify::dark", self.on_theme_changed, ) Gtk.Settings.get_default().connect( "notify::gtk-theme-name", self.on_theme_changed, ) self.connect("realize", lambda _w: self.on_theme_changed()) try: self._desktop_settings = Gio.Settings( schema_id="org.gnome.desktop.interface", ) self._desktop_settings.connect( "changed::document-font-name", self._on_system_font_changed, ) self._desktop_settings.connect( "changed::monospace-font-name", self._on_system_font_changed, ) except Exception: self._desktop_settings = None # non-GNOME desktop self.set_child(self.webview) web_settings = self.webview.get_settings() web_settings.set_enable_javascript_markup(False) # XSS Fix self._apply_system_font() controller = self.webview.get_find_controller() self.search_done = None controller.connect("found-text", self.on_found_text) controller.connect("failed-to-find-text", self.on_faild_to_find_text) self.label = Label() self.label.set_halign(Align.START) self.label.set_valign(Align.END) self.add_overlay(self.label) self.link_uri = None # Window reference must be available before parser initialization self.__win = win self.parser_instance = None self.set_writer(writer) self.set_parser(parser) self.style = style self.tab_width = 8 self.__position = -1 self.file_name = None self._loaded_context = None # (file_name, mime_type) of last finished self._pending_context = None # context of the load in progress self.pos = 0 self.src = None # None = no content yet; prevents spurious renders @staticmethod def _rgba_to_hex(rgba): """Convert Gdk.RGBA to #rrggbb hex string.""" r = round(rgba.red * 255) g = round(rgba.green * 255) b = round(rgba.blue * 255) return f"#{r:02x}{g:02x}{b:02x}" def _read_theme_colors(self): """Read background, foreground and accent colors from the widget style. Uses Adwaita named colors (view_bg_color, view_fg_color, accent_color) looked up on the realised widget so that the injected CSS always matches the active GTK theme without hardcoded values. Falls back to Adwaita defaults when a color is not found. """ ctx = self.get_style_context() found_bg, bg = ctx.lookup_color("view_bg_color") found_fg, fg = ctx.lookup_color("view_fg_color") found_ac, ac = ctx.lookup_color("accent_color") is_dark = self._is_dark() self.bgcolor = ( self._rgba_to_hex(bg) if found_bg else ("#1d1d1d" if is_dark else "#fafafa") ) self.fgcolor = ( self._rgba_to_hex(fg) if found_fg else ("#ffffff" if is_dark else "#2e2e2e") ) self.linkcolor = self._rgba_to_hex(ac) if found_ac else self.fgcolor def _read_system_font(self): """Read document and monospace font family and size from GNOME.""" try: settings = Gio.Settings(schema_id="org.gnome.desktop.interface") font_name = settings.get_string("document-font-name") or "" mono_name = settings.get_string("monospace-font-name") or "" except Exception: font_name = "" mono_name = "" if font_name: desc = Pango.FontDescription.from_string(font_name) self.font_family = desc.get_family() or self.font_family size = desc.get_size() self.font_size_px = round( (size / Pango.SCALE if size else 12) * _PT_TO_CSS_PX, ) if mono_name: mono_desc = Pango.FontDescription.from_string(mono_name) self.mono_family = mono_desc.get_family() or self.mono_family mono_size = mono_desc.get_size() self.mono_size_px = round( (mono_size / Pango.SCALE if mono_size else 12) * _PT_TO_CSS_PX, ) def _apply_system_font(self): """Apply system document and monospace fonts to WebKit settings.""" self._read_system_font() web_settings = self.webview.get_settings() web_settings.set_default_font_family(self.font_family) web_settings.set_default_font_size(self.font_size_px) web_settings.set_monospace_font_family(self.mono_family) web_settings.set_default_monospace_font_size(self.mono_size_px) def _on_system_font_changed(self, _settings, _key): """React to system font change and re-render the preview.""" self._apply_system_font() idle_add(self._apply_theme_and_render) @staticmethod def _is_dark(): """Return True if dark mode is active. Checks both Adwaita StyleManager and the GTK theme name so that the renderer updates regardless of whether the user switches via GNOME Settings (color-scheme) or GNOME Tweaks (gtk-theme-name). """ if Adw.StyleManager.get_default().get_dark(): return True theme = Gtk.Settings.get_default().get_property("gtk-theme-name") return "dark" in theme.lower() def on_theme_changed(self, *_): """Schedule a theme colour update after the CSS cascade settles.""" idle_add(self._apply_theme_and_render) def _apply_theme_and_render(self): """Read current theme colours and re-render the preview. Called via idle_add so the GTK/libadwaita CSS cascade has finished recalculating before lookup_color() is invoked. """ self._read_theme_colors() background = Gdk.RGBA() background.parse(self.bgcolor) self.webview.set_background_color(background) self.do_render() @property def position(self): """Return cursor position.""" self.__position = -1 self.webview.evaluate_javascript( JS_POSITION, -1, None, None, None, self.on_position_callback, ) while self.__position < 0: Gtk.main_iteration() return self.__position def on_position_callback(self, webview, result): """Set cursor position value.""" try: js_res = webview.evaluate_javascript_finish(result) self.__position = js_res.get_js_value().to_double() except Error: self.__position = 0 def on_mouse(self, webview, hit_test_result, modifiers): """Show url links on mouse over.""" self.link_uri = None if hit_test_result.context_is_link(): self.link_uri = hit_test_result.get_link_uri() text = "link: " + self.link_uri elif hit_test_result.context_is_image(): text = "image:" + hit_test_result.get_image_uri() elif hit_test_result.context_is_media(): text = "media: " + hit_test_result.get_media_uri() else: if self.label.is_visible(): self.label.hide() return self.label.set_markup(MARKUP % text.replace("&", "&")) self.label.show() def on_context_menu(self, _webview, _menu, _hit_test_result): """No action on webkit context menu.""" return True # disable context menu for now def on_decide_policy(self, _webview, decision, _decision_type): """Intercept link navigation. Open files internally, others externally. Scroll to anchor for internal same-file links. """ if not isinstance(decision, NavigationPolicyDecision): return False action = decision.get_navigation_action() if action.get_navigation_type() != NavigationType.LINK_CLICKED: return False uri = action.get_request().get_uri() decision.ignore() if uri.startswith("file://"): parts = uri[7:].split("#", 1) file_path = unquote(parts[0]) anchor = unquote(parts[1]) if len(parts) > 1 else None if anchor and file_path == self.file_name: self.scroll_to_anchor(anchor) else: self.find_and_opendocument(file_path) else: Gtk.show_uri(None, uri, Gdk.CURRENT_TIME) return True def scroll_to_anchor(self, anchor): """Scroll to a named anchor in the current document.""" anchor_js = dumps(anchor) self.webview.evaluate_javascript( ( f"var el = document.getElementById({anchor_js})" f" || document.querySelector('a[name={anchor_js}]');" " if (el) el.scrollIntoView();" ), -1, None, None, None, None, ) def find_and_opendocument(self, file_path): """Find file on disk and open it.""" ext = splitext(file_path)[1] if not ext: for ext in LANG_BY_EXT: tmp = file_path + ext if exists(tmp): file_path = tmp break else: # No existing file found; inherit the source file's # extension so a new file gets the same type as the # document it came from. src_ext = splitext(self.file_name)[1] if self.file_name else "" if src_ext in LANG_BY_EXT: file_path += src_ext ext = src_ext if ext in LANG_BY_EXT: self.__win.open_document(file_path) elif exists(file_path): Gtk.show_uri(None, "file://" + file_path, Gdk.CURRENT_TIME) else: dialog = FileNotFoundDialog(file_path) run_alert_dialog(dialog, self.__win) def set_writer(self, writer): """Set renderer writer.""" assert writer in WRITERS self.__writer = WRITERS[writer] klass = self.__writer["class"] self.writer_instance = klass() if klass is not None else None idle_add(self.do_render) def get_writer(self): """Return renderer writer.""" return self.__writer["key"] def set_parser(self, parser): """Set renderer parser.""" assert parser in PARSERS self.__parser = PARSERS[parser] klass = self.__parser["class"] self.parser_instance = klass() if klass is not None else None if isinstance(self.parser_instance, JSONPreview): self.parser_instance.webview = self.webview self.parser_instance._win = self.__win # noqa: SLF001 idle_add(self.do_render) def get_parser(self): """Return renderer parser.""" return self.__parser["key"] def json_expand_all(self): """Expand all collapsed JSON elements.""" if isinstance(self.parser_instance, JSONPreview): self.parser_instance.expand_all() def json_collapse_all(self): """Collapse all expanded JSON elements.""" if isinstance(self.parser_instance, JSONPreview): self.parser_instance.collapse_all() def set_style(self, style): """Set style for webview.""" self.style = style self._loaded_context = ( None # force full page reload to apply new stylesheet ) idle_add(self.do_render) def get_style(self): """Return selected style.""" return self.style def set_tab_width(self, width): """Set tab width.""" self.tab_width = width idle_add(self.do_render) def render_output(self): # noqa: C901, PLR0911 """Render source and return output.""" if getattr(self, "src", None) is None: return False, "", "text/plain" try: if self.__parser["class"] is None: html = NOT_FOUND.format(**self.__parser) elif self.__writer["class"] is None: html = NOT_FOUND.format(**self.__writer) elif issubclass(self.__parser["class"], JSONPreview): try: parser = self.parser_instance html = parser.to_html(self.src, self.tab_width) except (ValueError, TypeError) as e: return False, DATA_ERROR % ("JSON", str(e)), "text/html" return True, html, "text/html" elif not issubclass(self.__parser["class"], HtmlPreview): settings = { "warning_stream": StringIO(), "embed_stylesheet": True, "tab_width": self.tab_width, "file_name": self.file_name, } if self.style: settings["stylesheet"] = self.style settings["stylesheet_path"] = [] kwargs = { "source": self.src, "source_path": self.file_name, "parser": self.parser_instance, "writer": self.writer_instance, "writer_name": "html", "settings_overrides": settings, } if self.__writer["key"] == "pep": kwargs["reader_name"] = "pep" kwargs.pop("parser") # pep is allways rst html = publish_string(**kwargs).decode("utf-8") return True, html, "text/html" except DataError as e: return False, DATA_ERROR % ("Data", e), "text/html" except NotImplementedError: exc_str = format_exc() return False, NOT_IMPLEMENTED_ERROR % exc_str, "text/html" except BaseException: exc_str = format_exc() return False, EXCEPTION_ERROR % exc_str, "text/html" # output to file or html preview return False, self.src, "text/html" @staticmethod def _extract_body(html): """Extract the innerHTML of from an HTML string, or None.""" start = html.find("") if start < 0 or end < 0: return None tag_end = html.find(">", start) if tag_end < 0: return None return html[tag_end + 1: end] def do_render(self): """Render the source, and show rendered output.""" # Skip until content has been explicitly set via render() or # load_file(). set_writer/set_parser/set_tab_width all call # idle_add(do_render) during initialisation before any content exists; # without this guard they each trigger a full WebKit load_bytes() with # empty HTML. if self.src is None: return state, html, mime_type = self.render_output() if html and self.__win.runing: if mime_type == "text/html" and "" in html: if not self.style: theme_css = ( f"" ) html = html.replace("", theme_css + "", 1) context = (self.file_name, mime_type) if self._loaded_context == context: body_html = self._extract_body(html) if body_html is not None: fgcolor = dumps(self.fgcolor) body_html = dumps(body_html) self.webview.evaluate_javascript( ( f"document.fgColor={fgcolor};" f"document.body.innerHTML={body_html};" ), -1, None, None, None, None, ) if hasattr(self.parser_instance, "inject_fold_js"): self.parser_instance.inject_fold_js(self.webview) self.scroll_to_position(self.pos) return file_name = self.file_name or get_home_dir() self._pending_context = (self.file_name, mime_type) self.webview.load_bytes( Bytes(html.encode("utf-8")), mime_type, "UTF-8", "file://" + file_name, ) if state: self.scroll_to_position(self.pos) def render(self, src, file_name, pos=0): """Add render task to ui queue.""" self.src = src self.pos = pos self.file_name = file_name idle_add(self.do_render) def print_page(self): """Print the rendered page.""" po = PrintOperation.new(self.webview) po.connect("failed", self.on_print_failed) po.run_dialog(self.__win) def on_print_failed(self, _, error): """Log error when print failed.""" # FIXME: if dialog is used, application will lock :-( log_default_handler( "Application", LogLevelFlags.LEVEL_WARNING, error.message, ) def on_load_changed(self, _webview, load_event): """On page load handler. Set foreground color and restore scroll when page finishes loading. """ if load_event != LoadEvent.FINISHED: return self._loaded_context = self._pending_context self.webview.evaluate_javascript( f"document.fgColor='{self.fgcolor}'", -1, None, None, None, None, ) self.scroll_to_position(None) def do_next_match(self, text): """Find next metch.""" controller = self.webview.get_find_controller() if controller.get_search_text() != text: self.search_done = None controller.search(text, FindOptions.WRAP_AROUND, MAXUINT) while self.search_done is None: MainContext.default().iteration(False) elif self.search_done: controller.search_next() return self.search_done def do_previous_match(self, text): """Find previous match.""" controller = self.webview.get_find_controller() if controller.get_search_text() != text: self.search_done = None controller.search( text, FindOptions.WRAP_AROUND | FindOptions.BACKWARDS, MAXUINT, ) while self.search_done is None: MainContext.default().iteration(False) elif self.search_done: controller.search_previous() return self.search_done def stop_search(self): """Stop searching.""" controller = self.webview.get_find_controller() controller.search_finish() def on_found_text(self, *_): """Mark search as done.""" self.search_done = True def on_faild_to_find_text(self, _): """Mark search as not done.""" self.search_done = False def scroll_to_position(self, position): """Scroll to right cursor position.""" if position is not None: self.pos = position if self.pos > 1: # vim a, b = len(self.src[:self.pos]), len(self.src[self.pos:]) position = (float(a) / (a + b)) if a or b else 0 else: position = self.pos self.webview.evaluate_javascript( JS_SCROLL % position, -1, None, None, None, None, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1774599979.9847155 formiko-2.0.0/formiko/shortcuts.py0000664000175000017500000001733115161437454016242 0ustar00mcbigmcbig"""Shortcuts widgets.""" from gi.repository import Gtk from gi.repository.Gtk import ( ShortcutsGroup, ShortcutsSection, ShortcutsShortcut, ) from formiko.editor import EditorType class SourceGroup(ShortcutsGroup): """Shortcut group for SourceView.""" def __init__(self): super().__init__(title="Editor") self.append(ShortcutsShortcut(accelerator="c", title="Copy")) self.append(ShortcutsShortcut(accelerator="x", title="Cut")) self.append(ShortcutsShortcut(accelerator="v", title="Paste")) self.append( ShortcutsShortcut(accelerator="a", title="Select All"), ) self.append( ShortcutsShortcut( accelerator="Home", title="Go to Begin of Document", ), ) self.append( ShortcutsShortcut( accelerator="End", title="Go to End of Document", ), ) class FindGroup(ShortcutsGroup): """Shortcuts group for searching.""" def __init__(self): super().__init__(title="Find") self.append( ShortcutsShortcut( accelerator="f", title="Find in Document / Find another match in same way", ), ) self.append( ShortcutsShortcut( accelerator="g", title="Find next match", ), ) self.append( ShortcutsShortcut( accelerator="g", title="Find previous match", ), ) class VimGroup(ShortcutsGroup): """Vim shortcuts group.""" def __init__(self): super().__init__(title="Vim") self.append(ShortcutsShortcut(accelerator="y", title="Copy")) self.append(ShortcutsShortcut(accelerator="x", title="Cut")) self.append(ShortcutsShortcut(accelerator="p", title="Paste")) self.append( ShortcutsShortcut( accelerator="Escape g g", title="Go to Begin of Document", ), ) self.append( ShortcutsShortcut( accelerator="Escape G", title="Go to End of Document", ), ) self.append( ShortcutsShortcut( accelerator="Escape g g v G", title="Select All", ), ) class PreviewGroup(ShortcutsGroup): """Preview shortcuts group.""" def __init__(self): super().__init__(title="Preview") self.append( ShortcutsShortcut( accelerator="r", title="Refresh preview", ), ) self.append( ShortcutsShortcut(accelerator="e", title="Show editor only"), ) self.append( ShortcutsShortcut(accelerator="p", title="Show preview only"), ) self.append(ShortcutsShortcut(accelerator="b", title="Show both")) class TabsGroup(ShortcutsGroup): """Shortcuts group for tab management.""" def __init__(self): super().__init__(title="Tabs") self.append( ShortcutsShortcut(accelerator="t", title="New Tab"), ) self.append( ShortcutsShortcut(accelerator="w", title="Close Tab"), ) self.append( ShortcutsShortcut( accelerator="Tab Page_Down", title="Switch to Next Tab", ), ) self.append( ShortcutsShortcut( accelerator="Tab Page_Up", title="Switch to Previous Tab", ), ) class GeneralGroup(ShortcutsGroup): """General application shortcuts group.""" def __init__(self, editor_type): super().__init__(title="Genaral") self.append( ShortcutsShortcut(accelerator="n", title="New Document"), ) self.append( ShortcutsShortcut(accelerator="o", title="Open Document"), ) if editor_type == "source": self.append( ShortcutsShortcut( accelerator="s", title="Save Document", ), ) self.append( ShortcutsShortcut( accelerator="s", title="Save Document As", ), ) elif editor_type == "vim": self.append( ShortcutsShortcut( accelerator="Escape colon w", title="Save Document Vim", ), ) self.append( ShortcutsShortcut( accelerator="e", title="Export Document As", ), ) self.append( ShortcutsShortcut( accelerator="p", title="Print Document", ), ) self.append( ShortcutsShortcut(accelerator="F9", title="Show File Browser"), ) self.append( ShortcutsShortcut(accelerator="q", title="Quit Formiko"), ) self.append( ShortcutsShortcut(accelerator="F10", title="Main Menu"), ) self.append( ShortcutsShortcut( accelerator="question", title="Keyboard Shortcuts", ), ) class FormattingGroup(ShortcutsGroup): """Shortcuts group for formatting actions.""" def __init__(self): super().__init__(title="Formatting") self.append( ShortcutsShortcut(accelerator="b", title="Bold"), ) self.append( ShortcutsShortcut(accelerator="i", title="Italic"), ) self.append( ShortcutsShortcut( accelerator="x", title="Strikethrough", ), ) self.append( ShortcutsShortcut( accelerator="c", title="Inline Code", ), ) self.append( ShortcutsShortcut( accelerator="k", title="Insert Link", ), ) self.append( ShortcutsShortcut( accelerator="q", title="Blockquote", ), ) self.append( ShortcutsShortcut( accelerator="b", title="Bullet List", ), ) self.append( ShortcutsShortcut( accelerator="n", title="Numbered List", ), ) for level in range(1, 7): self.append( ShortcutsShortcut( accelerator=f"{level}", title=f"Header {level}", ), ) class ShortcutsWindow(Gtk.ShortcutsWindow): """Shortcuts window widget.""" def __init__(self, editor_type: EditorType): # view_name and view does not work. Don't know why super().__init__(modal=True) sec = ShortcutsSection(title="Formiko", visible=True, max_height=15) sec.add_group(GeneralGroup(editor_type)) sec.add_group(TabsGroup()) sec.add_group(PreviewGroup()) sec.add_group(FindGroup()) if editor_type == EditorType.SOURCE: sec.add_group(SourceGroup()) sec.add_group(FormattingGroup()) elif editor_type == EditorType.VIM: sec.add_group(VimGroup()) self.add_section(sec) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1774599979.9847155 formiko-2.0.0/formiko/sourceview.py0000664000175000017500000010132615161437454016375 0ustar00mcbigmcbig"""SourceView based editor widget..""" import re from os import fstat, rename, stat from os.path import basename, dirname, exists, isfile, splitext from sys import stderr from traceback import format_exc from gi.repository import Adw, Gdk, Gio, GObject, Gtk, GtkSource, Pango from gi.repository.GLib import ( UserDirectory, Variant, get_user_special_dir, idle_add, timeout_add, timeout_add_seconds, ) from gi.repository.GtkSource import ( Buffer, SearchContext, SearchSettings, View, ) from formiko.dialogs import ( LANG_BY_EXT, FileChangedDialog, TraceBackDialog, build_save_filters, run_alert_dialog, save_file_dialog, ) from formiko.format_utils import ( compute_toggle_bullet, compute_toggle_format, compute_toggle_line_exclusive, compute_toggle_line_format, compute_toggle_ordered, compute_toggle_rst_header, ) from formiko.widgets import ActionHelper, ImutableDict try: from gi.repository import Spelling _SPELLING_AVAILABLE = True except ImportError: _SPELLING_AVAILABLE = False PERIOD_SAVE_TIME = 300 # 5min # Matches ordered-list prefix with at least one content char: " 2. x" _RE_ORDERED_CONT = re.compile(r"^(\s*)(\d+)\. \S") # Matches bullet-list prefix with at least one content char: " - x" _RE_BULLET_CONT = re.compile(r"^(\s*)([-*] )\S") def _is_list_line(line_text: str) -> bool: """Return True if the line begins with a list marker (after any indent).""" return bool(re.match(r"^\s*([-*] |\d+\. )", line_text)) def _list_continuation_marker(line_text: str) -> "str | None": """Return the list marker to insert on the next line, or ``None``.""" m = _RE_ORDERED_CONT.match(line_text) if m: return f"{m.group(1)}{int(m.group(2)) + 1}. " m = _RE_BULLET_CONT.match(line_text) if m: return f"{m.group(1)}{m.group(2)}" return None class SourceView(Gtk.ScrolledWindow, ActionHelper): """Widget containted SourceView.""" __file_name = "" __last_changes = 0 __last_ctime = 0 __pause_period = False __gsignals__ = ImutableDict( { "file-type": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "scroll-changed": (GObject.SignalFlags.RUN_LAST, None, (float,)), }, ) action_name = GObject.Property(type=str) action_target = GObject.Property(type=GObject.TYPE_VARIANT) def __init__(self, win, preferences, action_name=None): GtkSource.init() if _SPELLING_AVAILABLE: Spelling.init() super().__init__() if action_name: self.action_name = action_name self.set_hexpand(True) self.set_vexpand(True) self.text_buffer = Buffer.new_with_language( LANG_BY_EXT["." + preferences.parser], ) self.text_buffer.connect("changed", self.inc_changes) self.source_view = View.new_with_buffer(self.text_buffer) self.setup_list_features() adj = self.get_vadjustment() adj.connect("value-changed", self.on_scroll_changed) # Set monospace font from system settings (GNOME monospace-font-name) self._font_css = Gtk.CssProvider() self.source_view.get_style_context().add_provider( self._font_css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) self._apply_system_font() try: self._desktop_settings = Gio.Settings( schema_id="org.gnome.desktop.interface", ) self._desktop_settings.connect( "changed::monospace-font-name", self._on_system_font_changed, ) except Exception: # noqa: S110 pass # schema not available (non-GNOME desktop) self.set_child(self.source_view) # Follow Adwaita dark/light mode for syntax highlighting scheme self._apply_color_scheme(self._is_dark()) Adw.StyleManager.get_default().connect( "notify::dark", self.on_style_scheme_changed, ) Gtk.Settings.get_default().connect( "notify::gtk-theme-name", self.on_style_scheme_changed, ) # Initialize spell checker self.spell_adapter = None self.checker = None if _SPELLING_AVAILABLE: self.checker = Spelling.Checker.get_default() self.spell_adapter = Spelling.TextBufferAdapter.new( self.text_buffer, self.checker, ) self.spell_adapter.set_enabled(False) # off until user enables self.source_view.insert_action_group( "spelling", self.spell_adapter, ) self.source_view.set_extra_menu( self.spell_adapter.get_menu_model(), ) # Connect to checker (not adapter) — context menu's language action # calls spelling_checker_set_language() directly, bypassing the # adapter's own notify::language signal. self.checker.connect( "notify::language", self.on_language_changed, ) editor_pref = preferences.editor self.set_period_save(editor_pref.period_save) self.set_check_spelling( editor_pref.check_spelling, editor_pref.spell_lang, ) self.set_spaces_instead_of_tabs(editor_pref.spaces_instead_of_tabs) self.source_view.set_tab_width(editor_pref.tab_width) self.source_view.set_auto_indent(editor_pref.auto_indent) self.source_view.set_show_line_numbers(editor_pref.line_numbers) self.source_view.set_right_margin_position( editor_pref.right_margin_value, ) self.source_view.set_show_right_margin(editor_pref.right_margin) self.source_view.set_highlight_current_line(editor_pref.current_line) self.set_text_wrapping(editor_pref.text_wrapping) self.set_white_chars(editor_pref.white_chars) self._auto_bullet_pref = editor_pref.auto_bullet self._tab_indent_pref = editor_pref.tab_indent_bullet self.search_settings = SearchSettings(wrap_around=True) self.search_context = SearchContext.new( self.text_buffer, self.search_settings, ) self.search_mark = None self.__win = win timeout_add(200, self.check_in_thread) @property def changes(self): """Return number of changes.""" return self.__last_changes @property def is_modified(self): """Return if text is modified.""" return self.text_buffer.get_modified() @property def text(self): """Return text.""" return self.text_buffer.props.text @property def position(self): """Return cursor position.""" adj = self.source_view.get_vadjustment() hight = self.get_height() value = adj.get_value() if value: return adj.get_value() / (adj.get_upper() - hight) return 0 @property def file_name(self): """Return opened file name.""" return basename(self.__file_name) @property def file_path(self): """Return opened file name with path.""" return self.__file_name @property def file_ext(self): """Returned opened file extension.""" _, ext = splitext(self.__file_name) return ext def inc_changes(self, text_buffer): """Incrace changes from last storing to storage.""" self.__last_changes += 1 @staticmethod def _is_dark(): """Return True if dark mode is active. Checks both Adwaita StyleManager and the GTK theme name so that the correct colour scheme is applied regardless of whether the user switches via GNOME Settings (color-scheme) or GNOME Tweaks (gtk-theme-name). """ if Adw.StyleManager.get_default().get_dark(): return True theme = Gtk.Settings.get_default().get_property("gtk-theme-name") return "dark" in theme.lower() def _apply_color_scheme(self, is_dark): """Apply Adwaita or Adwaita-dark source view color scheme.""" scheme_id = "Adwaita-dark" if is_dark else "Adwaita" manager = GtkSource.StyleSchemeManager.get_default() scheme = manager.get_scheme(scheme_id) if scheme: self.text_buffer.set_style_scheme(scheme) def on_style_scheme_changed(self, *_): """React to dark/light mode change (Adwaita or GTK theme name).""" self._apply_color_scheme(self._is_dark()) def _apply_system_font(self): """Apply the system monospace font (GNOME gsettings) to the editor.""" font_name = "Monospace 11" try: settings = Gio.Settings(schema_id="org.gnome.desktop.interface") font_name = settings.get_string("monospace-font-name") or font_name except Exception: # noqa: S110 pass desc = Pango.FontDescription.from_string(font_name) family = desc.get_family() or "Monospace" size_pts = desc.get_size() / Pango.SCALE if desc.get_size() else 11 css = ( f'textview {{ font-family: "{family}"; font-size: {size_pts}pt; }}' ) self._font_css.load_from_string(css) def _on_system_font_changed(self, _settings, _key): """React to system monospace font change.""" self._apply_system_font() def change_mime_type(self, parser): """Change internal mime type for right syntax highlighting.""" language = LANG_BY_EXT.get("." + parser, LANG_BY_EXT[".rst"]) if self.text_buffer.get_language() != language: self.text_buffer.set_language(language) def on_language_changed(self, checker, pspec): """Proxy the language change action and re-check the buffer.""" if self.spell_adapter is not None: self.spell_adapter.invalidate_all() action, go = self.get_action_owner() if go: code = checker.get_language() or "" go.activate_action(action, Variant("s", code)) def on_scroll_changed(self, *_): """Emit scroll event.""" self.emit("scroll-changed", self.position) def setup_list_features(self): """Set up list auto-continuation and Tab-indent (initially disabled). Call :meth:`set_list_features_enabled` to activate/deactivate. Both features share :func:`_is_list_line` for bullet detection. """ self._list_handler_id = None self._tab_key_ctrl_active = False self._parser_supports_lists = False self._auto_bullet_pref = True self._tab_indent_pref = True self._tab_key_ctrl = Gtk.EventControllerKey.new() self._tab_key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) self._tab_key_ctrl.connect("key-pressed", self._on_tab_key_pressed) def set_list_features_enabled(self, enabled): """Enable or disable list features based on parser support.""" self._parser_supports_lists = enabled self._apply_auto_bullet() self._apply_tab_indent_bullet() def _apply_auto_bullet(self): """Apply auto-bullet state based on parser support and user pref.""" should = self._parser_supports_lists and self._auto_bullet_pref active = self._list_handler_id is not None if should == active: return if should: self._list_handler_id = self.text_buffer.connect( "insert-text", self._on_list_insert_text, ) else: self.text_buffer.disconnect(self._list_handler_id) self._list_handler_id = None def _apply_tab_indent_bullet(self): """Apply Tab-indent state based on parser support and user pref.""" should = self._parser_supports_lists and self._tab_indent_pref if should == self._tab_key_ctrl_active: return if should: self.source_view.add_controller(self._tab_key_ctrl) else: self.source_view.remove_controller(self._tab_key_ctrl) self._tab_key_ctrl_active = should self.source_view.set_indent_on_tab(should) def set_auto_bullet(self, enabled): """Set auto bullet continuation user preference.""" self._auto_bullet_pref = enabled self._apply_auto_bullet() def set_tab_indent_bullet(self, enabled): """Set Tab key indentation for bullets user preference.""" self._tab_indent_pref = enabled self._apply_tab_indent_bullet() def _on_list_insert_text(self, buf, location, text, _length): """Auto-continue list item marker after pressing Enter.""" if "\n" not in text: return line_start = location.copy() line_start.set_line_offset(0) line_text = buf.get_text(line_start, location, False) marker = _list_continuation_marker(line_text) if not marker: return def _insert(): cur = buf.get_iter_at_mark(buf.get_insert()) new_line_start = cur.copy() new_line_start.set_line_offset(0) already = buf.get_text(new_line_start, cur, False) if marker.startswith(already): rest = marker[len(already):] if rest: buf.insert_at_cursor(rest) return False idle_add(_insert) def _on_tab_key_pressed(self, _ctrl, keyval, _keycode, state): """Indent the current list-item line on Tab (no selection).""" if keyval != Gdk.KEY_Tab: return False if state & ( Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK | Gdk.ModifierType.ALT_MASK ): return False buf = self.text_buffer if buf.get_has_selection(): return False ins = buf.get_iter_at_mark(buf.get_insert()) line_start = ins.copy() line_start.set_line_offset(0) line_end = line_start.copy() if not line_end.ends_line(): line_end.forward_to_line_end() line_text = buf.get_text(line_start, line_end, False) if not _is_list_line(line_text): return False self.source_view.indent_lines(line_start, line_end) return True def set_period_save(self, save): """Set period save and start save thread.""" self.period_save = bool(save) * PERIOD_SAVE_TIME if save: self.period_save_thread() def set_check_spelling(self, check_spelling, spell_lang): """Set spell check.""" if self.spell_adapter is None: return if check_spelling: if spell_lang: self.spell_adapter.set_language(spell_lang) else: # No saved language: save the system default (GTK3 behaviour) self.on_language_changed(self.checker, None) self.spell_adapter.set_enabled(True) else: self.spell_adapter.set_enabled(False) def set_spaces_instead_of_tabs(self, use_spaces): """Set spaces instead of tabs.""" self.source_view.set_insert_spaces_instead_of_tabs(use_spaces) self.source_view.set_smart_backspace(use_spaces) def set_text_wrapping(self, text_wrapping): """Set text wrapping mode.""" if text_wrapping: self.source_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) else: self.source_view.set_wrap_mode(Gtk.WrapMode.NONE) def set_white_chars(self, white_chars): """Set white chars showing.""" space_drawer = self.source_view.get_space_drawer() space_drawer.set_enable_matrix(white_chars) def check_in_thread(self): """Check source file time. This function is called from GLib.timeout_add """ if not self.__file_name: timeout_add(200, self.check_in_thread) return if not self.__win.get_realized(): return # window was closed, stop the loop if not self.__win.is_active(): timeout_add(200, self.check_in_thread) return try: last_ctime = stat(self.__file_name).st_ctime if last_ctime > self.__last_ctime: self.__pause_period = True dialog = FileChangedDialog(self.__file_name) dialog.connect( "response", self._on_file_changed_response, last_ctime, ) dialog.present(self.__win) return # loop restarted in _on_file_changed_response except OSError: pass # file switching when modify by another software timeout_add(200, self.check_in_thread) def _on_file_changed_response(self, _dialog, response, last_ctime): """Handle response from the file-changed dialog.""" if response == "yes": cursor = self.text_buffer.get_insert() offset = self.text_buffer.get_iter_at_mark(cursor).get_offset() self.read_from_file(self.__file_name, offset) else: self.__last_ctime = last_ctime self.__pause_period = False timeout_add(200, self.check_in_thread) def period_save_thread(self): """Create timeouted save.""" if self.period_save: if ( self.__file_name and self.is_modified and not self.__pause_period ): idle_add(self.save_to_file) timeout_add_seconds(self.period_save, self.period_save_thread) def read_from_file(self, file_name, offset=0): """Read file and set all states.""" self.__file_name = file_name self.emit("file_type", self.file_ext) if isfile(file_name): with open(file_name, encoding="utf-8") as src: self.text_buffer.set_text(src.read()) self.__last_changes += 1 self.__last_ctime = fstat(src.fileno()).st_ctime self.text_buffer.set_modified(False) if offset > -1: cursor = self.text_buffer.get_iter_at_offset(offset) self.text_buffer.place_cursor(cursor) idle_add(self.scroll_to_cursor, cursor) def scroll_to_cursor(self, cursor): """Scroll to cursor position.""" self.source_view.scroll_to_iter(cursor, 0, 1, 1, 1) def save_to_file(self): """Save text to file.""" try: if exists(self.__file_name): rename(self.__file_name, self.__file_name + "~") with open(self.__file_name, "w", encoding="utf-8") as src: src.write(self.text) src.flush() self.__last_ctime = fstat(src.fileno()).st_ctime self.text_buffer.set_modified(False) except Exception: error = format_exc() if self.__win: md = TraceBackDialog(error) run_alert_dialog(md, self.__win) stderr.write(error) stderr.flush() def save(self): """Save the file.""" if not self.__file_name: self._open_save_dialog(self._after_save_dialog) else: self.save_to_file() def save_as(self): """Save the file as another.""" self._open_save_dialog(self._after_save_dialog) def _after_save_dialog(self, file_name): """Apply new file name and save.""" if file_name: self.__file_name = file_name self.emit("file_type", self.file_ext) self.save_to_file() def _open_save_dialog(self, callback): """Open Gtk.FileDialog for saving and call *callback(file_name)*.""" lang = self.text_buffer.get_language() filters, default_filter, suffix, filter_suffixes = build_save_filters( lang.get_id(), ) if self.__file_name: folder = dirname(self.file_path) name, _ = splitext(self.file_name) else: folder = get_user_special_dir(UserDirectory.DIRECTORY_DOCUMENTS) name = "Untitled document" save_file_dialog( self.__win, "Save Document", filters, default_filter, default_suffix=suffix, filter_suffixes=filter_suffixes, initial_folder=folder, initial_name=name, callback=callback, ) def do_file_type(self, ext): """Set file type for right syntax highlighting.""" if ext: language = LANG_BY_EXT.get(ext, LANG_BY_EXT[".rst"]) if self.text_buffer.get_language() != language: self.text_buffer.set_language(language) def do_next_match(self, text): """Find next search match.""" if self.search_settings.get_search_text() != text: self.search_settings.set_search_text(text) self.search_mark = self.text_buffer.get_insert() search_iter = self.text_buffer.get_iter_at_mark(self.search_mark) elif self.search_mark: search_iter = self.text_buffer.get_iter_at_mark(self.search_mark) search_iter.forward_char() else: return False found, search_iter, _end, _ = self.search_context.forward(search_iter) if not found: self.search_mark = None return False self.source_view.scroll_to_iter(search_iter, 0, 1, 1, 1) self.text_buffer.place_cursor(search_iter) return True def do_previous_match(self, text): """Find previous search match.""" if self.search_settings.get_search_text() != text: self.search_settings.set_search_text(text) self.search_mark = self.text_buffer.get_insert() search_iter = self.text_buffer.get_iter_at_mark(self.search_mark) elif self.search_mark: search_iter = self.text_buffer.get_iter_at_mark(self.search_mark) else: return False found, _start, search_iter, _ = self.search_context.backward( search_iter, ) if not found: self.search_mark = None return False search_iter.backward_chars(len(text)) self.source_view.scroll_to_iter(search_iter, 0, 1, 1, 1) self.text_buffer.place_cursor(search_iter) return True def get_selected_text(self) -> str: """Return the currently selected text, or an empty string.""" bounds = self.text_buffer.get_selection_bounds() if not bounds: return "" return self.text_buffer.get_text(bounds[0], bounds[1], False) def get_selection_offsets(self) -> "tuple[int, int]": """Return ``(start, end)`` character offsets of the current selection. When nothing is selected both values equal the cursor offset. """ bounds = self.text_buffer.get_selection_bounds() if bounds: return bounds[0].get_offset(), bounds[1].get_offset() cursor = self.text_buffer.get_iter_at_mark( self.text_buffer.get_insert(), ) off = cursor.get_offset() return off, off def _replace_range( self, start_off: int, end_off: int, new_text: str, ) -> None: """Replace buffer content between offsets and select *new_text*. This is the single low-level primitive used by all formatting methods that modify a character range. The replacement text is selected so the user can see what changed. Must be called inside ``begin_user_action / end_user_action`` or wraps its own pair when called standalone. """ buf = self.text_buffer start = buf.get_iter_at_offset(start_off) end = buf.get_iter_at_offset(end_off) buf.delete(start, end) ins = buf.get_iter_at_offset(start_off) buf.insert(ins, new_text) sel_s = buf.get_iter_at_offset(start_off) sel_e = buf.get_iter_at_offset(start_off + len(new_text)) buf.select_range(sel_s, sel_e) def toggle_format(self, before, after, known_formats): """Toggle formatting markers around selected text. Expands the selection to cover any adjacent known formatting markers, strips the detected format, then applies the requested format — or removes it when the selection is already wrapped with it. The original (inner) text remains selected after the operation. ``known_formats`` is a list of (before, after) tuples for the current file type. Longer markers are tested first so that ``**`` is never mis-detected as a pair of ``*`` markers. """ bounds = self.text_buffer.get_selection_bounds() if not bounds: return buf_text = self.text_buffer.props.text sel_start = bounds[0].get_offset() sel_end = bounds[1].get_offset() eff_start, eff_end, result, inner_start, inner_end = ( compute_toggle_format( buf_text, sel_start, sel_end, before, after, known_formats, ) ) self.text_buffer.begin_user_action() self._replace_range(eff_start, eff_end, result) # Restore selection over the inner (content) text only sel_iter = self.text_buffer.get_iter_at_offset( eff_start + inner_start, ) sel_end_iter = self.text_buffer.get_iter_at_offset( eff_start + inner_end, ) self.text_buffer.select_range(sel_iter, sel_end_iter) self.text_buffer.end_user_action() def insert_link_text( self, link_text: str, start_off: int, end_off: int, ) -> None: """Insert *link_text* at the given buffer offsets and select it. When *start_off == end_off* (no prior selection), the text is inserted at that position with padding spaces added as needed. Otherwise the range ``[start_off, end_off)`` is replaced. The inserted link text is selected after the operation so the user can see what was inserted. """ buf = self.text_buffer buf.begin_user_action() if start_off != end_off: self._replace_range(start_off, end_off, link_text) else: full = buf.props.text off = start_off prefix = "" if off == 0 or full[off - 1] in (" ", "\n") else " " suffix = ( "" if off >= len(full) or full[off] in (" ", "\n") else " " ) padded = prefix + link_text + suffix self._replace_range(off, off, padded) # Re-select only the link text (not padding) ins_start = off + len(prefix) sel_s = buf.get_iter_at_offset(ins_start) sel_e = buf.get_iter_at_offset(ins_start + len(link_text)) buf.select_range(sel_s, sel_e) buf.end_user_action() # ------------------------------------------------------------------ # Line-level formatting helpers # ------------------------------------------------------------------ def _get_current_line(self): """Return ``(line_num, start_iter, end_iter, text)`` for cursor.""" buf = self.text_buffer cursor = buf.get_iter_at_mark(buf.get_insert()) line_num = cursor.get_line() start = cursor.copy() start.set_line_offset(0) end = cursor.copy() if not end.ends_line(): end.forward_to_line_end() return line_num, start, end, buf.get_text(start, end, True) def _get_line_text(self, line_num): """Return the text of *line_num*, or ``None`` if invalid.""" if line_num < 0: return None buf = self.text_buffer _, start = buf.get_iter_at_line(line_num) end = start.copy() if not end.ends_line(): end.forward_to_line_end() return buf.get_text(start, end, True) def _replace_line(self, line_start, line_end, old_text, new_text): """Replace *old_text* with *new_text* between the two iters.""" if new_text != old_text: buf = self.text_buffer buf.begin_user_action() buf.delete(line_start, line_end) buf.insert(line_start, new_text) buf.end_user_action() # ------------------------------------------------------------------ # Public line-level formatting methods # ------------------------------------------------------------------ def toggle_line_format( self, before, after="", all_block_variants=(), strip_ordered=False, ): """Toggle block formatting for the line at the cursor.""" _, start, end, text = self._get_current_line() new = compute_toggle_line_format( text, before, after, all_block_variants=all_block_variants, strip_ordered=strip_ordered, ) self._replace_line(start, end, text, new) def toggle_line_exclusive( self, before, after, all_variants, extra_strip_variants=(), strip_ordered=False, ): """Toggle an exclusive block format on the current line.""" _, start, end, text = self._get_current_line() new = compute_toggle_line_exclusive( text, before, after, all_variants, extra_strip_variants=extra_strip_variants, strip_ordered=strip_ordered, ) self._replace_line(start, end, text, new) def toggle_rst_header(self, underline_char): """Toggle an RST heading underline on the current line.""" buf = self.text_buffer line_num, _, line_end, line_text = self._get_current_line() # Next line (if any) next_start = line_end.copy() next_start.forward_char() next_line_text = None next_line_end = None if next_start.get_line() > line_num: next_line_end = next_start.copy() if not next_line_end.ends_line(): next_line_end.forward_to_line_end() next_line_text = buf.get_text( next_start, next_line_end, True, ) new_underline, had_underline = compute_toggle_rst_header( line_text, next_line_text, underline_char, ) if new_underline is None and not had_underline: return buf.begin_user_action() if had_underline and next_line_end is not None: buf.delete(line_end, next_line_end) if new_underline is not None: _, cur_end = buf.get_iter_at_line(line_num) if not cur_end.ends_line(): cur_end.forward_to_line_end() buf.insert(cur_end, "\n" + new_underline) buf.end_user_action() def _toggle_list_item(self, compute_fn, needs_blank, **kwargs): """Shared implementation for bullet and ordered list toggle. Calls *compute_fn* with the current and (optionally) previous line texts plus any extra *kwargs*. Handles blank-line insertion and line replacement. """ buf = self.text_buffer line_num, line_start, line_end, line_text = self._get_current_line() prev = self._get_line_text(line_num - 1) if needs_blank else None new_text, insert_blank = compute_fn( line_text, prev, **kwargs, ) if new_text == line_text and not insert_blank: return buf.begin_user_action() if insert_blank: buf.insert(line_start, "\n") _, line_start = buf.get_iter_at_line(line_num + 1) line_end = line_start.copy() if not line_end.ends_line(): line_end.forward_to_line_end() if new_text != line_text: buf.delete(line_start, line_end) buf.insert(line_start, new_text) buf.end_user_action() def toggle_bullet( self, before, after, all_block_variants, needs_blank, strip_ordered=False, ): """Toggle bullet-list formatting on the current line.""" self._toggle_list_item( compute_toggle_bullet, needs_blank, before=before, after=after, all_block_variants=all_block_variants, strip_ordered=strip_ordered, ) def toggle_ordered( self, all_block_variants, needs_blank, auto_number=False, ): """Toggle ordered (numbered) list on the current line.""" self._toggle_list_item( compute_toggle_ordered, needs_blank, all_block_variants=all_block_variants, auto_number=auto_number, ) def stop_search(self): """Stop searching.""" self.search_settings.set_search_text(None) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1774009482.4368303 formiko-2.0.0/formiko/status_menu.py0000664000175000017500000002053215157236212016542 0ustar00mcbigmcbig"""Status bar menu widgets.""" from gi.repository import Gio, GLib, Gtk from formiko.widgets import ActionableSpinButton class StatusMenuButton(Gtk.MenuButton): """Status bar menu button.""" css = Gtk.CssProvider() css.load_from_string(""" * { border: 0; padding: 1px 8px 2px 4px; outline-width: 0; } """) def __init__(self, label, popover): super().__init__(popover=popover) ctx = self.get_style_context() ctx.add_provider( StatusMenuButton.css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0) self.set_child(box) self.label = Gtk.Label(label=label) box.append(self.label) icon = Gio.ThemedIcon(name="pan-down-symbolic") img = Gtk.Image.new_from_gicon(icon) box.append(img) def set_label(self, label): """Set button label.""" self.label.set_label(label) class LineColPopover(Gtk.Popover): """Lines and columns settings popover.""" def __init__(self, preferences): super().__init__() self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.box.set_margin_top(10) self.box.set_margin_bottom(10) self.box.set_margin_start(10) self.box.set_margin_end(10) self.set_child(self.box) self.box.append( self.check_button( "Display line numbers", "editor.line-numbers-toggle", preferences.line_numbers, ), ) self.box.append( self.check_button( "Highlight current line", "editor.current-line-toggle", preferences.current_line, ), ) self.box.append(self.margin(preferences)) self.box.append( self.check_button( "Text wrapping", "editor.text-wrapping-toggle", preferences.text_wrapping, ), ) self.box.append( self.check_button( "Draw white chars", "editor.white-chars-toggle", preferences.white_chars, ), ) def check_button(self, label, action, value): """Create check button for specific settings.""" del value # GTK4 auto-syncs from action state return Gtk.CheckButton(label=label, action_name=action) def margin(self, preferences): """Create Box with right margin settings.""" vbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) toggle = self.check_button( "Display right margin", "editor.right-margin-toggle", preferences.right_margin, ) vbox.append(toggle) spin = ActionableSpinButton( "editor.right-margin-value", sensitive=preferences.right_margin, value=preferences.right_margin_value, ) spin.set_range(1.0, 256.0) spin.set_increments(1.0, 8.0) vbox.append(spin) toggle.connect("toggled", self.on_margin_toggle, spin) return vbox def on_margin_toggle(self, widget, spin): """Change sensitve when margin toggle was changed.""" spin.set_sensitive(widget.get_active()) class Statusbar(Gtk.Box): """Status bar widget.""" css = Gtk.CssProvider() css.load_from_string( "* {border-top: 1px solid alpha(currentColor, 0.15); padding: 1px;}", ) def __init__(self, preferences): super().__init__(orientation=Gtk.Orientation.HORIZONTAL) ctx = self.get_style_context() ctx.add_provider( Statusbar.css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) self.message_label = Gtk.Label() self.message_label.set_hexpand(True) self.message_label.set_margin_start(10) self.message_label.set_margin_end(10) self.append(self.message_label) self._words_count = 0 self._chars_count = 0 self.info_bar = self.create_info_bar() self._update_stat_label() self.info_bar.set_margin_start(10) self.info_bar.set_margin_end(10) self.append(self.info_bar) self.editor_popover = self.create_editor_popover(preferences) self.editor_btn = StatusMenuButton("Editor", self.editor_popover) self.append(self.editor_btn) self.tab_popover = self.create_tab_popover(preferences) self.width_btn = StatusMenuButton( f"Tabulator width {preferences.tab_width}", self.tab_popover, ) self.append(self.width_btn) btn = StatusMenuButton("Line 1, Col 1", LineColPopover(preferences)) self.append(btn) def create_tab_popover(self, preferences): """Create popover for tab settings.""" pop = Gtk.Popover() box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box.set_margin_top(10) box.set_margin_bottom(10) box.set_margin_start(10) box.set_margin_end(10) pop.set_child(box) auto_indent = Gtk.CheckButton( label="Auto indent", action_name="editor.auto-indent-toggle", ) box.append(auto_indent) box.append( Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), ) tab_group = None for i in (2, 4, 8): tab_btn = Gtk.CheckButton( label=str(i), group=tab_group, action_target=GLib.Variant("i", i), ) if preferences.tab_width == i: tab_btn.set_active(True) tab_btn.connect("toggled", self.on_tab_spaces) tab_btn.set_action_name("editor.tab-width") box.append(tab_btn) if tab_group is None: tab_group = tab_btn box.append( Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), ) self.tab_use_space = Gtk.CheckButton( label="Use spaces", action_name="editor.use-spaces-toggle", ) box.append(self.tab_use_space) return pop def on_tab_spaces(self, widget): """Set label when radio buttons was toggled.""" if widget.get_active(): self.width_btn.set_label( f"Tabulator width {widget.get_action_target_value()}", ) def create_editor_popover(self, preferences): """Create Editor preferences popover.""" pop = Gtk.Popover() box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box.set_margin_top(10) box.set_margin_bottom(10) box.set_margin_start(10) box.set_margin_end(10) pop.set_child(box) period_btn = Gtk.CheckButton( label="Save file each 5 min", action_name="editor.period-save-toggle", ) box.append(period_btn) spell_btn = Gtk.CheckButton( label="Check Spelling", action_name="editor.check-spelling-toggle", ) box.append(spell_btn) auto_bullet_btn = Gtk.CheckButton( label="Auto bullet completion", action_name="editor.auto-bullet-toggle", ) box.append(auto_bullet_btn) tab_indent_btn = Gtk.CheckButton( label="Tab key indents bullets", action_name="editor.tab-indent-bullet-toggle", ) box.append(tab_indent_btn) return pop def create_info_bar(self): """Create info bar part.""" bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) bar.stat_label = Gtk.Label() bar.append(bar.stat_label) return bar def _update_stat_label(self): """Reformat the combined word/character count label.""" self.info_bar.stat_label.set_label( f"{self._words_count}\N{NO-BREAK SPACE}words," f" {self._chars_count}\N{NO-BREAK SPACE}characters", ) def set_words_count(self, count): """Set words count label.""" self._words_count = count self._update_stat_label() def set_chars_count(self, count): """Set chars count label.""" self._chars_count = count self._update_stat_label() def push(self, context_id, text): """Set message to status bar.""" self.message_label.set_text(text) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772790005.8043964 formiko-2.0.0/formiko/user.py0000664000175000017500000001502415152520366015152 0ustar00mcbigmcbig"""Classes for working with user settings and cache.""" from configparser import ConfigParser, NoOptionError, NoSectionError from os import makedirs from os.path import exists from traceback import print_exc from gi.repository.GLib import ( LogLevelFlags, get_user_cache_dir, get_user_config_dir, log_default_handler, ) from gi.repository.Gtk import Orientation from formiko.renderer import PARSERS class View: """View settings.""" EDITOR = 1 PREVIEW = 2 BOTH = 3 def __new__(cls, value): """Create enum value from string or number value.""" value = int(value) assert 0 < value < View.BOTH + 1 return value def smart_bool(value): """Make boolean value from any human form.""" if value.lower() in ("1", "true", "yes", "on", "enable"): return True if value.lower() in ("0", "false", "no", "off", "disable"): return False msg = f"{value} is not boolean value" raise ValueError(msg) class SmartParser(ConfigParser): """ConfigParser without rising excpetion whehn something does not exist.""" def smart_get(self, obj, key, conv=str, sec="main"): """Get with conversion function.""" try: val = self.get(sec, key) setattr(obj, key, conv(val)) except NoSectionError: pass except NoOptionError: pass except Exception: print_exc() def smart_set(self, obj, key, sec="main"): """Set obj as string.""" self.set(sec, key, str(getattr(obj, key))) class EditorPreferences: """Editor preferences model.""" period_save = True check_spelling = True spell_lang = "" spaces_instead_of_tabs = False tab_width = 8 auto_indent = True line_numbers = True right_margin = True right_margin_value = 80 current_line = False text_wrapping = True white_chars = False auto_bullet = True tab_indent_bullet = True class UserPreferences: """User preferences settings.""" preview = Orientation.HORIZONTAL.numerator auto_scroll = True parser = "rst" writer = "html4" style = "" custom_style = False editor = EditorPreferences() def __init__(self): self.load() def load(self): """Load and set from user config.""" directory = get_user_config_dir() cp = SmartParser() cp.read(f"{directory}/formiko.ini") cp.smart_get(self, "preview", int) cp.smart_get(self, "auto_scroll", smart_bool) cp.smart_get(self, "parser") if self.parser not in PARSERS: log_default_handler( "Application", LogLevelFlags.LEVEL_WARNING, f"Unknow parser `{self.parser}' in config, set default.", ) self.parser = "rst" cp.smart_get(self, "writer") cp.smart_get(self, "style") cp.smart_get(self, "custom_style", smart_bool) cp.smart_get(self.editor, "period_save", smart_bool, "editor") cp.smart_get(self.editor, "check_spelling", smart_bool, "editor") cp.smart_get(self.editor, "spell_lang", str, "editor") cp.smart_get( self.editor, "spaces_instead_of_tabs", smart_bool, "editor", ) cp.smart_get(self.editor, "tab_width", int, "editor") cp.smart_get(self.editor, "auto_indent", smart_bool, "editor") cp.smart_get(self.editor, "line_numbers", smart_bool, "editor") cp.smart_get(self.editor, "right_margin", smart_bool, "editor") cp.smart_get(self.editor, "right_margin_value", int, "editor") cp.smart_get(self.editor, "current_line", smart_bool, "editor") cp.smart_get(self.editor, "text_wrapping", smart_bool, "editor") cp.smart_get(self.editor, "white_chars", smart_bool, "editor") cp.smart_get(self.editor, "auto_bullet", smart_bool, "editor") cp.smart_get(self.editor, "tab_indent_bullet", smart_bool, "editor") def save(self): """Set settings to user config.""" cp = SmartParser() cp.add_section("main") cp.set("main", "preview", str(int(self.preview))) cp.smart_set(self, "auto_scroll") cp.smart_set(self, "parser") cp.smart_set(self, "writer") cp.smart_set(self, "style") cp.smart_set(self, "custom_style") cp.add_section("editor") cp.smart_set(self.editor, "period_save", "editor") cp.smart_set(self.editor, "check_spelling", "editor") cp.smart_set(self.editor, "spell_lang", "editor") cp.smart_set(self.editor, "spaces_instead_of_tabs", "editor") cp.smart_set(self.editor, "tab_width", "editor") cp.smart_set(self.editor, "auto_indent", "editor") cp.smart_set(self.editor, "line_numbers", "editor") cp.smart_set(self.editor, "right_margin", "editor") cp.smart_set(self.editor, "right_margin_value", "editor") cp.smart_set(self.editor, "current_line", "editor") cp.smart_set(self.editor, "text_wrapping", "editor") cp.smart_set(self.editor, "white_chars", "editor") cp.smart_set(self.editor, "auto_bullet", "editor") cp.smart_set(self.editor, "tab_indent_bullet", "editor") directory = get_user_config_dir() if not exists(directory): makedirs(directory) with open(f"{directory}/formiko.ini", "w+") as fp: cp.write(fp) class UserCache: """User cache.""" width = 800 height = 600 paned = 400 is_maximized = False view = View.BOTH def __init__(self): self.load() def load(self): """Load values from cache file.""" directory = get_user_cache_dir() cp = SmartParser() cp.read(f"{directory}/formiko/window.ini") cp.smart_get(self, "width", int) cp.smart_get(self, "height", int) cp.smart_get(self, "paned", int) cp.smart_get(self, "is_maximized", smart_bool) cp.smart_get(self, "view", View) def save(self): """Save values to cache file.""" cp = SmartParser() cp.add_section("main") cp.set("main", "width", str(self.width)) cp.set("main", "height", str(self.height)) cp.set("main", "paned", str(self.paned)) cp.set("main", "is_maximized", str(self.is_maximized)) cp.set("main", "view", str(self.view)) directory = get_user_cache_dir() + "/formiko" if not exists(directory): makedirs(directory) with open(f"{directory}/window.ini", "w+") as fp: cp.write(fp) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/formiko/utils.py0000664000175000017500000000021215152030611015312 0ustar00mcbigmcbig"""Utility classes and helpers for the formiko package.""" class Undefined: """Undefined class as type for not imported classes.""" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/formiko/vim.py0000664000175000017500000001320115152030611014747 0ustar00mcbigmcbig"""GTK widget based on gvim. This widget is deprecated, and it not work well on new platform. Modern solution have to be based on nvim and it's protocol. """ from logging import error from os.path import exists, splitext from time import sleep from uuid import uuid4 import pynvim from gi.repository import GLib, Vte from gi.repository.GObject import SIGNAL_RUN_FIRST, SIGNAL_RUN_LAST from formiko.dialogs import TraceBackDialog from formiko.widgets import ImutableDict VIM_PATH = "/usr/bin" class VimEditor(Vte.Terminal): """NeoVim widget based on VTE.""" __gsignals__ = ImutableDict( { "file-type": (SIGNAL_RUN_FIRST, None, (str,)), "scroll-changed": ( SIGNAL_RUN_LAST, None, (float,), ), # not implemented }, ) nvim: pynvim.Nvim _nvim_alive: bool = False def __init__(self, app_window, file_name=""): super().__init__() self.__win = app_window self.__file_name = file_name self._uuid = "/tmp/.formiko." + str(uuid4()) # noqa: S108 self.connect("child-exited", self._on_nvim_exited) self.connect("child-exited", app_window.destroy_from_vim) self.connect("realize", self.start_server) def _on_nvim_exited(self, *_): """Mark the Neovim connection as dead when the process exits.""" self._nvim_alive = False def start_server(self, *_): """Start neovim server and put it into terminal.""" file_type = "" if self.__file_name: _, ext = splitext(self.__file_name) self.emit("file-type", ext) else: file_type = "rst" args = [VIM_PATH + "/nvim", "--listen", self._uuid] if self.__file_name: args.append(self.__file_name) try: _, _pid = self.spawn_sync( Vte.PtyFlags.DEFAULT, None, args, None, GLib.SpawnFlags.DEFAULT, ) except GLib.Error as error: dialog = TraceBackDialog(self.__win, str(error)) dialog.present() return while not exists(self._uuid): sleep(0.1) self.nvim = pynvim.attach("socket", path=self._uuid) self._nvim_alive = True if file_type: self.vim_remote_send(":set filetype=" + file_type) def _nvim_call(self, fn, default=None): """Call a pynvim callable; return *default* if Neovim has exited.""" if not self._nvim_alive: return default try: return fn() except EOFError: self._nvim_alive = False return default def vim_remote_expr(self, command): """Do expresion on vim server and return value.""" return self._nvim_call(lambda: self.nvim.eval(command)) def vim_remote_send(self, command): """Call command on vim server.""" self._nvim_call(lambda: self.nvim.command(command)) def get_vim_changes(self): """Retun number of changes in vim.""" return self.vim_remote_expr("b:changedtick") or 0 def get_vim_lines(self): """Return number of lines in vim.""" return self.vim_remote_expr("line('$')") or 0 def get_vim_get_buffer(self, count): """Retun text from vim.""" buff = self.vim_remote_expr(f"getline(0, {count})") return "\n".join(buff) def get_vim_pos(self): """Return cursor position in vim.""" pos = self.vim_remote_expr("getpos('.')") or [0, 0, 0, 0] _buff, row, col, _off = pos return row, col def get_vim_scroll_pos(self, total_lines): """Return scroll position based on cursor row as a fraction (0.0-1.0). Uses the cursor row so the preview tracks vertical position in the document without flickering on each keystroke within a line. """ row, _ = self.get_vim_pos() return (row - 1) / max(total_lines - 1, 1) def get_vim_file_path(self): """Return file path from vim.""" return self.vim_remote_expr("expand('%:p')") or self.__file_name def get_vim_encoding(self): """Return vim encoding.""" return self.vim_remote_expr("&l:encoding") def get_vim_filetype(self): """Return file type of file from vim.""" return self.vim_remote_expr("&l:filetype") def vim_quit(self): """Quit the vim.""" self._nvim_call(lambda: self.nvim.quit()) @property def is_modified(self): """Return true if file in vim is modified.""" return bool(self.vim_remote_expr("&l:modified") or 0) @property def file_name(self): """Return file openned in vim.""" __file_name = self.vim_remote_expr("@%") if __file_name != self.__file_name: _, ext = splitext(__file_name) self.emit("file-type", ext) self.__file_name = __file_name return self.__file_name @property def file_path(self): """Return file path in vim.""" return self.get_vim_file_path() def do_file_type(self, ext): """Do nothing - just compatible interface.""" def read_from_file(self, file_name): """Open a file in Neovim.""" self.__file_name = file_name _, ext = splitext(file_name) self.emit("file-type", ext) self._nvim_call(lambda: self.nvim.command("e " + file_name)) def save(self, _): """Log error, save is not supported.""" error("Not supported call save in VimEditor") def save_as(self, *_): """Log error, save as is not supported.""" error("Not supported call save_as in VimEditor") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772965959.9181523 formiko-2.0.0/formiko/widgets.py0000664000175000017500000000773615153250110015641 0ustar00mcbigmcbig"""Gtk Widgets extensions.""" from gi.repository import GObject, Gtk from gi.repository.GLib import Variant def connect_accel_tooltip(widget, tooltip_text, accel_action=None): """Set a tooltip that includes the registered keyboard shortcut. The tooltip is initially set to *tooltip_text*. When the widget is realised the handler looks up the accelerators registered with the application for *accel_action* (or the widget's own action_name when *accel_action* is ``None``) and appends the shortcut in parentheses, e.g. ``"Save Document (Ctrl+S)"``. """ widget.set_tooltip_text(tooltip_text) def on_realize(_w): action = accel_action if action is None: get_name = getattr(_w, "get_action_name", None) if get_name: action = get_name() if not action: return app = Gtk.Application.get_default() if not app: return accels = app.get_accels_for_action(action) if not accels: return result = Gtk.accelerator_parse(accels[0]) if not result[0]: return label = Gtk.accelerator_get_label( result.accelerator_key, result.accelerator_mods, ) _w.set_tooltip_text(f"{tooltip_text} ({label})") widget.connect("realize", on_realize) class ImutableDict(dict): """Imutable dict implementation.""" def __hash__(self): """Return hash.""" return id(self) def _immutable(self, *args, **kws): raise TypeError __setitem__ = _immutable __delitem__ = _immutable clear = _immutable update = _immutable setdefault = _immutable pop = _immutable popitem = _immutable class IconButton(Gtk.Button): """Icon button with themed icon.""" def __init__(self, symbol, tooltip, **kwargs): super().__init__(**kwargs) self.set_icon_name(symbol) connect_accel_tooltip(self, tooltip) class ActionHelper: """Support class for own Actionable widgets.""" # pylint: disable=too-few-public-methods def get_action_owner(self): """Get action owner.""" action_name = self.action_name if action_name: prefix, action = action_name.split(".") root = self.get_root() top = root.get_action_group(prefix) if root else None if top and top.has_action(action): return action, top return "", None class ActionableSpinButton(Gtk.SpinButton, Gtk.Actionable, ActionHelper): """Gtk.SpinButton with action support.""" action_name = GObject.Property(type=str) action_target = GObject.Property(type=GObject.TYPE_VARIANT) def __init__(self, action_name=None, **kwargs): Gtk.SpinButton.__init__(self, **kwargs) if action_name: self.action_name = action_name def do_realize(self): """Realize SpinButton and set the action.""" Gtk.SpinButton.do_realize(self) action, top = self.get_action_owner() if top: self.set_value(top.get_action_state(action).get_double()) def set_action_name(self, action_name): """Set action name to widget.""" self.action_name = action_name if self.get_realized(): action, top = self.get_action_owner() if top: self.set_value(top.get_action_state(action).get_double()) def get_action_name(self): """Return action name from widget.""" return self.action_name def set_action_target_value(self, target_value): """Set action target.""" self.action_target = target_value def get_action_target_value(self): """Get action target.""" return self.action_target def do_value_changed(self): """Activate action if value was changed.""" self.action_target = Variant("d", self.get_value() or 0.0) root = self.get_root() if root: root.activate_action(self.action_name, self.action_target) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1774601849.0090375 formiko-2.0.0/formiko/window.py0000664000175000017500000013233615161443171015507 0ustar00mcbigmcbig"""Gtk.ApplicationWindow implementation.""" from enum import Enum from os.path import basename, dirname, expanduser, splitext from gi.repository import Adw, Gio, GLib, Gtk, Pango from formiko.dialogs import ( UnsavedChangesDialog, build_export_filters, build_open_filters, open_file_dialog, run_alert_dialog, save_file_dialog, ) from formiko.document_page import DocumentPage from formiko.editor import EditorType from formiko.filebrowser import FileBrowser from formiko.formatting_actions import FormattingActionGroup from formiko.menu import AppMenu from formiko.preferences import Preferences from formiko.renderer import WebView as GtkWebView from formiko.sourceview import View as GtkSourceView from formiko.status_menu import Statusbar from formiko.user import UserCache, UserPreferences, View from formiko.widgets import IconButton, connect_accel_tooltip NOT_SAVED_NAME = "Untitled Document" class SearchWay(Enum): """Direction for search navigation.""" NEXT = 0 PREVIOUS = 1 class TabLabel(Gtk.Box): """Tab label widget with title and close button for Gtk.Notebook.""" def __init__(self, title, on_close): super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) self._label = Gtk.Label(label=title) self._label.set_hexpand(True) self._label.set_ellipsize(Pango.EllipsizeMode.END) self._label.set_width_chars(8) self._label.set_max_width_chars(30) close_btn = Gtk.Button() close_btn.set_icon_name("window-close-symbolic") close_btn.add_css_class("flat") close_btn.add_css_class("circular") close_btn.set_focus_on_click(False) close_btn.connect("clicked", on_close) self.append(self._label) self.append(close_btn) def set_title(self, title): """Update the displayed tab title.""" self._label.set_label(title) def set_tooltip(self, text): """Set tooltip on the inner label (works on all tabs).""" self._label.set_tooltip_text(text) def set_needs_attention(self, needs): """Highlight label when the document has unsaved changes.""" if needs: self._label.add_css_class("accent") else: self._label.remove_css_class("accent") class AppWindow(Adw.ApplicationWindow): """Main application window with tabbed editor interface.""" # pylint: disable = too-many-public-methods # pylint: disable = too-many-instance-attributes # pylint: disable = unused-argument def __init__( self, editor_type: EditorType, file_name="", no_initial_tab=False, ): """:param EditorType editor_type: SOURCE, VIM or PREVIEW. :param str file_name: Path to open on startup. :param bool no_initial_tab: Skip initial tab (drag-receive windows). """ self.runing = True self.editor_type = editor_type self.focused = None self.search_way = SearchWay.NEXT self.cache = UserCache() self.preferences = UserPreferences() self._action_groups = {} self._tabs_needing_paned_reset: set = set() self._last_active_doc = None self._tab_labels: dict = {} super().__init__() self._setup_actions() self.set_default_icon_name("formiko") self._setup_layout(file_name, no_initial_tab) self.connect("close-request", self._on_close_request) def insert_action_group(self, prefix, group): """Override to track inserted action groups for get_action_group().""" self._action_groups[prefix] = group super().insert_action_group(prefix, group) def get_action_group(self, prefix): """Return action group by prefix (GTK4 replacement for removed API).""" if prefix == "win": return self if prefix == "app": return self.get_application() return self._action_groups.get(prefix) def _setup_actions(self): self._register_document_actions() self._register_search_actions() self._register_view_actions() self._register_renderer_actions() self._register_json_actions() def _register_document_actions(self): action = Gio.SimpleAction.new("new-tab", None) action.connect("activate", self._on_new_tab) self.add_action(action) action = Gio.SimpleAction.new("open-document", None) action.connect("activate", self.on_open_document) self.add_action(action) action = Gio.SimpleAction.new("save-document", None) action.connect("activate", self._on_save_document) action.set_enabled(False) self.add_action(action) action = Gio.SimpleAction.new("save-document-as", None) action.connect("activate", self._on_save_document_as) action.set_enabled(self.editor_type == EditorType.SOURCE) self.add_action(action) action = Gio.SimpleAction.new("export-document-as", None) action.connect("activate", self._on_export_document_as) action.set_enabled(self.editor_type != EditorType.PREVIEW) self.add_action(action) action = Gio.SimpleAction.new("print-document", None) action.connect("activate", self._on_print_document) self.add_action(action) action = Gio.SimpleAction.new("close-window", None) action.connect("activate", self._on_close_window) self.add_action(action) action = Gio.SimpleAction.new("next-tab", None) action.connect("activate", self._on_next_tab) self.add_action(action) action = Gio.SimpleAction.new("prev-tab", None) action.connect("activate", self._on_prev_tab) self.add_action(action) def _register_search_actions(self): action = Gio.SimpleAction.new("find-in-document", None) action.connect("activate", self._on_find_in_document) self.add_action(action) action = Gio.SimpleAction.new("find-next-match", None) action.connect("activate", self._on_find_next_match) self.add_action(action) action = Gio.SimpleAction.new("find-previous-match", None) action.connect("activate", self._on_find_previous_match) self.add_action(action) def _register_view_actions(self): self.refresh_preview_action = Gio.SimpleAction.new( "refresh-preview", None, ) self.refresh_preview_action.connect( "activate", self._on_refresh_preview, ) self.add_action(self.refresh_preview_action) self._create_stateful_action( "switch-view-toggle", "q", self.cache.view, self._on_switch_view_toggle, ) for name, view in ( ("show-editor", View.EDITOR), ("show-preview", View.PREVIEW), ("show-both", View.BOTH), ): action = Gio.SimpleAction.new(name, None) action.connect( "activate", lambda _a, _p, v=view: self.change_action_state( "switch-view-toggle", GLib.Variant("q", v), ), ) self.add_action(action) self._create_stateful_action( "change-preview", "q", self.preferences.preview, self._on_change_preview, ) self._create_toggle_action( "auto-scroll-toggle", self.preferences.auto_scroll, self._on_auto_scroll_toggle, ) if self.editor_type != EditorType.PREVIEW: self._create_toggle_action( "toggle-sidebar", False, self._on_toggle_sidebar, ) def _register_renderer_actions(self): pref = self.preferences self._create_stateful_action( "change-writer", "s", pref.writer, self._on_change_writer, ) self._create_stateful_action( "change-parser", "s", pref.parser, self._on_change_parser, ) self._create_toggle_action( "custom-style-toggle", pref.custom_style, self._on_custom_style_toggle, ) self._create_stateful_action( "change-style", "s", pref.style, self._on_change_style, ) def _register_json_actions(self): action = Gio.SimpleAction.new("json-expand-all", None) action.connect( "activate", lambda *_: self.active_page and self.active_page.renderer.json_expand_all(), ) self.add_action(action) action = Gio.SimpleAction.new("json-collapse-all", None) action.connect( "activate", lambda *_: self.active_page and self.active_page.renderer.json_collapse_all(), ) self.add_action(action) def _create_stateful_action(self, name, _type, default_value, method): """Create a stateful GSimpleAction with the given variant type.""" action = Gio.SimpleAction.new_stateful( name, GLib.VariantType.new(_type), GLib.Variant(_type, default_value), ) action.connect("activate", method) self.add_action(action) def _create_toggle_action(self, name, default_value, method): action = Gio.SimpleAction.new_stateful( name, None, GLib.Variant("b", default_value), ) action.connect("activate", method) self.add_action(action) def _on_close_window(self, action, *params): """'close-window' action handler: close active tab or whole window.""" if self.notebook.get_n_pages() > 1: n = self.notebook.get_current_page() if n >= 0: doc = self.notebook.get_nth_page(n) self._request_close_doc(doc) elif self.ask_if_modified(): self.save_win_state() self.destroy() def open_document(self, file_path): """Open *file_path* in an existing tab or create a new one.""" for window in self.get_application().get_windows(): if not isinstance(window, AppWindow): continue if not window.notebook: continue for doc in window.iter_doc_pages(): if file_path == doc.file_path: window.present() window.notebook.set_current_page( window.notebook.page_num(doc), ) return self.new_tab(file_path) def on_open_document(self, actions, *params): """Open the file-chooser dialog.""" filters, default_filter = build_open_filters() open_file_dialog( self, "Open Document", filters, default_filter, callback=self.open_document, ) def _on_save_document(self, action, *params): page = self.active_page if page and page.editor_type == EditorType.SOURCE: page.editor.save() def _on_save_document_as(self, action, *params): page = self.active_page if page and page.editor_type == EditorType.SOURCE: page.editor.save_as() def _on_export_document_as(self, action, *params): """'export-document-as' action handler.""" page = self.active_page if not page: return file_name = ( page.editor.file_name if page.editor_type != EditorType.PREVIEW else None ) renderer = page.renderer filters, default_filter, suffix, filter_suffixes = ( build_export_filters(renderer.get_parser()) ) name, _ = splitext(file_name) if file_name else (None, None) save_file_dialog( self, "Export Document As", filters, default_filter, default_suffix=suffix, filter_suffixes=filter_suffixes, initial_folder=dirname(name) or GLib.get_home_dir() if name else GLib.get_home_dir(), initial_name=basename(name) if name else None, callback=lambda fn: self._write_export(renderer, fn), ) def _write_export(self, renderer, file_name): """Write rendered output to *file_name*.""" with open(file_name, "w+", encoding="utf-8") as output: output.write(renderer.render_output()[1].strip()) def _on_print_document(self, action, *params): page = self.active_page if page: page.renderer.print_page() def _on_close_request(self, *args): """'close-request' handler (GTK4 replacement for delete-event).""" rv = self.ask_if_modified() if rv: self.save_win_state() return not rv # True = prevent close def _on_switch_view_toggle(self, action, param): """'switch-view-toggle' action handler.""" if action.get_state() != param: action.set_state(param) state = param.get_uint16() self.cache.view = state for doc in self._iter_doc_pages(): if doc.editor_type == EditorType.PREVIEW: continue if state == View.BOTH: doc.editor.set_visible(True) doc.renderer.set_visible(True) elif state == View.EDITOR: doc.editor.set_visible(True) doc.renderer.set_visible(False) else: doc.editor.set_visible(False) doc.renderer.set_visible(True) refresh_enabled = state != View.EDITOR self.refresh_preview_action.set_enabled(refresh_enabled) if state == View.BOTH: self.both_toggle_btn.set_active(True) elif state == View.EDITOR: self.editor_toggle_btn.set_active(True) else: self.preview_toggle_btn.set_active(True) def _on_change_preview(self, action, param): """'change-preview' action handler.""" if action.get_state() != param: action.set_state(param) orientation = param.get_uint16() if self.preferences.preview == orientation: return self.preferences.preview = orientation self.preferences.save() for doc in self._iter_doc_pages(): if doc.paned: doc.paned.set_orientation(orientation) self._tabs_needing_paned_reset.add(doc) # Workaround for https://gitlab.gnome.org/GNOME/gtk/issues/1959 - # set_orientation() calls queue_resize(), which schedules a layout # pass via the GDK frame clock (next vsync, ~16 ms at 60 Hz). # idle_add fires before that frame clock tick, so get_width() would # still return the old size; the 20 ms timeout waits past one frame. GLib.idle_add( lambda: GLib.timeout_add( 20, self._reset_active_paned, ) and False, ) def _paned_half(self, paned): """Return the position for a 50/50 split of *paned*.""" if paned.get_orientation() == Gtk.Orientation.VERTICAL: return paned.get_height() // 2 return paned.get_width() // 2 def _reset_active_paned(self): """Set the active tab's paned to 50/50 after orientation change.""" doc = self.active_page if doc and doc.paned: paned = doc.paned half = self._paned_half(paned) if half > 0: paned.set_position(half) self._tabs_needing_paned_reset.discard(doc) return False def _on_auto_scroll_toggle(self, action, param): """'auto-schroll-toggle' action handler.""" auto_scroll = not action.get_state().get_boolean() action.set_state(GLib.Variant("b", auto_scroll)) self.preferences.auto_scroll = auto_scroll self.preferences.save() def _on_change_parser(self, action, param): """'change-parser' action handler (applies to active tab).""" parser = param.get_string() if action.get_state() != param: action.set_state(param) page = self.active_page if not page: return page.preferences.parser = parser page.renderer.set_parser(parser) if page.editor_type == EditorType.SOURCE: page.fmt_actions.set_parser(parser) page.editor.change_mime_type(parser) page.editor.set_list_features_enabled( parser in ("rst", "md", "m2r"), ) # Save as global default for new tabs self.preferences.parser = parser self.preferences.save() self._apply_parser_ui(parser) def on_active_tab_parser_changed(self, doc, parser): """Update window UI when the active tab's parser changes.""" if self.active_page is not doc: return action = self.lookup_action("change-parser") if action: action.set_state(GLib.Variant("s", parser)) self._apply_parser_ui(parser) def _apply_parser_ui(self, parser): """Update header-bar visibility for JSON vs markup parsers.""" self.json_box.set_visible(parser == "json") self.json_fold_box.set_visible(parser == "json") if self.editor_type == EditorType.SOURCE: self.fmt_bar.set_visible(parser != "json") def _on_browser_file_activated(self, _browser, file_path): """Open a file selected in the file browser.""" self.open_document(file_path) action = self.lookup_action("toggle-sidebar") if action and action.get_state().get_boolean(): action.activate(None) def _on_toggle_sidebar(self, action, *_): """'toggle-sidebar' action handler.""" new_state = not action.get_state().get_boolean() action.set_state(GLib.Variant("b", new_state)) self.overlay_split.set_show_sidebar(new_state) def _on_scroll_changed(self, widget, position): """'scroll-changed' event handler.""" if self.preferences.auto_scroll: self.renderer.scroll_to_position(position) def _on_filter_activate(self, _): """Handle activation of the JSONPath filter.""" page = self.active_page if not page: return expr = self.path_entry.get_text() if page.renderer.get_parser() == "json": json_preview = page.renderer.parser_instance if json_preview: json_preview.filter_callback = self._on_filter_applied json_preview.apply_path_filter(expr) def _on_filter_applied(self, expr, count): """Update statusbar after a filter is applied.""" if hasattr(self, "status_bar"): if expr: msg = f'Filter: "{expr}" → {count} match(es)' else: msg = "Filter cleared." self.status_bar.push(0, msg) def _on_change_writer(self, action, param): """'change-writer' action handler (applies to all tabs).""" if action.get_state() != param: action.set_state(param) writer = param.get_string() self.preferences.writer = writer self.preferences.save() for doc in self._iter_doc_pages(): doc.renderer.set_writer(writer) def _on_custom_style_toggle(self, action, param): """'custom-style-toggle' action handler (applies to all tabs).""" custom_style = not action.get_state().get_boolean() action.set_state(GLib.Variant("b", custom_style)) self.preferences.custom_style = custom_style style = self.preferences.style if custom_style else "" for doc in self._iter_doc_pages(): doc.renderer.set_style(style) self.preferences.save() def _on_change_style(self, action, param): """'change-style' action handler (applies to all tabs).""" if action.get_state() != param: action.set_state(param) style = param.get_string() self.preferences.style = style effective = style if self.preferences.custom_style else "" for doc in self._iter_doc_pages(): doc.renderer.set_style(effective) self.preferences.save() def _on_find_in_document(self, action, *param): """'find-in-document' action handler.""" page = self.active_page if not page: return if ( page.editor_type != EditorType.SOURCE and not page.renderer.props.visible ): return # works only with source view or renderer if self.search.get_search_mode(): if self.search_way == SearchWay.NEXT: self._on_find_next_match(action, *param) else: self._on_find_previous_match(action, *param) else: editor = getattr(page, "editor", None) if editor: editor.stop_search() page.renderer.stop_search() self.search_way = SearchWay.NEXT self.focused = self.get_focus() self.search.set_search_mode(True) self.search_entry.grab_focus() if self.search_text: self.search_entry.set_text(self.search_text) self.search_entry.select_region(0, -1) def _on_search_focus_out(self, *args): """'focus-leave' event handler (replaces focus-out-event).""" self.search_text = self.search_entry.get_text() if self.search.get_search_mode(): self.search.set_search_mode(False) def _on_search_mode_changed(self, search_bar, param): """'search-mode-enabled' notify event handler.""" if not self.search.get_search_mode(): page = self.active_page editor = getattr(page, "editor", None) if page else None renderer = page.renderer if page else None if not self.search_text: if isinstance(self.focused, GtkSourceView): if editor: editor.stop_search() elif isinstance(self.focused, GtkWebView): if renderer: renderer.stop_search() elif ( page and page.editor_type == EditorType.SOURCE and editor and editor.props.visible ): editor.stop_search() elif renderer and renderer.props.visible: renderer.stop_search() if self.focused: self.focused.grab_focus() self.focused = None def _on_search_changed(self, search_entry): """'search-changed' event handler.""" if self.search_way == SearchWay.NEXT: res = self._on_find_next_match(None, None) else: res = self._on_find_previous_match(None, None) ctx = self.search_entry.get_style_context() if not res and self.search_entry.get_text(): Gtk.StyleContext.add_class(ctx, "error") else: Gtk.StyleContext.remove_class(ctx, "error") def _on_find_next_match(self, action, *params): """'find-next-match' action handler.""" res = False if self.search.get_search_mode(): text = self.search_entry.get_text() page = self.active_page if not page: return res editor = getattr(page, "editor", None) renderer = page.renderer if isinstance(self.focused, GtkSourceView): res = editor.do_next_match(text) if editor else False elif isinstance(self.focused, GtkWebView): res = renderer.do_next_match(text) elif ( page.editor_type == EditorType.SOURCE and editor and editor.props.visible ): res = editor.do_next_match(text) elif renderer.props.visible: res = renderer.do_next_match(text) return res def _on_find_previous_match(self, action, *params): """'find-previous-match' action handler.""" res = False if self.search.get_search_mode(): text = self.search_entry.get_text() page = self.active_page if not page: return res editor = getattr(page, "editor", None) renderer = page.renderer if isinstance(self.focused, GtkSourceView): res = editor.do_previous_match(text) if editor else False elif isinstance(self.focused, GtkWebView): res = renderer.do_previous_match(text) elif ( page.editor_type == EditorType.SOURCE and editor and editor.props.visible ): res = editor.do_previous_match(text) elif renderer.props.visible: res = renderer.do_previous_match(text) return res def _on_refresh_preview(self, action, *params): """'refresh-preview' action handler.""" page = self.active_page if page: page.refresh() def ask_if_modified(self): """Ask user for each unsaved tab. Returns True if OK to proceed.""" for doc in self._iter_doc_pages(): if doc.editor_type == EditorType.PREVIEW: continue if doc.is_modified: n = self.notebook.page_num(doc) if n >= 0: self.notebook.set_current_page(n) dialog = UnsavedChangesDialog(doc.file_name) response = run_alert_dialog(dialog, self) if response == "cancel": return False if ( response == "save" and doc.editor_type == EditorType.SOURCE ): doc.editor.save() self.runing = False for doc in self._iter_doc_pages(): doc.stop() if doc.editor_type == EditorType.VIM: doc.editor.vim_quit() return True def destroy_from_vim(self, vim_widget=None, *args): """Close the tab whose VimEditor exited.""" self.runing = False for doc in list(self.iter_doc_pages()): if doc.editor_type == EditorType.VIM and ( vim_widget is None or doc.editor is vim_widget ): doc.stop() if self.notebook.get_n_pages() <= 1: self.save_win_state() self.destroy() else: n = self.notebook.page_num(doc) if n >= 0: self._tab_labels.pop(doc, None) self.notebook.remove_page(n) self._update_tab_bar_visibility() return self.save_win_state() self.destroy() def save_win_state(self): """Persist window geometry and paned position to cache.""" self.cache.width = self.get_width() self.cache.height = self.get_height() page = self.active_page if page and page.paned: self.cache.paned = page.paned.get_position() self.cache.is_maximized = self.is_maximized() self.cache.save() def _update_title(self, file_name, file_path, modified=False): """Update window title and headerbar title/subtitle.""" star = "*" if modified else "" name = file_name or NOT_SAVED_NAME if file_path: raw_dir = dirname(file_path) home = expanduser("~") subtitle = ( "~" + raw_dir[len(home):] if raw_dir.startswith(home) else raw_dir ) wm_title = f"{star}{name} ({subtitle})" else: subtitle = "Draft" wm_title = f"{star}{name}" self._window_title.set_title(f"{star}{name}") self._window_title.set_subtitle(subtitle) self.set_title(wm_title) @staticmethod def doc_starred_name(doc): """Return tab display name for *doc*, prefixed with '*' if modified.""" star = "*" if doc.is_modified else "" return f"{star}{doc.file_name or NOT_SAVED_NAME}" def _sync_tab_page_ui(self, doc): """Sync the tab label title and attention highlight for *doc*.""" tab_label = self._tab_labels.get(doc) if not tab_label: return name = self.doc_starred_name(doc) tab_label.set_title(name) tab_label.set_needs_attention(doc.is_modified) fallback = f"{doc.file_name or NOT_SAVED_NAME} (Draft)" tab_label.set_tooltip(doc.file_path or fallback) def _sync_active_window_ui(self, doc): """Sync window title, save action and status bar for the active tab.""" self._update_title(doc.file_name, doc.file_path, doc.is_modified) action = self.lookup_action("save-document") if action: action.set_enabled( doc.editor_type == EditorType.SOURCE and doc.is_modified, ) if ( hasattr(self, "status_bar") and doc.editor_type == EditorType.SOURCE ): self.status_bar.set_words_count(doc.words_count) self.status_bar.set_chars_count(doc.chars_count) def _on_doc_state_changed(self, doc): """Update tab UI and window title on name/modified state change.""" self._sync_tab_page_ui(doc) if doc is self.active_page: self._sync_active_window_ui(doc) def _on_doc_words_changed(self, doc, words, chars): if doc is self.active_page and hasattr(self, "status_bar"): self.status_bar.set_words_count(words) self.status_bar.set_chars_count(chars) def _create_headerbar(self): headerbar = Adw.HeaderBar() self._window_title = Adw.WindowTitle() headerbar.set_title_widget(self._window_title) self._headerbar_pack_start(headerbar) self._headerbar_pack_end(headerbar) return headerbar def _headerbar_pack_start(self, headerbar): if self.editor_type != EditorType.PREVIEW: sidebar_btn = Gtk.ToggleButton( icon_name="sidebar-show-symbolic", action_name="win.toggle-sidebar", ) connect_accel_tooltip(sidebar_btn, "Show File Browser") headerbar.pack_start(sidebar_btn) headerbar.pack_start( IconButton( symbol="tab-new-symbolic", tooltip="New Tab", action_name="win.new-tab", ), ) headerbar.pack_start( IconButton( symbol="document-open-symbolic", tooltip="Open Document", action_name="win.open-document", ), ) if self.editor_type == EditorType.SOURCE: headerbar.pack_start( IconButton( symbol="document-save-symbolic", tooltip="Save Document", action_name="win.save-document", ), ) self.fmt_bar = FormattingActionGroup.create_bar() self.fmt_bar.set_visible(self.preferences.parser != "json") headerbar.pack_start(self.fmt_bar) headerbar.pack_start(self._create_json_filter_box()) def _headerbar_pack_end(self, headerbar): """Pack right-side buttons into the header bar.""" headerbar.pack_end( Gtk.MenuButton( icon_name="open-menu-symbolic", tooltip_text="Main Menu", menu_model=AppMenu(self.editor_type), primary=True, ), ) self.pref_menu = Preferences(self.preferences) headerbar.pack_end( Gtk.MenuButton( icon_name="emblem-system-symbolic", tooltip_text="Preferences", popover=self.pref_menu, ), ) headerbar.pack_end( IconButton( symbol="view-refresh-symbolic", tooltip="Refresh preview", action_name="win.refresh-preview", ), ) headerbar.pack_end(self._create_json_fold_box()) if self.editor_type != EditorType.PREVIEW: headerbar.pack_end(self._create_view_toggle_box()) def _create_json_filter_box(self): """Create the JSONPath filter entry and button box.""" self.path_entry = Gtk.SearchEntry(placeholder_text="JSONPath filter…") self.path_entry.set_width_chars(50) self.path_entry.set_hexpand(True) self.path_entry.connect("activate", self._on_filter_activate) filter_btn = Gtk.Button.new_from_icon_name("system-search-symbolic") filter_btn.set_tooltip_text("Apply Filter") filter_btn.connect("clicked", self._on_filter_activate) self.json_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.json_box.add_css_class("linked") self.json_box.append(self.path_entry) self.json_box.append(filter_btn) self.json_box.set_visible(self.preferences.parser == "json") return self.json_box def _create_json_fold_box(self): """Create the JSON expand/collapse buttons box.""" self.json_fold_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.json_fold_box.add_css_class("linked") self.json_fold_box.append( IconButton( symbol="format-indent-more-symbolic", tooltip="Expand All", action_name="win.json-expand-all", ), ) self.json_fold_box.append( IconButton( symbol="format-indent-less-symbolic", tooltip="Collapse All", action_name="win.json-collapse-all", ), ) self.json_fold_box.set_visible(self.preferences.parser == "json") return self.json_fold_box def _create_view_toggle_box(self): """Create the editor/preview/both toggle button group.""" btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) btn_box.add_css_class("linked") self.editor_toggle_btn = Gtk.ToggleButton( icon_name="text-editor-symbolic", action_name="win.switch-view-toggle", action_target=GLib.Variant("q", View.EDITOR), ) connect_accel_tooltip( self.editor_toggle_btn, "Show Editor", "win.show-editor", ) btn_box.append(self.editor_toggle_btn) self.preview_toggle_btn = Gtk.ToggleButton( icon_name="view-reveal-symbolic", action_name="win.switch-view-toggle", action_target=GLib.Variant("q", View.PREVIEW), ) connect_accel_tooltip( self.preview_toggle_btn, "Show Web Preview", "win.show-preview", ) btn_box.append(self.preview_toggle_btn) self.both_toggle_btn = Gtk.ToggleButton( icon_name="view-dual-symbolic", action_name="win.switch-view-toggle", action_target=GLib.Variant("q", View.BOTH), ) connect_accel_tooltip( self.both_toggle_btn, "Show Editor and Web Preview", "win.show-both", ) btn_box.append(self.both_toggle_btn) return btn_box def _setup_layout(self, file_name, no_initial_tab=False): """Create and wire up the window layout.""" self.set_default_size(self.cache.width, self.cache.height) self.notebook = Gtk.Notebook() self.notebook.set_scrollable(True) self.notebook.set_show_border(False) self.notebook.set_show_tabs(False) self.notebook.connect("switch-page", self._on_tab_switched) toolbar_view = Adw.ToolbarView() toolbar_view.add_top_bar(self._create_headerbar()) if self.editor_type == EditorType.SOURCE: self.status_bar = Statusbar(self.preferences.editor) toolbar_view.add_bottom_bar(self.status_bar) overlay = Gtk.Overlay() overlay.set_vexpand(True) self._create_search_bar(overlay) if self.editor_type != EditorType.PREVIEW: self.file_browser = FileBrowser() self.file_browser.connect( "file-activated", self._on_browser_file_activated, ) self.overlay_split = Adw.OverlaySplitView( collapsed=True, show_sidebar=False, sidebar=self.file_browser, content=self.notebook, sidebar_width_unit=Adw.LengthUnit.SP, min_sidebar_width=220, max_sidebar_width=220, ) overlay.set_child(self.overlay_split) else: overlay.set_child(self.notebook) toolbar_view.set_content(overlay) self.set_content(toolbar_view) if self.cache.is_maximized: self.maximize() if not no_initial_tab: self.new_tab(file_name) def _create_search_bar(self, overlay): """Create the floating search bar and add it to *overlay*.""" self.search_text = "" self.search = Gtk.SearchBar() self.search.set_show_close_button(False) self.search.set_halign(Gtk.Align.CENTER) self.search.set_valign(Gtk.Align.START) self.search.connect( "notify::search-mode-enabled", self._on_search_mode_changed, ) overlay.add_overlay(self.search) sbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) sbox.add_css_class("linked") self.search.set_child(sbox) self.search_entry = Gtk.SearchEntry() self.search_entry.set_width_chars(30) self.search_entry.set_hexpand(True) sbox.append(self.search_entry) self.search.connect_entry(self.search_entry) self.search_entry.connect("search-changed", self._on_search_changed) focus_ctrl = Gtk.EventControllerFocus.new() focus_ctrl.connect("leave", self._on_search_focus_out) self.search_entry.add_controller(focus_ctrl) sbox.append( IconButton( symbol="go-previous-symbolic", tooltip="Previous search", action_name="win.find-previous-match", focus_on_click=False, ), ) sbox.append( IconButton( symbol="go-next-symbolic", tooltip="Next search", action_name="win.find-next-match", focus_on_click=False, ), ) # ------------------------------------------------------------------ # Tab management # ------------------------------------------------------------------ def _update_tab_bar_visibility(self): """Auto-hide the tab bar when only one tab is open.""" self.notebook.set_show_tabs(self.notebook.get_n_pages() > 1) def new_tab(self, file_name=""): """Open *file_name* (or an empty document) in a new tab. If the active tab is an empty, unmodified document, the file is loaded into it instead of opening a new tab. """ if file_name: active = self.active_page if ( active is not None and active.editor_type != EditorType.PREVIEW and not active.file_path and not active.is_modified ): active.load_file(file_name) return active # Sync cache.paned from active tab so new tab inherits current split active = self.active_page if active and active.paned: self.cache.paned = active.paned.get_position() doc = DocumentPage(self, self.editor_type, file_name) title = basename(file_name) if file_name else NOT_SAVED_NAME tab_label = TabLabel( title, lambda _btn, d=doc: self._request_close_doc(d), ) tab_label.set_tooltip(file_name or NOT_SAVED_NAME) self._tab_labels[doc] = tab_label doc.connect("doc-state-changed", self._on_doc_state_changed) doc.connect("words-count-changed", self._on_doc_words_changed) scroll_ctrl = Gtk.EventControllerScroll.new( Gtk.EventControllerScrollFlags.VERTICAL, ) scroll_ctrl.connect("scroll", self._on_tab_label_scroll) tab_label.add_controller(scroll_ctrl) idx = self.notebook.append_page(doc, tab_label) self.notebook.set_tab_reorderable(doc, True) doc.show() self.notebook.set_current_page(idx) self._update_tab_bar_visibility() return doc def _on_new_tab(self, action, *params): self.new_tab() def _on_next_tab(self, action, *params): """Switch to the next tab, wrapping around at the end.""" n = self.notebook.get_n_pages() if n > 1: current = self.notebook.get_current_page() self.notebook.set_current_page((current + 1) % n) def _on_prev_tab(self, action, *params): """Switch to the previous tab, wrapping around at the start.""" n = self.notebook.get_n_pages() if n > 1: current = self.notebook.get_current_page() self.notebook.set_current_page((current - 1) % n) def _on_tab_label_scroll(self, _ctrl, _dx, dy): """Switch tab on mouse-wheel scroll over a tab label.""" n = self.notebook.get_n_pages() if n <= 1: return False current = self.notebook.get_current_page() if dy > 0: self.notebook.set_current_page(min(current + 1, n - 1)) elif dy < 0: self.notebook.set_current_page(max(current - 1, 0)) return True @property def active_page(self): """Active :class:`DocumentPage`, or ``None``.""" n = self.notebook.get_current_page() return self.notebook.get_nth_page(n) if n >= 0 else None def iter_doc_pages(self): """Yield all :class:`DocumentPage` instances in this window.""" for i in range(self.notebook.get_n_pages()): yield self.notebook.get_nth_page(i) def _iter_doc_pages(self): """Alias for backwards compat; prefer iter_doc_pages().""" yield from self.iter_doc_pages() def get_page_for_doc(self, doc): """Return *doc* if it is an open tab in this window, else ``None``.""" return doc if self.notebook.page_num(doc) >= 0 else None def _on_tab_switched(self, notebook, doc, page_num): """Handle tab switch: update action groups, title, parser UI.""" # Copy paned position from the previously active tab prev = self._last_active_doc if ( prev is not None and prev is not doc and prev.paned and doc.paned and doc not in self._tabs_needing_paned_reset ): doc.paned.set_position(prev.paned.get_position()) self._last_active_doc = doc if hasattr(doc, "fmt_actions"): self.insert_action_group("fmt", doc.fmt_actions) if hasattr(doc, "editor_actions"): self.insert_action_group("editor", doc.editor_actions) self._sync_active_window_ui(doc) parser = doc.parser action = self.lookup_action("change-parser") if action: action.set_state(GLib.Variant("s", parser)) self._apply_parser_ui(parser) if doc in self._tabs_needing_paned_reset: # Same GTK#1959 workaround: defer set_position until after layout. GLib.timeout_add(20, self._reset_active_paned) if hasattr(self, "file_browser") and doc.file_path: directory = dirname(doc.file_path) if directory: self.file_browser.set_directory(directory) if hasattr(self, "search") and self.search.get_search_mode(): self.search.set_search_mode(False) def _request_close_doc(self, doc): """Close tab for *doc*, prompting to save if unsaved changes exist.""" if doc.editor_type != EditorType.PREVIEW and doc.is_modified: n = self.notebook.page_num(doc) if n >= 0: self.notebook.set_current_page(n) dialog = UnsavedChangesDialog(doc.file_name) response = run_alert_dialog(dialog, self) if response == "cancel": return if response == "save" and doc.editor_type == EditorType.SOURCE: doc.editor.save() self._finalize_close_doc(doc) def _finalize_close_doc(self, doc): """Stop the doc's refresh loop and remove its tab.""" doc.stop() if doc.editor_type == EditorType.VIM and hasattr(doc, "editor"): doc.editor.vim_quit() n = self.notebook.page_num(doc) if n >= 0: self._tab_labels.pop(doc, None) self.notebook.remove_page(n) self._update_tab_bar_visibility() if self.notebook.get_n_pages() == 0: self.save_win_state() self.destroy() # ------------------------------------------------------------------ # Old refresh methods removed; refresh and title updates are now # driven by DocumentPage signals (doc-state-changed, words-count-changed). # ------------------------------------------------------------------ @property def file_path(self): """Return the active tab's file path.""" page = self.active_page return page.file_path if page else None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9632962 formiko-2.0.0/formiko-vim.desktop0000664000175000017500000000061115152030611015767 0ustar00mcbigmcbig[Desktop Entry] Type=Application Name=Formiko Vim Comment=reStructuredText and MarkDown editor and live html previewer - Vim mode Exec=formiko-vim %f Icon=formiko StartupWMClass=cz.zeropage.Formiko.vim Terminal=false MimeType=text/plain;text/html;text/x-markdown;text/x-rst;application/json;text/json; Categories=Office;WordProcessor;GNOME;GTK; Keywords=Text;Editor;MarkDown;reStructuredText; ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9632962 formiko-2.0.0/formiko-vim.rst0000664000175000017500000000247315152030611015136 0ustar00mcbigmcbigformiko-vim =========== :manual_section: 1 :manual_group: General Commands Manual :date: 18 Jan 2018 :subtitle: reStructuredText and MarkDown editor and live previewer :author: Ondřej Tůma (mcbig@zeropage.cz) :version: {version} SYNOPSIS ~~~~~~~~ formiko-vim [options] [FILE] DESCRIPTION ~~~~~~~~~~~ Formiko is reStructuredText and MarkDown editor and live previewer. It is written in Python with Gtk3 and Webkit2. It use Docutils and recommonmark Common Mark parser. This version use Vim as editor. At this moment, **this works only on X11 graphics backend**, because GtkSockets not work on Wayland yet. OPTIONS ~~~~~~~ -h, --help Show help options :FILE: Specifies the file to open when formiko starts. If this is not specified, formiko will load a blank file with an "Untitled Document" label with ReStructuredText format. FILES ~~~~~ ~/.config/formiko.ini Your personal configuration file. Formiko store your options automatically when you make change. ~/.cache/formiko/window.ini Your personal cache file. Formiko store there values about window size and paned panels. ~/.cache/formiko/WebKitCache This is your WebKit preview cache. BUGS ~~~~ If you find a bug, please report it at the GitHub issues tracker. See https://github.com/ondratu/formiko/issues. SEE ALSO ~~~~~~~~ vim(1) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9632962 formiko-2.0.0/formiko.desktop0000664000175000017500000000060715152030611015203 0ustar00mcbigmcbig[Desktop Entry] Type=Application Name=Formiko Comment=reStructuredText and MarkDown editor and live html previewer Exec=formiko %f Icon=formiko StartupWMClass=cz.zeropage.Formiko Terminal=false MimeType=text/plain;text/html;text/x-markdown;text/x-rst;application/json;text/json; Categories=Office;WordProcessor;GNOME;GTK; Keywords=Text;Editor;MarkDown;reStructuredText;Documentation;Markup; ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7488818 formiko-2.0.0/formiko.egg-info/0000775000175000017500000000000015236556646015327 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1786437029.695576 formiko-2.0.0/formiko.egg-info/PKG-INFO0000644000175000017500000001641015236556646016424 0ustar00mcbigmcbigMetadata-Version: 2.4 Name: formiko Version: 2.0.0 Summary: Formiko is reStructuredText and MarkDown editor and live previewer. Author-email: Ondrej Tuma License-Expression: BSD-3-Clause Project-URL: Homepage, https://formiko.zeropage.cz Keywords: doc,html,rst,docutils,md,markdown,editor Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: X11 Applications :: GTK Classifier: Intended Audience :: Developers Classifier: Intended Audience :: End Users/Desktop Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Documentation Classifier: Topic :: Software Development :: Documentation Classifier: Topic :: Text Editors :: Documentation Classifier: Topic :: Text Editors :: Text Processing Classifier: Topic :: Text Processing Classifier: Topic :: Text Processing :: Markup Classifier: Topic :: Text Processing :: Markup :: HTML Classifier: Topic :: Utilities Description-Content-Type: text/x-rst License-File: COPYING License-File: AUTHORS Requires-Dist: docutils>=0.12 Requires-Dist: jsonpath-ng Provides-Extra: extra Requires-Dist: pynvim; extra == "extra" Requires-Dist: m2r2; extra == "extra" Requires-Dist: Pygments; extra == "extra" Requires-Dist: docutils-tinyhtmlwriter; extra == "extra" Requires-Dist: docutils-htmlwriter; extra == "extra" Requires-Dist: docutils-html5-writer; extra == "extra" Dynamic: license-file Formiko ======= :author: Ondřej Tůma :contributors: `The Formiko Team `_ Formiko is a reStructuredText and MarkDown editor and live previewer. It is written in Python with Gtk4, GtkSourceView and WebKit. It uses Docutils and a MarkDown-to-reStructuredText converter. If you want to **donate** to the development, you can do so via the `paypal link `_. Features: --------- * GtkSourceView based editor with syntax highlighting * optional Vim editor support * vertical or horizontal window splitting * preview mode with auto scroll * periodic save file * json and html preview * spell check * linked file opening It supports these parsers and writers: * Docutils reStructuredText parser - https://www.docutils.org * MarkDown to reStructuredText convertor (M2R2) - https://github.com/crossnox/m2r2 * Docutils HTML4, HTML5, S5/HTML slide show and PEP HTML writer - http://docutils.sourceforge.net * Tiny HTML writer - https://github.com/ondratu/docutils-tinyhtmlwriter Custom directives ~~~~~~~~~~~~~~~~~ Formiko registers additional directives that are available in both reStructuredText and MarkDown (via M2R2) documents. ``.. file-tree:: `` Renders a directory tree rooted at *path* (relative to the source file) as a bullet list. Options: ``:depth: `` Maximum display depth. Default is ``2``. ``:include: `` Show only files matching the glob pattern (e.g. ``*.md``). Directories with no matching files are hidden automatically. ``:exclude: `` Hide files matching the glob pattern. Applied after ``:include:``. ``:links:`` Turn file names into hyperlinks pointing to the files, with paths relative to the source document. Example:: .. file-tree:: docs :depth: 3 :include: *.rst :links: ``.. mdinclude:: `` Includes a Markdown file and converts it to reStructuredText inline. Requires `m2r2 `_. The directive is intended for Markdown files, but works in RST documents as well. Options: ``:start-line: `` Start reading the file from line *n*. ``:end-line: `` Stop reading the file at line *n*. Example:: .. mdinclude:: CONTRIBUTING.md :start-line: 3 Vim support ~~~~~~~~~~~ Formiko has Neovim editor support aka ``formiko-vim`` command. This runs `Neovim `_ editor in Vte.Terminal. Requirements: ------------- * Python 3 * GTK 4 * gobject-introspection * PyGObject * WebKitGTK 6.x * GtkSourceView 5.x * libspelling 1.x * gir files for all Gtk libraries * vte - neovim support * docutils - reStructuredText support * jsonpath-ng - JSON Search path support recommended: ~~~~~~~~~~~~ * m2r2 - converting MarkDown to reStructuredText * Pygments - syntax color in html output code blocks * docutils-tinyhtmlwriter - Tiny HTML Writer **formiko-vim**: * vte - Terminal emulator widget * neovim - heavily refactored vim fork * pynvim - Python3 library for scripting Neovim processes development: ~~~~~~~~~~~~ * pygobject-stubs Installation ------------ Flatpak ~~~~~~~ Formiko exists in the Flathub repository as cz.zeropage.Formiko. If you are new with Flatpak, see `setup guide `_. .. code:: sh # add Flathub repository as root flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo # install Formiko as user flatpak install flathub cz.zeropage.Formiko # run Formiko flatpak run cz.zeropage.Formiko Debian based ~~~~~~~~~~~~ Debian-based distributions sometimes use versions in package names. Here is an example for the Debian Forky version. If you use a different version, your gtksource or webkit2 may have a different version name. .. code:: sh # python3, gtk4 etc are in dependencies apt install python3-pip python3-gi python3-docutils python3-jsonpath-ng \ python3-pygments gir1.2-gtksource-5 gir1.2-webkit-6.0 \ gir1.2-spelling-1 gir1.2-adw pip3 install formiko --break-system-packages # languages apt install hunspell-en-us # or other language you want # neovim apt install gir1.2-vte-3.91 neovim python3-pynvim NetBSD ~~~~~~ **Broken at this moment - the WebKit with GTK4 and the libspelling packages are not available at this moment** Installation process can be different for each BSD releases. It's about which Python release is default. By this, you can change ``py313`` to your right version. NetBSD use pkgsrc, so some binaries are stored in ``/usr/pkg/bin`` directory. Formiko call neovim directly. If you want to use neovim version with pkgsrc, you must fix ``VIM_PATH`` variable in ``formiko/vim.py`` file. .. code:: sh # python3.13 is in dependencies, as is gtk4 pkgin install py313-pip py313-gobject3 py313-docutils gtksourceview5 \ libadwaita librsvg webkit-gtk pip3.6 install jsonpath-ng formiko # optionally pkgin install neovim pynvim pip3.6 install m2r2 docutils-tinyhtmlwriter FreeBSD ~~~~~~~ Installation process can be different for each BSD releases. It's about which Python release is default. By this, you can change ``py311`` to your right version. On FreeBSD you must install all these packages: .. code:: sh pkg install py311-pygobject py311-docutils py311-pygments py311-pip \ gtksourceview5 webkit2-gtk_60 libspelling py311-jsonpath-ng \ libadwaita pip-3.11 install formiko **Optionally** .. code:: sh # MarkDown, and tiny writer support pip-3.11 install m2r2 docutils-tinyhtmlwriter # languages pkg install en-hunspell # or other language you want # neovim pkg install vte3 neovim py311-pynvim py311-typing-extensions ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7014315 formiko-2.0.0/formiko.egg-info/SOURCES.txt0000664000175000017500000000236715236556646017223 0ustar00mcbigmcbigAUTHORS COPYING ChangeLog MANIFEST.in README.rst check_css.py formiko-vim.desktop formiko-vim.rst formiko.desktop formiko.metainfo.xml formiko.rst pyproject.toml setup.py formiko/__init__.py formiko/__main__.py formiko/application.py formiko/debug_stubs.py formiko/dialogs.py formiko/directives.py formiko/document_page.py formiko/editor.py formiko/editor_actions.py formiko/filebrowser.py formiko/format_utils.py formiko/formatting_actions.py formiko/json_preview.py formiko/menu.py formiko/preferences.py formiko/renderer.py formiko/shortcuts.py formiko/sourceview.py formiko/status_menu.py formiko/user.py formiko/utils.py formiko/vim.py formiko/widgets.py formiko/window.py formiko.egg-info/PKG-INFO formiko.egg-info/SOURCES.txt formiko.egg-info/dependency_links.txt formiko.egg-info/entry_points.txt formiko.egg-info/requires.txt formiko.egg-info/top_level.txt formiko/data/AUTHORS formiko/data/jsonfold.css formiko/data/jsonfold.js icons/formiko.svg icons/svg2png.py icons/128x128/formiko.png icons/16x16/formiko.png icons/22x22/formiko.png icons/24x24/formiko.png icons/256x256/formiko.png icons/32x32/formiko.png icons/48x48/formiko.png icons/512x512/formiko.png icons/64x64/formiko.png tests/test_directives.py tests/test_format_utils.py tests/test_sourceview.py././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.6958253 formiko-2.0.0/formiko.egg-info/dependency_links.txt0000664000175000017500000000000115236556646021375 0ustar00mcbigmcbig ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1786437029.696051 formiko-2.0.0/formiko.egg-info/entry_points.txt0000664000175000017500000000012615236556646020624 0ustar00mcbigmcbig[gui_scripts] formiko = formiko.__main__:main formiko-vim = formiko.__main__:main_vim ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1786437029.696303 formiko-2.0.0/formiko.egg-info/requires.txt0000664000175000017500000000017315236556646017730 0ustar00mcbigmcbigdocutils>=0.12 jsonpath-ng [extra] pynvim m2r2 Pygments docutils-tinyhtmlwriter docutils-htmlwriter docutils-html5-writer ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.6963723 formiko-2.0.0/formiko.egg-info/top_level.txt0000664000175000017500000000001015236556646020050 0ustar00mcbigmcbigformiko ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786432880.2551613 formiko-2.0.0/formiko.metainfo.xml0000664000175000017500000000643415236546560016161 0ustar00mcbigmcbig cz.zeropage.Formiko Formiko reStructuredText and Markdown editor text/plain text/html text/x-markdown text/x-rst application/json text/json org.gnome.Platform https://github.com/ondratu/formiko/releases/tag/2.0.0 https://github.com/ondratu/formiko/releases/tag/1.5.0 https://github.com/ondratu/formiko/releases/tag/1.4.3 https://github.com/ondratu/formiko/releases/tag/1.4.2 https://github.com/ondratu/formiko/releases/tag/1.4.0 https://formiko.zeropage.cz https://github.com/ondratu/formiko/issues https://formiko.zeropage.cz cz.zeropage.Formiko.desktop MIT BSD-3-Clause

    Formiko is a reStructuredText and Markdown editor and live previewer. It is written in Python with GTK4, libadwaita, GtkSourceView and WebKit. Uses Docutils and m2r2 Markdown parser.

    Features:

    • GtkSourceView based editor with syntax highlighting
    • Optional Vim editor support
    • Vertical or horizontal window splitting
    • Preview mode with auto scroll
    • Periodic file save
    • JSON and HTML preview
    • Spell check
    • Linked file opening
    Main window showing the editor and live preview side by side. http://formiko.zeropage.cz/assets/gtk4.png JSON file rendered in the built-in preview. http://formiko.zeropage.cz/assets/json.png #ebebeb #828282 Ondřej Tůma mcbig@zeropage.cz
    ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9632962 formiko-2.0.0/formiko.rst0000664000175000017500000000236215152030611014342 0ustar00mcbigmcbigformiko ======= :manual_section: 1 :manual_group: General Commands Manual :date: 18 Jan 2018 :subtitle: reStructuredText and MarkDown editor and live previewer :author: Ondřej Tůma (mcbig@zeropage.cz) :version: {version} SYNOPSIS ~~~~~~~~ formiko [options] [FILE] DESCRIPTION ~~~~~~~~~~~ Formiko is reStructuredText and MarkDown editor and live previewer. It is written in Python with Gtk3 and Webkit2. It use Docutils and recommonmark Common Mark parser. This version use SourceView as editor and GtkSpell3 as spell checker. OPTIONS ~~~~~~~ -h, --help Show help options -p, --preview Preview only :FILE: Specifies the file to open when formiko starts. If this is not specified, formiko will load a blank file with an "Untitled Document" label with ReStructuredText format. FILES ~~~~~ ~/.config/formiko.ini Your personal configuration file. Formiko store your options automatically when you make change. ~/.cache/formiko/window.ini Your personal cache file. Formiko store there values about window size and paned panels. ~/.cache/formiko/WebKitCache This is your WebKit preview cache. BUGS ~~~~ If you find a bug, please report it at the GitHub issues tracker. See https://github.com/ondratu/formiko/issues. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7491577 formiko-2.0.0/icons/0000775000175000017500000000000015236556646013302 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7492237 formiko-2.0.0/icons/128x128/0000775000175000017500000000000015236556646014237 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/128x128/formiko.png0000664000175000017500000001603415152030611016367 0ustar00mcbigmcbigPNG  IHDR>asBIT|dIDATx{TՕ羫Q  ƀ#С(f1?!D+3L&o&2WPC3i5Gli>P@t7tU7UuM?oUkj=gߺ\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\>ڐ S֭['EQ(++M^l>L~4C?!` c,? 0}t<MӴD"q@Ӵ7o]GG`ݺu| RZ HP7lӧO}]S!J˦M^|lpL,'+?sMBŌ-0V{~` 8B!TUU܆ ӦM:(ޟ9+;Xiu]~ч?|\oB@ɇ0$h~TTUa7onA;d2R˗/xc ΝÙ3g`O_=Cr$Lu:PUǎCkk+jjjP]]=q󡦦e\ZZZJc-4|cR*0ݽ믿d85^7gEBV\a… tC!k -`6o,?~)IRJ_yѧ~}dϟ?b,+}χ3frܶdYƌ3z9 @kk+.^8ޡ}oٲb]?!T*UL&S@@i(vH+#@4Xq=zMMM@ kvn~4űc2g=nݺ;&uuu),zp$a~ʕ;+={Mxu,(**3gNɧ!*b9rdOŻ+תVtwwC4KUpH"AQٵ\pώ;̵LhѢP6++7|3***roR)477qRJWlݺurŊ+t3g(J.\ht-c `0 غu+8UUs370?>qOw}AAdC-G3l$IEKK}ʿTk׮)CD"ػw/{1{衇FLQD"axǿ-x|i*Z7k֬/glٲ݇u])0uT̝;s"(ͩ>#dY~b;! !GYoI~Յ>:thWt.]7?r294( ,@mm-chkkC8{߃ 6mFrMDkk+A?/ٴr3,8s 4MCeee}Z~&u-Y垞:'׋P(`06EQPSSo!9aO<1"<q,B}eeehmmB^/RvȑT8\x۽p]$ @ mEԩSYyy9-"ׇVXU_hX xΝ Xd $I… {,~TUUR 7:qc,[ [;.\KRSUU@4774M̝;cRyb1S%t]nll|qxCKK>o޼UU+˲>UUѣGL&8}ԩSbYiYYqyr\uUC`!ŋ`XEN:\ٳs΍bryDQȑ#8}4tl{{}&$I8PJ*!,X03J)Eqq1 "b= ͛w>#O%K)}8(# (s碬,vL(++CGGDQę3g:ZL2evLĉ'PZZ Am۶g}q pm+^UA׳#)8ǍؙσR ?f0 D"ߏ.nqLXTVVž.ʲ,KtM7OMgݫD"qiiyEQu]D".(8 ,:W0.݄B!477#L" jEV0tDc7A(7hL_d{si7h݁VX4?L&Ξ=XJ)$IFBPJ"i_ 9;}Y0׉F&nذEEEKR|4 5tӋ/x/ _oEY \[[[af !Ax<2B!aK5!j;u=0PK_4˲v)2Gu;=QJgg&"Xlܸ~Y̿u]:V^V{"ӳ0dYpDQɩ"ٍ_I;y?(J)J=7Z@ٔL&Ӄ/کӜڣc#F2 x 9Yd2pww7?e?~9QFlܸ?dY$ @aǙ8q1Džc vRW_}5]{ ?iZK2&[!_p7a? k&p(©3Vwu׍$gBO)=O(J4 vjƥصkWק?uϟ1V*vN<:.'t»bM],K? dd>aՏ|sGBܹsOݻsҥꆁ<(Xv=L|wݹlzb-Hٶ5a|~t1~t_ٳg7SV `ؐ88q?ΦL 9pi>Xxw|ny/..~lc2ÇsK䂢(CHDLH$g)/[,Bkkkef]]ݸydcr0;LJ1g6,O( T鑆;A^0e᭷&#tqZdY&x뭷6Xl`'d'1α(8h0aY?Q2Ȥ ;v y ϗn3W$I:?#l,˗/nd2GpɾƢ_F 4M(T{躎ӦMx4˲ŐL&|-۷u͚53T Ǚ+V7UUoP0dJ7w)I >Qޕ ?Þ&'۷RYWVV.EQ|:!c x7q;wn̰r<Gooo'򆆆^WBkBJnu}i3cS8 HT iV?=xLcQUUiӦwWuh4m !P}رcG]]?pu?$dc,\(^ -va͚5}aBpEE8qĘy?;~ %I*;w,!dW>cl:!dolll ,[%trH$2d?EQ.x[m6n3<G{D+Bȣ*Xt]g>yY[nx<Ֆei eqY=5k֬/o޼+MJbIMpEB?644{|}Ђcؘ-GR(%ag e]keiWqՔjXc,H)-QJK(^˲R4Mٲ,4Ͱgy/p8}뺎d2rxl6 ~4M[( =,˱]vEDǏ$I/[h4T*A+ü<ڵk3Ƃaf;)²vy2D*aPz*vtp8l0]׉(9޽{!ZZBvuaeR ]aƓ yێ} c{maǎիWRY~ L&*t]eYTN2esF:3ldݺuy9,"H L%?r!tUyyMӖSJq!I!Qmllko0W&TqzU{)H^?8K'+cUsTIiQ{ak W= v5I[|,, 0{]0;n4a u}@AEdqx<kTCCc?> 0 0~kce) o?=JdSi fGGTU,˵-O!p`(a+nM0>@ ;8чWXz9ik&W~R 4|l\i{Ase) <+f\rEdNq^x|e)$ `B |㈋ˇHnǥIENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7492805 formiko-2.0.0/icons/16x16/0000775000175000017500000000000015236556646014067 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/16x16/formiko.png0000664000175000017500000000115615152030611016216 0ustar00mcbigmcbigPNG  IHDRasBIT|d%IDAT8Œk`ǿ6-kҴd%6ѵBiaNݐ)  i$Vxܟ (HIe)n&ºNbvk5dccy?<}0??| \`nn$I0\9S988rJij ʍR J),Kz2|CCChmggx|midssfmqBŶ,zT(' 4'Ǘ^ORVmTv#Hd"ϿqYe~>::\ ~zr/2$I1@A6]a-1;::Zb~tވF)MB!___/E loopxxxh<qja,ǂ X__.c pN|J<EqBQzRTm~j16 ]ׯ3M2Ejmzzyuft]JӑyB/ p]yO0+xIENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7493408 formiko-2.0.0/icons/22x22/0000775000175000017500000000000015236556646014061 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/22x22/formiko.png0000664000175000017500000000155215152030611016210 0ustar00mcbigmcbigPNG  IHDRĴl;sBIT|d!IDAT8TK[W{ύ5I0f,`6bԂ7.L=lAa+#PdZBh!%Yb6?3^oLƛ{blmy9||>r8XhIe355us=566uttPf麾Kxܪ8 zn> 0nƍD"Q6h?77RN׿֐Jp8jLUU*54>n{{;iwPQȲ0~x<twwrAӴ3Azu?LOOȲL&vn@ZQkfEN'n7:;;߲GEA&RZuݳX}}}rPnfM1($ "~s. d\E,k?(bYV1Ekkkl6ˋ"c $baJ晙gotgggSd2^آ(1c P(}>]kgGFFn>' UUեQT*}[L[z~~> eY!\?%̠bq |T 8>>N% =V(^qVQs\R#¢nF$n7%Iyf=*:"H" ^'&&Kh40hNp/s544vrr+imYփhB$yU/RǗ³U+MIENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7493935 formiko-2.0.0/icons/24x24/0000775000175000017500000000000015236556646014065 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/24x24/formiko.png0000664000175000017500000000175715152030611016223 0ustar00mcbigmcbigPNG  IHDRw=sBIT|dIDATHR]H#W=33ILKm5!j(Z$}hԾ>R肰 -Eq X[t]%̺d4q2K$FKmB={/?mpx<"cϋz_ K+ zz t]?eˑ[  ;L&7779J)<ŐP)x<>tD(Z@Nmۊ\yhiiurž#Xpttayo  vvv~udlχ*TVVɭV+^((rGU'gޠ%I( g'rU[[[ݥL&ktK$J,\c "BeWTTRt:555Yn7GJR`&6f[K3 MBHnw`QIIP[[:p8( _d2BE.|>oȲ\a&y>rVMU|!]{p*@ӴiwKtz4͛KKKp#MӞ.//7t8%I)H$C08}(D"7riکP0M0J^!}]@ 0Lzjj^Qo뺞_YY|hh(|>(Wu]( l6 պi_X,{B]TU]=Hx^)^Ӵ(򝝝 4 \[Ӵkfyy SQq<1?7t:߳X,]TIPUm]cgff~}]o=gIENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7494457 formiko-2.0.0/icons/256x256/0000775000175000017500000000000015236556646014243 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/256x256/formiko.png0000664000175000017500000003623515152030611016400 0ustar00mcbigmcbigPNG  IHDR\rfsBIT|d IDATxy՝9ZUUw 4 /▸DyiŐ01"ĨLf2f&cI&f_56IbȍJ 4.DY*^_/^T?Tou0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 `03mf[lዊ !GQQ!'CP{uc0bĉsNuJ.ٖgBPWW b6j({oԒk ܹf2l6̟?,>V0 E{2f,9vyRzYe,UUUq; zp]vqyM; ٖeȲs碰0ۢHPv^a>YS;vXH)T)))A}}{قRP(UڵTeb&x_+DQ>o qFO)f& 2k{Bu@ cy-< U7|U555W+|L#"'?SPpo;kɶL s0 Μ9s#v=/WΒ,X6-ۢd˅?`jVĉ,Cqbr:F>)PWWht]Gww7:;;A)/ĉg%< ^z$Kٳgc94d6? hooG Hwˠ*e˖)P(epOT̙3'Vت*PJqytvvB ~t޸g-12aKrCCc``{4 N nguI S9EA{{;ǚkٔ'N2[6F0L7\[/ti8<JKKqjM~ǃɾ.'N V޲}+VxpٲeSNe[`̻':ł9s栶 3gJJJL'W==wxC=t([:AxZfi麾WU՟wʶa،4mJA?PgΜo;90ÁK~j'(‡~8u<<4[ou,},_F)n7n7xeY^aZx׮]|ͷg[tG+N/DKh58555{+QGfcc/||a۶mZ,s(S/MӠ(W^ylɜի]ǰ9Պi)Պy84 Νo8_򗟚d~{ oKT 8Rϟ?Z?H5@ H$ׇB0_|L˞öHwM`0>o&5 K,N~ߏ?Ь72@N\, zzzp9{b)m֭,@YYCFW<@'#]t>{ۋӧO#="M4{bIܔ盚=|>t$ (~{ڵ9qi g>srhh#s,7o*++&x.pmmmS9ޛw~*f*(L ꫛ3"͛+Wb3!H) 'ēI(!䋏>shgΜTpGkkkmիW}u߫UUU(p8|5$`.YgfaXbEE"e[t8\x.ۂڵk#2x\y^H$p6-"p8PYYH$O<3˭Vk@x'؈#G$Do N|A.O~`$-[̘/LDرc;YxTQ躎RB #N>_bq׻R6x(Bgg'_@)Eww7L˕0zժ8qbFt1^`OL'cwww͛RU]׉((l .|=.Y6{ 8~8 IJJJr_ re[rի777Uzl*.\x_tpGʾ># ,#-VIicIAƾgWgjbZcYfeM/VTtc)vɲ|?0rf'm6?f7h۹/`js-^jݞ`0'O'Dcc#>q(hiiIU3ߨu0 m //td\6x}`]s(8 ZZZ@)EiiǃgώW3 \|饗:3vm$]tQlrL~?gΜZj㪪^.놖ňknaǹsueyêU /: y# l 2IHCC.#un[ZZbOeLY1ͮnFb z@ʲ|ك>ɶ eڵdY0r}̠ؖ/?t:ggk|)..ҥKbŊI3ZO1:k`'HBDb# !8NB~}BvY#'F}p8^8yLb)kg+BPG0y].x 曨ʕ+tRHRj_zr Պsf-`2 o;vw9&B)>NL`6Yp k^4-o_֖P2!6e톉t:1:XyaJ;0qq>uwwla66 -Byyy%Baa!aX& 8SJ=w=-!aڵ?eӱL93񡡡˲-G(,,DUUg\AP[[)4JvS]S:`$6퓀Fñɓ7y%Ky:U!G믿?V5(**ҥKngΜֈ'dPUΝK(=M"|͛PVVv fEQt:i FRJH$188ثiE$U#XΞ=d&TpW ϶LuP^^Q36vTs"<:::8 SJ{ Ey6 RxS^VEEE $AQpc-unj<[[Z[[gLD2žFc0gΜت9Y N|!HdsOOK,p8ٳgrU8;|bA,u](Y)3mf~DTepYf%,w줂 ${$`0Xr( 9{,PQQ<0g`:dikkkY|x>mY ]188sappPAIYX`*%?||D"$*kOO\.D`PYYYBR^x^lSSvHAZ1CdU… 7l˒D"\.&e98ա~(hkkC uQ$AmmmB$Ihmmy5M5`X IRu]wɓfdVpM7=t:&L>D:Bggg:B.){fi, ?/VUUa޼y vv\ >@  Dt3gμf2yFO#eeehhh@M .]ӓ&Pà| DQW_=nBGGZ[[! ͺCUՏ^z饌6Pf͚v !s7o֯_?e?IL6#V\rBcG%}ՊڄP( j2NެK,Y^"ñD B)92v; _ª*̝;wB+/ryX轜`{KK}̑7@/t !)..N=qh",^8m֢(hooǓJ_@ fSJL4≘ At:qytww'%i @UUTVVO>d" $o,(˗/}([UUs+0>ϱX,1g yֆǏ>v=\.={6c&CMe@ Oe˦6vQ^^2(󡾾[pNj-sS---+V|뺞wL2V󡳳'ON3cMBN'TUq1%Mܹs u* o@)Mp*bĎ )+}_i3G)hkkk]tzYAHp 6ѢZEx'Boԉ8%%%9W7/_08RVB ~q5M(ɘ5k,Yb}p6 cx? t޵{%EFʫ,bn{ž $Ib8ʢ G~?f[I>())틢xB ƒ$K.rLAp饗onn>{3GNA3uUUt:t2Z@lӧYf]QYYXTTr`f$_<~P(fz 1 Zk f"4!s^.z8Iz, SeȲ) «ࠩcʲS`0qnJz1OAAjjjr|f8ؼys'5~$!{5ä~/lcSJ5nC49:9?K\l6[:&u:YbầOFYYY>EQRp$썍Yo2u `۶m;EQ|0WBN)x<,Oyٔwlݺ&?0APDMԉDLh+6d4/HIDATw:nw,L8SJȩKsɚ뮻JEyb|6z-Rꡔ>iC\iپ},?zB4-#3,z۔2kQ B'(Ɨٚ:? `֭ g%&Uf߾}?ʆFq]wRJ_"\%pSE0LO5ѣr`8%|'WYf5v9&qu=c}rBHN83~$oNs{}#3m۶dYZ(}>߸+ SrRͦV]wuׇᓑH0;uTGc@"PDl EIyO)=λۮ׿>hZo8wݼ뽠yoT@h0 ɲ<(rbybZHYf-;:t5}L qɷ,2Y1=PYY'-[,bE|D{θ9yW !AB% s7ukv_p=tи>|᫯z]- gPB$IZX6ȘƊkI{=)ٌ[ojQX9֍N9K)0~I"Nꫯf7|饗龗 [!M}La$`+ 6l8. eE Aτ\FpwXt], 沮:Q!?ݻweCi@˯ P(Z8H$jmmE$… gyBN@F|ҟup8@ ӉP(_kl۶KǝX, R ˕4Lh/@YYS1) I.s om߾~-2rtAJ7y>|ر)4dݺu9s+IelSS֭[9Ґ-&͖] ñD'BdYF__wxx8󫨨V#E19^7u +Qz$2Qn{RSbEQ$IEgRL.DD!yw"( N'8߄㺮D"x'd6Xv-..7t">#A)8ҙ6r|Hek63],zAx*gJiRGu 8vs6(ZĉtہrX,R:C@*_ӴtA :!"]O*W$̈`F)pG}3z裏o%bF8qJJJl6*++|.)7Z0L3ӭ$:pML~'l!$iL Lxws1 m޼o?TZZwQ% =NՒa " 7,LEƋ$ %_,,,L0)R +~뭷ٙ큪q ;oYc;~/֮].Ŀ>{aÆ;JUUgB(eJi1RP@)t dB,ٳ'sNoZ"yp'~b@K-v^z]SG(--Emm-*** W-?hRZ_~yߦMn@̏ !}ӦM뛔F:CCAA4M/k _GL(79iZ[ y(**j5k%^ʇ Ǜos}( vM jjj:L) G(5$MZSe-88twwjZB]׽>9 3iNˀM>zw755t^tEu? =9H0TVV;u=f8Xq-J^,,ǜ8<'00( 8[}r޽{s &tbɋB)mPXE㦦bΣ۾Yf-gf t]G__`Q[[ vtNg#rpc]w޽{_ټy***^^GZImY<HPo)[{ڨ1DQo>No^4L]O _J_MMMl~|0/BDc 96`>88UZZ:o"?ʳ>SUqGA>3_kd2P,2 &h=Bݛ7޳l{{{iAHlbOᄏJ'ѝ"kL[`Ĥ y}${Re ݎ2~ VH?9۶ms[VGU4/̀ Ye6ᄏWU(^WBK'# Ey>nZq\ף(J8P2aAZ]!Dҕx(N~|СgAVظqC)up;wBui.2 P/򡄙BbyD"\>BHr#oΟ??'OLzq??t?fC@شiӏ4M+EVH$B"TU_dO̒8M+cH$ҍpNᐜN'/[G8l h$6lWJ N]1<<< ꧔9fLL ߖlܸ񇚦}R46wRp8`0;SEQӟԛqd,o o +r-?VU!bQJ}f|O*8K H$ fؾ}S|m"1_nʛ>B8ocfغuC,/r%y^Eς 4M031 @GBЫ^q\(%vrMӎl0#s>-gϞ OH(Id20!$rM: mc$zR`0We$_ BjA` D-!$ISy S$DQ`( a )SY8̃H5XfM^f$#H$H ˲)F(lםN(b5U i0Hb޽)S`$w?BS`Lf<`Lq-`6L0&85 Oa m 8ly0c0MGpY!2$I+,,|>2 Sl)**:XP(B2널G٣}#FNöR%u)@Y2/H`D['SXc L0婧zJUToエ`ljj({1)u)1@)TM@ u=i NVOٖa `\A pac`˶mX,PhϦ< ca n:Yw@TB*P'J))((Ď0` ]K^/<"H4%:r1)FJ6nXKFcn7,gU00#%ҟGG"uA^ˊ@ S` ]oFTU]8?&p`R 5#-[B8 g?fL0?8{#[21́)F1xx:+1L)FYH5`M)q~С*p`$q`$w a5L0X~wRA8-#J \Vb S!GH9g L0(++{bĢx,˃GdY4 d F%%%l6` q-H@US'?!=Ѭ 0  X,,?ͮD 3!i㮻 !ۓmy|$|>߭&6 `0 `0 `0 `0 `0 `0 `0 `0 `0 `0 H*; %IENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7495124 formiko-2.0.0/icons/32x32/0000775000175000017500000000000015236556646014063 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/32x32/formiko.png0000664000175000017500000000262615152030611016215 0ustar00mcbigmcbigPNG  IHDR szzsBIT|dMIDATXVkhW~wksm^HkK΂C\nӱTHݼ`*AyactgȜZ1F)ER;JӖ4i&i/w91+1MU0|yy OA?pRꠔV8 o{zzcQJ)`٠:,cG=z4zB Vk׮(Ⱥ󹹹~j мB?jJ), A(9 `0XC)u|߿Ujx<F !I\.L&2An߾X,4|bʆ);/MLL^s6mt=ЀfX,qLOOCU:YI<Ӓ$e9Եk׌^֙3g5U2::L&e l0+++u)#rdYޜd)V cl,ˇm۶m(u]W'Lmmmɓ'w>'R»v:K$c$zMc@Kn'􉅅>O!$a .3Rat]g @j N?(b$I_ l-xXhv,nX-;@yVGY5ݻ_o^SDՋIENDB`././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1786437029.749573 formiko-2.0.0/icons/48x48/0000775000175000017500000000000015236556646014101 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/48x48/formiko.png0000664000175000017500000000444615152030611016235 0ustar00mcbigmcbigPNG  IHDR00WsBIT|dIDAThXml[W~W츮W7[<US*4*BZV15(T2ۘi &@. 4E[uI_׹~͜_]!>}<>X*Vg BB<hud2ظRD(zgtVDEh!p8477CEQ^QUgPhru/ p=I `͚5eIcPU5(`>JKtvvEqd2u|*, Z[[t:+B l6q`0z7^ʯ`HLVFq(χfX1˗/czz~O{{D8>WoiiyW5&IF&hDKK 8l2h .`ff!8q뮻 1== YX,BdYcؑǏ4SM$鉉˦{zҥFx<x<saڵ| `nnx;%IRy t8ESp˟n˖-MӁzL,׋FvlbSSg69EQBQ\.b9L&?g4 _ xJ1ͅ1$IpYb1X,h4rDSSSkD"aL&n\nYXX@X<{ƫy"ȗl̪p\HӰX,hkkZST x`pQJ1;; ۝hhhhzg˞WgϞy&iZR& B4΢X,rAe˗/y45}|DpQ;|͗@OOOf;d$IX,Y|d29 !MDy6 p\ppp8P(UU]'fv#b~~7ov@nnooo뼶\\?I&tꫯt? [n$W,ReG+r,;O* ~_{@XJgΜyGuj& d2U"Bn02.$I$q5P(,]M}4@UUd25 ,Kܽ{N\Γ翦(1h4|:;cBEQ{<s P_fD"S4eYvj63::Z} 822Z",ː$)f֯_L̀R_s!NUU2Ƃt,OեiUQveT*J{홊z_2@)`8Zl׮] Ya4G=c5Cų/=(fggL&|H$BxᇟھlUUa0⃃*)UUa2_1eZO(/bddΝC.C.&IP__߿q Z>V=}t+ U}Ax嗿ZKNEQbBᤦiO ekci]'N8ԍ qA0֎NޛMX R%t]g \S:ou-=c6[xܷĀBz{{i-YBt\nn%o ,..4-+>>`7~p% ycoouU>-83IENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7496254 formiko-2.0.0/icons/512x512/0000775000175000017500000000000015236556646014231 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/512x512/formiko.png0000664000175000017500000010031015152030611016350 0ustar00mcbigmcbigPNG  IHDRxsBIT|d IDATxy|[/{Hlkر!  @… I ev̝ۅ;,wz3% .Li]iS d-۱J]:#+lK:ZȖy>B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!B!wdw]fX,Y^/;&B7~499iyFUUm1X7 Pڊ @( |dYY!$_P@rn'tvEEZZZ bc!Q@-'t1ԄZ0: LD!2G>磬 ˗/h\k$_ G!yPh'Gcc#8kFH$~{aB#b>h4%"W~7fC:ϏfCKK xFP(םo !$(:ghnnFuuޡ&'BEx5MUU2`9S^^ CA4Aw((_?F!$,C=,qCCC%@tdB`>OW`)Ix$IhkkTx*5|G@?ҥK޽J9wLPG|p@iL: ˗gQUU|`dYFggW!O}S6YcO@r ---EOOh~BȂL~3c5MlzSjAe`я>f%ӯwLW @gg'p8Zu$(JѣdN./?;RqQ__w(%iV27Ac"䏢L|TU:K2LX|yA/F Bf+l\ߡw,1:ZޗdYV}>`0ϪT{Eq޷o_ p?*/׉ hmm->((>P(tBJKA'4s?@td,%Lz!(4qf ɠH$"|w) xTaWӴ@:X,hii$IzB(~P(tB2 f}WOcK,B >?;tI!,\/{FǿDAn;tc",L^'7|󑆆fYP)4 NPrA6}36Buvyy# wH%IEѐD"4Mˢֶ=uꔖAijkkֆ2C+hiiDy<vDyf _aX ˹oynw1bŊ8K.Emm-] M0<< ÑeM裏!k'O2d2a͚5 B8sL'cO۷*qB2+odYޖةz3H~%t?/]<,Eɂ-\t_|tzqE~XVw~ V\/Ancxxxv|ߺupWWWW&c#dGnzv/c4"0O6 ˖- "׋d~d8xۙx1BHmpW~{R"qXldhhCCCmBX^q`pQ U###hlla4҆Qeeehkk}%* nf3ƾ `6^y9p-}5DFjSn7v;E[=tпeM! ޑH$4 xq̙LwIn:444ɿ)>twwxչx#R:::0Yp|4qJbD1j455~  MLL\O^Me޽_3D\,Di,`ǎ6xGcc#ZZZ2;M066ϔ;t_$߿_ BDn \ee%IQ,g4M{:O?=cE'/-[NNN=o)@KK E(QHvSSSz1>tzB޽{wƟ P199 *++!Isk5M,ˮH$(|'CE%/p MZ[[ B1&_rX藮1Y7=i&k޽k2>q.էNoX`X </e?wg;|i_%hnnƲe l6hF1 ÁLτNLLOMVBrk߾}UHג$mt)6 lqےG`A4aǑ#GZً]EXssnzǡin7.^EQ`Z󾀮˗//dN @ww7nޡ$j2]]]/ɽ={|HW CS@`EA `q=G-k֬/ds;555xޫwQ(h4RiN'zzz ay ۶mc w=d2}0&'i`0҈c,qNiǗ/_~]ww E)]w3;DS---y;f%G'HvD"U<EQ//N!ٵ{$E~}*qTCY|eee-?O;D~]D"+!i z{{!L9m ٱk׮f | 2ޫi6*.X?ٌƔ@UU/ył?H˻M.d"zzz+/VZN%* ?|ܘLLL$|N9 "xΝ;{/yWcǎƮ;E˖-CssDQDKK /a^v]M|r1vU8Hfܹ3"IҜh!AoQʺٕ[lɛy_Պ%KU~`E&o[ MNNVK& ,_ i%6 ˖-ˋDCCCǎ~yGyށŻ۴Ǯp8 $Zݷm۶MNN~]QOAd'}(eGI+(FyM7ݴbddL0, A}}=4EPWWSDoPv=g1cu\ki¶k׮ƿ}NZjZx뭷'[399y4,I'.`@Ŷn1佽ׯz?T,WAbdd`FfW!611 .$?~/`mmmO0g)lD 8GGGaŊpxksB($ CҽQAeYʕ+/'}"wEn}}zǑ Pxq1LNNXKQ'v9gxJRc-w{+<;v6IR.eLZ@k0 7vu.E^Oھ}9ӹJ88,[ 6lիic`ۓ^q68p`P@śWΝKXϒl?M6U8x%!"QVVq`PapȑWO!)XVzBQ?D"E}F=<;QJD{TKJYӴڞ8uTq[o()|I71Lo?:XbED(bIx<4mʕ+W^p'L`ۃWm Tb*N'N>'N`jj %eK,hTW$Iw $}+V oOT8/_c:"Hʃc0$L&SV---lH =\jv-n9to'ݞ0`W^[7o<1j&] BqEƗw .;v866c ---T/P TUnJB[x̙3 GFԩSOꫯ{jjp8ֆ)Fq;ʲ Q[[w[=ib*x$Iz_EExG7M`[ytwwӐr 8s )|zRJvqY HzL2 2믿Deeej};LJB9qEq!+Xv]]nݏCC,diT/4MDww7-ꫮ;!}/K'r˗WȲCӴ dBSS*++q?'j:`Okkk? "x^tuu{_OH#Ut__,in;TS3!?6ZI/A!2L&Sܚ?uVj]wݿ;|Ccc#۱~zȲ)!tA-?xno=ٳgJw>La`ϩے=.ڢ]$l3*?cEu,)X?^j՞@ Ptx=fiGUWq\cWWWIn֯xC YcccW^=DnWUuι/ qe(،1ƭZwy7JQ/2%3Ȳg??#WFGGA>}:'d>ܭwd&UUNxl-}}UUU36>XOf=*ѣ D<ū(]2t̙3I;If0ٿZ kw{wTofbiX ^{si 7p8LWCSp\zRjNh%pB:?uꔮ˔o~{d2Y>fA$ӭ+~鴊 ];f͚}>_ޱ:H4bzA]]]?;Rwf.rҩEٜd7oOV㸫8d280ƠiĉEmpQ%r'c B>b x<(++Œ]ihh wUW]VWWY)e|^SSS gXw8?k֬p+sAQNTۺ&.R5=*WUU$(7oГCWWl@(™3gp8hmiݷo1$EQ|,kA-Ç8n3f {AhulRt#3aÆs^#tΎ@ >;v v mmm?so֭byy5d,ǿ3Ǐe㛯gN566>!IL B!Hi wׯ_n;bWB?1gY -&I:w fÆ o0}N|OngϞ=f͚`| G^F:۷u:X,Y/=4|>v;A*6s;{_H)ٹsC9>}}w_oƗ\yu,? ;rHQT/ot\>Ϝ$vZBpҢi088HWcCNk.h4X0PWW+$Hgg'٩()OছnvppE9P__ /@?;tQ|a|xu?}}} 7 ˇ䒢;aÆEXJ] LLL4_Vmݺށe˖}SNJ&KMI$5kZ/`6u/iqEZWn޺u둮A)fEql DR"SQ7x055嘜;@ǿt-ַv<_(Jq՚Yc61Ƭ83_ Ǚ4M3pg pgOMMM }?'I˕pc 555_{5{#TvZ>88x6 40^`ݺu0 h]Ay^f1e`YsRU}}}}!M>裏>7xl6{_x1a@/%3^~AzYK ͆UVb`0( %q,'ߏr;lrdzEx$\9c6O jp$m%@__ߩ LKl'0559xp{a0|r444 ^WlAooowcCW]uՉbۯ; ̹O j~wppz5䉒m۶3.kqo@odd$ё\`MMM9oC `#"\H4vp8>1wY[nblllҜTK^  hDKK Vkg)$ IDAT# :|ʉ뮻ZQQ1/r8pqG[v LM XbN'h"q.]```Ns!O08x2W.ڵ1Ѹ/}6˲Z]oM摒s=xv Yp0zF2d2aժUܹs[[e\dXEQN)k77Ɏ2cɒ%0LzC8466NCCCrQ?c춃˵Dqf[od=@6J.&A1rTWWV5oV+ZZZtřG|MuС32L[i122pjbP-PUU*# `ҥ D6w4m磏>F n {ݗ&秒mIPPU>F@$N***rJ]FhdYF___&c7oǏe2eŊ 0gkuUUS|P8bGjkkҎ$"atyǃ^D"\8:t2ovRFn˫6V+َ% l۶Zw$?0`XP]] ͖W[Ebhnn 9qܹ)IR5Mùs6ڪ~/ܠT 7x.^wTHE+ R3hii<,'dYn.7>#IٳnA>Q6i8qAe(yꩧ[ܹS$i9#f;okMzA qXd sk_v{ 櫟8p[o%II_DUULNNNleZaX߯)<%wraxx8i?h~[^t zB l)łH Ál/˔ x?W Ԕ0 L:a4QYY7뛮mQU,ǭuL8R^^~7ܚ#=vzTz$Iy}@(Ǚ3gK/^Cww7\.Wy477c͚5Ξ=B9TU7w_c>`0@[jILLL$:OE# a|||Ʈn8s`08~~SP#4v N|?,sHnEA_***j*all ===>H5qoxo0 7EEAYY|8B8Nz>؇\pAחpdJȲ<]K 2>1}}}}H<]v8;RL&TA8W/H}}}Z7_'CНf˲&xޔ{0=쳏 ξbbV5ei"BP CTy%l߾\wtp7Ztl6,[L %\V0gVQB+*xU]]gx:ish~ZCFGG+Du`6!ϗ QUD;G ZZZt_V\.wvcdds0jjjnp::TUU5c>=]3044ǃƤhDcc#<ϜzTv=q_{TUKJ,˘pݐeFQ+\Rב ߏː4EQt:6K øx|>&&& 2$Iqmg/xD"ӉSl`8(IA$X,x^J\~;RڢE\Ѯv`h4fut@$\BD t''S>\.D"ף,R|@ ǃ9kǃP(40QaZi4MæMQI"]s5E8yTTTB/]Tp8 w\.?cw`,ٌ;v$~v=nobr fx< G3O8/sfKk_p8,ƪg}8j fn?nݺ:׻MXYYx022颰뾮FFF[Cbbb~H$[UU'æM4Emm'Jtw"y. ժU/\pbށ ۾};NsqX۷ou] Pv=eZ! B`“PjXOtԩS ϐh(P_7,:@hdwax. A?LIv̰Çvx<Cccts(^_eϘoW%[ӴKcX~}F 4EQD{{;֮]TۛrehPU>g?;4%7pw8O;Bq$IXb֮]ִNӓs!q B ,GGGSzhnnի3gl\>'OL@h4bҥIk.w"t9rdi% ####;t^! :Mv# ͸=0Doooҟd5\pxxw0>>1Z$}\(R$IϾIZ(.jD hsrME`͚5XzuFCz{{Ӿ,DPsD֍W4vÖ-[PYYKuq8`3& qG8BV+,;t% 0+@ Dz,Ϛ=򠮪*\.Ο?cǎahhrA#N.\(!h!p8prv'}f,]tEQ^w:Y,iden1L&0!"`6q\ǿB栓Ruww{7lp~X5I0 P'^2Q^^rd@e,p\qGU8`/Ʉ7f3 @lS]]&0099MMMX~:c[fk'O'!QzzBH2FqWdǎCoo/0*** o{<?~?&&&6$ 6 N3ax;baƍ9ٓ8WyyR?͆mS1 l(b>q' P#?]jG@ޱbXa^tuua``H555JͮE %7ǡV56p\Iߧ)CQ1D"AD"L9 F466"l0ęh~뭷w'IPC[l~0p8oCuB$)e|Ewށ  O|. abb"J*MG7J6.I6mڔMcyA4TTT}_P[[ ٜph4bɒ%~_[7qa۶m]]]og(Jrܹsu߯* y' @p8!O%+gAUU%OVXOm/x$I)ł%K`rrrwaU$1py۶mǓxDC6lx<[L&Sw4 N}JV{ח`ll,555iqAg hll(hhh@ Mf<EA۟9v3Jtڵk|+XHaX`6s򞙖oUTtv3L9τ*TUUr:E$7`47o~[)3 @'VU0!0z-;wFM$ΩΏn=22~ݺus򏪨W_bHVo?VMߟBІE$!̙ꈝ >[xs1 TU 57w%$!!3I[\:FȭD"~KWN3gKW?[8d!Vti quܲuV녜[㯫qX☪yޚ*k$)ڄd @s.=5UT?`# c0EpxzDh4d2%JnꪫiE zЪU~yi>u~^} ɴtvY+++s>-/TC/^L7_,CɄp8 ٜe [li (oU~igyD`) )//ԔaQQQALNNƽꯨ=<,p8 GN'FFF͛QUU~q{8ށ:JoJӴA2󡯯/S/GȼDW#AvZg=~+**敀Up===I/]k׮MJq8pw `6l07 $`01VԻe02P6Qd|XoV@u ޫoholٲ &`Z!|>_ƲuUU_1M K)\ "KU8TB\G%eIlٲyojy~wG<.:سgO`Qc2Ӊ@ =Oݳ$IieH,ĕ)10p:pxSe 4M`jhh@MMMFb!*ŵB_|ݻ`0{nbb,C$9gi&튊-tbifyyW@J1%zeQe4jժBfh8Ϫw 8Ⳁ1kvϸZ. jy7 =~wxϾfSUU ٜx’bL!JLlxO0L|֬Y0FhAx#~e&>8n|xP)IRN[ 4?ÇŋYnEQo TĊM+lBТUahh(}}=jkkI}UUU>w 4tttFt ,//xvkSDQ^z/Xpٳgqn˖-7<_85jFHH1n90o1Ngҡ˕=F#֬YHzTUA%i$ӌG0nw $IB0X|(# |_|,/~z_5\ye4M7B;$ F& (" l8O1&5Ȳ ϗ0D"M|֮]KC9kǥ%%'y,˘HY]lZ30`0zDQo۟eEp .M<7o^/,7׻JUO 4܉c'迣DzZip8iM;c3i9IGʡϩ|zo(HE=ՃTUJk(`iF[ԧ7|ʕ+6N'@ Pw|]wS#sNgr>rҥhkkC0H´bK]/v9nt aN7 CU (>Oqd4 .k^|+** ߘL920ȁ . NAuu5|>Ο y@qJy~ FQ\L$6 gdٛo9b+h4D"w|6UUݪ|)"***xz:88Τ%)| <`k)2g1UUaZi?lAf&O}ci4<NLw # ?8q\=dZdGfJ+"=ژi]f\x}S8DcAl@>яn4L?E.ZIGfpƍL{W^)q V[[/ynTVˤiEUzbD~.UUg\OMM]+Iˋ~JqG ,G  FBu3vjjGefx;%IJ;d [Uihgm}!/3NMMM-A@^ YGBМ;jl-1i;::~]s{8OP=p8g'4MC=-I9<ϣNsQ#'I4{yǞ[/BʓhD}}=^zh%)6L(///ѣ"al$_Pp w=|h4eq&i ?sρ֝ܞ={:Fg\( |>ߜ]tm6@ҝ-rk{+A@EEEZ.ಒO𖲲X}Η5Ms=w8Qtٳd2pHSSSÈD"xsQ|N ߺ:Wf (x<ӯ-+#r!3zǑ/J60'L&&#NT1U4NӴb}ïe+^ZggjEQx `# W3Ʀ IPa:["'b0k&***hi_B%-޽{%IWQ˒=%miaUUϿ ɼ$IRZ-TU;wbM70dɒW0%œюŮlH %1H7˽+s=?X,?Iu.K1C:={|]S=.qXܓ?P#@u'dY^ /,Q6`, xJя~ tU4J偏}c7q~' 80 o!?ӱSZM6mxIUe4Hݻ6jQ%} Uuk{tK/۵^sddެ!wDQK+vi0L{tb駟^م,JGGIgL&mPG ߼۷*ϧ_JQ,˵PL8N{#lOT4={P+VUh$?JyWOڢ3Ly @oM=Tf˗)L]GFFEDGH=M>n&H)qJR(NaUV土u{w;OJu{OZ=cn,tr``^}Ut Nضʕ+;z2Bs !<)0{i^49s@ p)tA- a7l2N8O>5j x-^UJŔqΓ<,^3nV9 ]u> 0ann[h+cl|RᜧcikP ] &m]ƆΜ9!JNF"pGG_?#zTMS%iK*l~o__p dYsh4zݶ)ſv޽\]=,YWJyCggD"AR8qh͒L&:::TMJ4}``f"ʕ+_?~/0[+mJ/ux`64)0kK}>3 z{{K&e.'4 [57!9teW27ǹ5.qѣ[oy} h"йs1F1✓eY7̟?VDǟv|'J)眧o`t:Jlۦa?2Chfnr'x&'Nyhph.^H>neϟLsy@IPclĿBI@fҟJ{2QB-8h=ٶM.]I;UQk::u]'0f -j?(MysDS p['6/7'AAR&mri5M0$z' (p@0D"r˲Ϗ; @<:V^zzϞ=?L]ϕDQ/ ۡ-[*/B%X2Co)_xc!,^\r=|%b,͟?u>R!'A)EgӧOS?E"J$*1Lk"K,Rw^1^|2xXlOOQ!DgyP %i[e:::F/e Kny+sο$(hK9%5mիW'=#]adOJ)R*=NOԧiCf0#")JH$20(hjsN1eYɓ'#k֬c5Msnܹidtny/DD~?r Ln"Rn+BP2(׾WxfW>|>aPKRd}˲7|3|cϞ=MTܖݬ-ΥZBBBTgn?N'j&cU7 x:t(o C,S`@ 0xwW8h@e0$(\tZy1F>﫞skn{w2 p8l !^Bбc7߬[A|2zzeY<XBd5mv?z9wv/qA~fgrC&"q۶+^{Ξ=[ ʴ|>׷|򙕌ښ6P0E-/4O'?^]]{TkE[w !jڸ̙3hdo х DcWlٲٕ2vM)? r[R㏺9.J)_H }""Zlu@40ĉ."P{iNFsO|>6<<|i]Z2P)&sg׮]{ S)}>ȁy BJYrn"oz݉aPU2ӹs8*is/v5UOM-۶qPj- +? oolSMuupp> K&tY[kmYl;*_4]P*? ZhQ? @OB {B0 #cǎY_ߠaeÇ= mAV2lۦS,S~k-[r㫖K9^lvR<W.Z5{>ǹq^9dmvCȎtaw(P@q:H$-^ܗZj@;G<y-X"oa|]IP7K.ˏpxΝIg?K.;ȑK)%]rS 4mGϟA1VCS%՜7xI)]m[efXD̞* x&OmlRJ:y$5RU |>c>*imHaYAU[IPWR7y=)H 9s^{54 j 7xC׃#)ޅ ~\5jh஻z*RpR29n/^1iڤjS#'DDo&?~S<haiDDoJOI*k&@;!)4)0=Pzgƛpx{)eGGp*@xPiaOz APЊ+kjỂ;ҋ5)%ӫJ .`@̘1B{{cg4 \h֬Y57|Gi1Md*N4S{N9(H$C7yޱcG 6X,FNݻwɓ'PСCie5i4 wMJR9n:;;1FBoʳOa̺ t:<_ԍax;jf`6]tO^48pߦOr!p !HJId~?tMt 6y#NTRJs /;M\, qxwO]&344DǏ={xZEw}]DD~{{rJFFFŋJo۩=qBX4tROJcsUsa#G(/^|lVgɶuŎqoJ&tڽ{7?~Ҕ |o|}%\YIuttPkR$Z swnRJ eY<u#c=|~BE]O CAt!:p]tIL/8tGGǝ{?u֬Y KV5Ѱ??R R- xS`P7tЖźJLӤ9sLxXB&N&Ts۶m'& LJnUcΝ;;qISG=?>#k׮{m<| (:a1FsΥ$ʧI7dZ0~A9((hK-rUI$^f{S`POnGc,ҩSh׮]tI !Dرc+LiӦaΝdYPHv-SC&*1mk^cTt]w:wuS NիWixx8Ӵ'lٲUkiiiWtO7xc*4 {92 jr4PJ߶m/5ƴaqiӦQWWM>=o3J)Is_usbh!ӧi>q (ctzCNL6ϗܹsK/4 xÏPum#/LqdR#9xÞB\$w͙3R]z2㤔%O0\BW\Vٳg5)%qD"I$w[o}i M7i yڶ}#Mf !f7d9OK)Rtm~_y5Ź.rt-0M:;;ɶmW1&nG+-F_N>M4w\|lTtt={SK,]"zn)u|"х-[D"x|6mB9!D !fQGD~)eEJR18!Uu29sfDJ9cOg+Qi#TN xVTz IDATS`('}Cu={6qd7-pn46lp5a@Z$;wΟ?Of͢9sPGGXD\oBSG.Z6]׷ئB%*X|{5-]8?sJ2B>̙CX&$RKcdRbJ]EKKzOACR~tl&N>t$q/޻[Pz{{Z[[iΜ9I\bFU{ (O$ݺuk]wmo2>$% 㧈(HDd^5$@T@ @sΥ`08\0۷oTH{՜"|l'xuH:tM4ͳ~pxR=eZ޽N:հ-SWqm{0SŎD"[o`*|4M/o!)kk+͝;~?9{>JD' 8N {P־̙C~Z[[,"4IcA_gTdRi7\by 8y1ɤ;eYn޼Yrk׮wv͞={aH,޽{S 3רX>iey˶DtZa=#9AeQ `0HmmmN9 {Iujm?GJIBr'3^z*^V=zT=4$)D(oFCaŋgG"KtCL%ΝٳgV~lkkWʍi֭㎏ !v;Tk$1F_5M#Dz.!)eѓ**|(眤⋟x1D) qDُ !J =F-[\!-Z6::WޛH$J68]t.]DEfͪxOL3a\Ѵi,˺{?PxiӦ Yfi؍jf {(@A1r i6~aLh֑ϗdS);T㜋7ޡ pη>YUWC_V#[Fs_```M<`unhhUFGGcYѽׯDP(=?cg] FuԆ,(t;aYr$<؜GQpZu*7bP㏏ڶ=Y wRP(۹sfΜO---F2=CǏ78sE_w )"Zy2k7KD?o޼;00g=lۦH$Rvhlm/u],]XvMDk֬ߚ1 5M'%C r ID!<^y߿8^ZjFyqnٲhu }>_QJINrs.g̘Ѳo>wa,_|eYgYeUr"d(z.A>*TI]]]VOD `0wsڵkn;l9_L&-jYmBs(MD6%5MRF(iڠ"cL(Cc@DyK&[u͚53- bJ~p:LoFsNTt]ϝrB#u q;v<@D̛7Z$)pE>}:uuu̙3ɶmP::s|g"6n8DDqi9y;&I)S`|xvgg7nxLJIt1ڿ? WeGG7GFFn~衇_O Y`җ͛7eImoX,F}}}dƄ**sʇ bŊee~Hni sݻ&͛w[4oV hY94mڴ+aC=5b6[U0n/"M7DsTpXd g͚5oO1Kޥl̹H>|s3gs *˲,9s3---dZ,CdӡPRhZ[[1k A#Ikzw4Cw͛ws"xp``C(OCa^EZZZYn)!"4Pcl /0Z K[3ga1{3e-H !Q:V^ig~υd/jp}ĺڶt?G3PQkkk0֭\LD" i6mzvQ> Dc{ff%sQح===VXeݖ{.&6?/h… ; .m`0~z'rn mݶpڴi_1 8cgmb# $i'7mtvC @Vl"TOOf6@ B3K%P߶me"}7~``T*UVn70a֭[u @wwDŽE@`q2|>_7mR\Ecݫ1=== YlvhbWj.zz !W,`QKK_ZaLPPom[mR񝍈hR ؄d'?nP8+.vzU.*߿0BwoTJ2h\ww344*uP9i~b LT%Xt釅[ӄyǀ^JImS2d2===W=M t3~r-M(h\P[VZkYև&BL # ň菮G .p&q&Li'6X]*hqo۶q#Nkc|?臨Gׯ_IU,ȾQɍ .9t۶}k !(xVwq?(L#V\!W4O&:qΧlk۶mD4@Dzy~Vqf1:783KQ Ѕ'(fhFmmm~럯cXSBY >y!" gBR)JR& ­[-wFFfMDDgu]_sa&r64_*uvm³|U| ҔRBdH˲-dYֽw4)J9ȾO$!(S<t:M17? O?F(4P?[sV'*]R쥠  V!&$Bv";Sa !M6-g֭[%f[+LR,D"1N?PGGC1!͛7қA-LJJDU n|-uRЦlt=+7'>9::eٶ)Q1KJ9:mBi!i}P 'ݿ8΋^/tzn8`"d2I>ɱ~"ZyfO`P]Z5DP(AUXbeY_ps?*כg}2)uۻwoMJ뮛q)EqŊF'$R'C=DIb JJ9)(r_ 9WJ0ƚnbT0!X|d2ʸ~ܮ JQ<'?j'n݊0 q n ?ڼy?,0Y16m<.w~ժUO[i0hpp4b1be} Ðtm۷a P7 @<7q5eS FFFhxxڨm1n&I))Je&z5bt"իW Iټs1ƈs. øg< cl$<'B;ls(E"4c "JM,ҶmJ~mODD3I|^Ռ1rg|?kt ͎;9O6 ?~ !8{gu?Hl&Us8J… lg.%E.@l)E1v"Zar^)ekkk+1#!$"Gai54::Jmlj: POS*H$Bj}jcH`lt:MDlۦW5ZuL%"QB-]+|16q 2][k6ih*m6 ]Pc%+nyug'˦iinJwc/2v?9ڎ;C,]_MH:dP(tu"Q f^gQ{{;,kFaDt]Νk'TH ͽLlؔ1MSt1 N---42_9dFs޳sO$B([wwmfU9H$/V~! / )1` ѥBD7LXK)[c-`ПL&[8Q;wRrU B [q!Nc$cΉ9uc#rݐg\XiÌ/}`` ø۶q߾}u` BPHƍOiϛ7/;}W+DT`> p j1*\RV%] 媷 CuCu9T>xC@y  "t>C<p*C @] WVcJ+H zJ/+B<1|J-@J @ \'vC0\o:̛7;p16p[O W8QǪ&T* bv4p0 2D"Wʂ\m;X aHj 9XpeUVpKJ)]0C!Ƈ*C TƇ*NucWP$Z/ @!SH)Q1$Pq"1$P*Q=$F@Dj &P1RR&PpsU#Gmmm4s]"k:rT*HRd6 !HӴI>JD4 IieY9"^(|m 4MӞ>}mۦX,FDlۦ@ @DDiHlܺuk_Uאg?@2<Lh H,m# go喗SO=8Τ|%RG֮]+jʖN_rq9AՃW@ْ_lٴiӅE!=3'l~+<{<\$Pts*W:;;4 ( T?H)EŸ<~@m!BT* " ?R ԰r?|"rg1vRJyjƍL4i+V^4Mwz{{3 B!(a;5MӉD7 eժU|eIe˖>*( xzYJ&L&jPhm۶]ITP$P+"I 4MgH@ӴV= $PТE>iO:20$@A`b:r;w|q@@A6lMK)&QIc{lj͘1mƌFihh Ø0 h"H() ] 4M#]x`mR=o0E !&3϶5D4ychXH c9 $?ܲe˕FeCEI)w*UBk Nw˹?aY_>"$P3N&&4M[aÆx}r!x ˛6m:^ Jr'm۶y-7o^W׀l@IdA"=%׏@ce)OIDAT?wf&,IENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7496853 formiko-2.0.0/icons/64x64/0000775000175000017500000000000015236556646014075 5ustar00mcbigmcbig././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/64x64/formiko.png0000664000175000017500000000627115152030611016227 0ustar00mcbigmcbigPNG  IHDR@@iqsBIT|d pIDATx}Lg~ǿ>싽/`^cFF M `ܔQȭi#'鋚JmiE骶݋E(iχᆠwIʉcB`Ysޞ<lll Eeuabe|*F4MSݢzo98y 41 㧔(Xlq ^5MӴL&sQo>`0m۶A (++^Sa`ddDb8ѣ.T*VU>l2 S~q%%=`ݣT! PJ133Q׋'ICU|TVV{zz/4nYYh?U%(Jn|Ν{^_oO+R:˗*|>_A?`ddidvoee{zz"رcx<.sul<Wt_r|~ˎh2 TTTbY1t]ǧ~T*e=%I|饗V>LQZZj@UUWv{KII ̙mX!&[YY)R={, H`ddaXmˍ,ۺe˖o,QJKs\og[WW2<555/z|+񵵵SSSײ٬PH6 wFIIj=/EEE(++gfff0444b(hvBrȩxZX'x^x<xK{;H 7vts\(// 0*fCCCTv80 æR)Hd[ɪb؝_ Y q=Gn< D"Pt9099D"X,FH$C噷zd\8U0  ڵk\6,C,[bKA2(GEEct:QTTH$4ƾo[;wdҝɬI122>va>뜺#N#H  ߷y(z:;j%?kŨ)JiDm[lYq + %```.]iί ÀafصkW8>eY6O.5ʃ%$Iz: JAUU8lڴ 'iӦQ6E,C,Ç~iv, EQ011}}NeQJܼi5Us=LJ]D,#JQQ?8OSSSӨl6[J6SJSw:p8`_Wuha*fff ـE_Hrܔy_|͙Ef5@}}}10HaF7R Br(Boc jZhll^TT?rEQ)[VXߟyAQv,S=whzz{$A47Vk}RJfv[6G=.N) Yw,Nq7I)8n4kP}D=>;;;*TUleJ|j @ٮi$#GZTҶP(hůO=$qa]I8fq]wa i?_; ;v L6q;r<Dbœttt\uݗŠiڨiiλ8a"qY__| `w !u(Jfy[2D:iUhi>gr!}sit&&& d2Y<#Gl `f&DoYI4662qHRe9|ż΍,˾\{ؼyD033_"oa1LiBe8F4p п؆Rׯ@/Ƣ( q H{J)4M,VonnѨ-eYH $Bj! #,3 ܳgϞ@4=/IRM&ajjjzϳDk}n;C)+ŏ$IP6m|-ٳڪPnmml6{:Ha]EիWquݻt:_:sih|\.w.okk?ͲapiRBȷCϬ/IRCBdYd2RJ?w9 8!di).B4"4eBd`Ou:+\Z7]?z(IRɲ3mmmߴZ|CCC#tO.( N*z-Nئ}cO>rn40,?z…kV;V@:fEQdYB 2 $vܹ߯s}FSSӡ\.>==]`ad2/.\XRt PюꝚrh[[ԭ(B^` ҒP4 % n ;!yHqn7dY~v^p,**UGGǻ35iB|R+[ zK<qmu"P.3nwu:X ׅ h ˮKG{flT( ?IENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/formiko.svg0000664000175000017500000007476115152030611015460 0ustar00mcbigmcbig image/svg+xml ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/icons/svg2png.py0000664000175000017500000000066415152030611015220 0ustar00mcbigmcbig"""Support PNG generator from SVG.""" from os import makedirs from gi.repository.GdkPixbuf import Pixbuf def main(): """Create the png files from svg.""" for size in (16, 22, 24, 32, 48, 64, 128, 256, 512): icon = Pixbuf.new_from_file_at_scale("formiko.svg", size, size, True) makedirs(f"{size}x{size}") icon.savev(f"{size}x{size}/formiko.png", "png", [], []) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772965959.9181523 formiko-2.0.0/pyproject.toml0000664000175000017500000000326715153250110015062 0ustar00mcbigmcbig[build-system] requires = ["setuptools>=64", "docutils>=0.12"] build-backend = "setuptools.build_meta" [project] name = "formiko" dynamic = ["version", "readme"] description = "Formiko is reStructuredText and MarkDown editor and live previewer." authors = [{name = "Ondrej Tuma", email = "mcbig@zeropage.cz"}] license = "BSD-3-Clause" keywords = ["doc", "html", "rst", "docutils", "md", "markdown", "editor"] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: X11 Applications :: GTK", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Topic :: Documentation", "Topic :: Software Development :: Documentation", "Topic :: Text Editors :: Documentation", "Topic :: Text Editors :: Text Processing", "Topic :: Text Processing", "Topic :: Text Processing :: Markup", "Topic :: Text Processing :: Markup :: HTML", "Topic :: Utilities", ] dependencies = ["docutils>=0.12", "jsonpath-ng"] [project.optional-dependencies] extra = [ "pynvim", "m2r2", "Pygments", "docutils-tinyhtmlwriter", "docutils-htmlwriter", "docutils-html5-writer", ] [project.urls] Homepage = "https://formiko.zeropage.cz" [project.gui-scripts] formiko = "formiko.__main__:main" formiko-vim = "formiko.__main__:main_vim" [tool.setuptools.dynamic] version = {attr = "formiko.__version__"} readme = {file = "README.rst"} [tool.setuptools.packages.find] include = ["formiko*"] [tool.setuptools.package-data] "formiko.data" = ["*.js", "*.css", "AUTHORS"] [tool.pytest.ini_options] testpaths = ["tests"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1786437029.7509158 formiko-2.0.0/setup.cfg0000664000175000017500000000004615236556646014010 0ustar00mcbigmcbig[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/setup.py0000664000175000017500000001707615152030611013664 0ustar00mcbigmcbig"""Project setup.py - custom build commands and data_files.""" import logging import re from gzip import open as zopen from os import listdir, makedirs, path from shutil import rmtree from typing import ClassVar from docutils.core import publish_string from docutils.writers.manpage import Writer from setuptools import Command, setup from setuptools.command.build import build from setuptools.command.install import install def _read_version(): """Read project version from formiko/__init__.py without importing it.""" init = path.join( path.dirname(path.abspath(__file__)), "formiko", "__init__.py", ) with open(init, encoding="utf-8") as f: match = re.search( r'^__version__\s*=\s*["\']([^"\']+)["\']', f.read(), re.M, ) if match: return match.group(1) error = "Cannot find __version__ in formiko/__init__.py" raise RuntimeError(error) __version__ = _read_version() def icons_data(): """Return list of icons for setup.py.""" _path = "share/icons/hicolor" icons = [(f"{_path}/scalable/apps", ["icons/formiko.svg"])] for size in (16, 22, 24, 32, 48, 64, 128, 256, 512): icons.append( ( f"{_path}/{size}x{size}/apps", [f"icons/{size}x{size}/formiko.png"], ), ) return icons def man_page(writer, src, dst): """Generate man page from rst source.""" with open(src, encoding="utf-8") as source: rst = source.read().format(version=__version__) with zopen(dst, "wb") as destination: destination.write(publish_string(source=rst, writer=writer)) class Build(build): """Build command class for generating man pages.""" man_base: str | None def initialize_options(self): """Initialize default option values.""" super().initialize_options() self.man_base = None def finalize_options(self): """Finalize and validate option values.""" super().finalize_options() if self.man_base is None: self.man_base = path.join(self.build_base, "man") def run(self): """Build man pages in addition to standard build.""" super().run() logging.info("building man pages") if self.dry_run: return writer = Writer() if not path.exists(self.man_base): makedirs(self.man_base) for page in ("formiko", "formiko-vim"): dst = f"{self.man_base}/{page}.1.gz" logging.info("manpage %s.rst -> %s", page, dst) man_page(writer, page + ".rst", dst) class CleanMan(Command): """Clean build man files.""" description = "clean up man files from 'build' command" user_options: ClassVar[list[tuple]] = [ ( "build-base=", "b", "base build directory (default: 'build.build-base')", ), ] man_base: str | None build_base: str | None def initialize_options(self): """Initialize default option values.""" self.man_base = None self.build_base = None def finalize_options(self): """Finalize and validate option values.""" self.set_undefined_options("build", ("build_base", "build_base")) if self.man_base is None: self.man_base = path.join(self.build_base, "man") def run(self): """Remove generated man page files.""" logging.info("clean man pages") if self.dry_run: return if path.exists(self.man_base): rmtree(self.man_base) # ruff: noqa: ARG005 install.sub_commands.append(("install_man", lambda self: True)) class InstallMan(Command): """Install man files from build command.""" description = "install data files" user_options: ClassVar[list[tuple]] = [ ( "man-dir=", "m", "destination directory for installing man files" "(default: installation base dir/man)", ), ] boolean_options: ClassVar[list[str]] = ["force"] man_base: str | None build_base: str | None install_data: str | None man_dir: str | None outfiles: list[str] def initialize_options(self): """Initialize default option values.""" self.man_base = None self.build_base = None self.install_data = None self.man_dir = None self.outfiles = [] def finalize_options(self): """Finalize and validate option values.""" self.set_undefined_options("build", ("build_base", "build_base")) self.set_undefined_options("install", ("install_data", "install_data")) if self.man_base is None: self.man_base = path.join(self.build_base, "man") if self.man_dir is None: self.man_dir = path.join(self.install_data, "share", "man", "man1") def run(self): """Install built man pages to the target directory.""" self.mkpath(self.man_dir) for page in listdir(self.man_base): src = f"{self.man_base}/{page}" (out, _) = self.copy_file(src, self.man_dir) self.outfiles.append(out) def get_inputs(self): """Return list of input files (none for man pages).""" return [] def get_outputs(self): """Return list of installed man page files.""" return self.outfiles class CheckVersion(Command): """Check versions validation.""" description = "check if all all versions in all files are same" user_options: ClassVar[list[tuple]] = [] def initialize_options(self): """Initialize default option values (none needed).""" def finalize_options(self): """Finalize and validate option values (none needed).""" def run(self): """Check that versions in all project files match.""" # pylint: disable=import-outside-toplevel from packaging.version import Version pkg_version = Version(__version__) logging.info("package version is %s", pkg_version) ch_version = Version(self.read_changelog()) logging.info("ChangeLog version is %s", ch_version) meta_version = Version(self.read_metainfo()) logging.info("metainfo version is %s", meta_version) if not pkg_version == ch_version == meta_version: msg = "Versions are not same!" raise RuntimeError(msg) def read_changelog(self): """Read last version From ChangeLog.""" with open("ChangeLog", encoding="utf-8") as chl: for line in chl: if line.startswith("Version"): return line[8:].strip() return None def read_metainfo(self): """Read last version from formiko.metainfo.xml.""" with open("formiko.metainfo.xml", encoding="utf-8") as meta: for line in meta: if " str: """Render *rst* with docutils and return the string output. ``halt_level=5`` prevents SEVERE errors from raising exceptions so that error cases can be asserted on the returned document tree. """ return publish_string( source=rst, source_path=source_path, writer=writer, settings_overrides={ "warning_stream": StringIO(), "halt_level": 5, }, ).decode() # --------------------------------------------------------------------------- # file-tree directive # --------------------------------------------------------------------------- @pytest.fixture def filetree(tmp_path): """Create a sample directory tree used by file-tree tests. Structure:: tmp_path/ docs/ guide.md readme.md images/ logo.png notes.txt .hidden """ docs = tmp_path / "docs" docs.mkdir() (docs / "guide.md").write_text("# Guide") (docs / "readme.md").write_text("# Readme") images = docs / "images" images.mkdir() (images / "logo.png").write_bytes(b"") (tmp_path / "notes.txt").write_text("notes") (tmp_path / ".hidden").write_text("hidden") return tmp_path def source(tmp_path): """Return a fake RST source path located inside *tmp_path*.""" return str(tmp_path / "source.rst") class TestFileTreeBasic: def test_lists_files_and_dirs(self, filetree): out = render(".. file-tree:: .", source(filetree)) assert "docs" in out assert "notes.txt" in out def test_hidden_entries_skipped(self, filetree): out = render(".. file-tree:: .", source(filetree)) assert ".hidden" not in out def test_dirs_sorted_before_files(self, filetree): out = render(".. file-tree:: .", source(filetree)) assert out.index("docs") < out.index("notes.txt") def test_files_sorted_alphabetically(self, filetree): out = render(".. file-tree:: docs", source(filetree)) assert out.index("guide.md") < out.index("readme.md") def test_nested_subdir_appears(self, filetree): out = render(".. file-tree:: .", source(filetree)) assert "images" in out # logo.png is at depth 3 — hidden with default depth=2 assert "logo.png" not in out def test_empty_dir_yields_no_output(self, tmp_path): (tmp_path / "empty").mkdir() out = render(".. file-tree:: empty", source(tmp_path)) assert "bullet_list" not in out def test_nonexistent_dir_produces_error(self, filetree): out = render(".. file-tree:: no_such_dir", source(filetree)) assert "system_message" in out assert "ERROR" in out class TestFileTreeDepth: def test_depth_zero_returns_empty(self, filetree): out = render(".. file-tree:: .\n :depth: 0", source(filetree)) assert "bullet_list" not in out def test_depth_one_lists_top_level_only(self, filetree): out = render(".. file-tree:: .\n :depth: 1", source(filetree)) assert "docs" in out assert "notes.txt" in out # files inside docs/ must not appear assert "readme.md" not in out assert "guide.md" not in out def test_default_depth_shows_direct_children(self, filetree): out = render(".. file-tree:: .", source(filetree)) assert "readme.md" in out assert "guide.md" in out # images/ contents are at depth 3 — must not appear with depth=2 assert "logo.png" not in out def test_depth_two_explicit_matches_default(self, filetree): default = render(".. file-tree:: .", source(filetree)) explicit = render(".. file-tree:: .\n :depth: 2", source(filetree)) assert default == explicit def test_depth_three_shows_all(self, filetree): out = render(".. file-tree:: .\n :depth: 3", source(filetree)) assert "logo.png" in out class TestFileTreeIncludeFilter: def test_matching_files_shown(self, filetree): out = render(".. file-tree:: .\n :include: *.md", source(filetree)) assert "readme.md" in out assert "guide.md" in out def test_non_matching_files_hidden(self, filetree): out = render(".. file-tree:: .\n :include: *.md", source(filetree)) assert "notes.txt" not in out def test_dir_with_no_matching_files_hidden(self, filetree): # images/ contains only .png — must be hidden when filtering for *.md out = render(".. file-tree:: .\n :include: *.md", source(filetree)) assert "images" not in out assert "logo.png" not in out def test_dir_with_matching_files_shown(self, filetree): out = render(".. file-tree:: .\n :include: *.md", source(filetree)) assert "docs" in out def test_include_combined_with_max_depth(self, filetree): out = render( ".. file-tree:: .\n :include: *.md\n :depth: 1", source(filetree), ) # docs/ would have matches at depth 2, but max-depth cuts the scan assert "readme.md" not in out class TestFileTreeLinks: def test_links_off_by_default(self, filetree): out = render(".. file-tree:: .", source(filetree)) assert "refuri" not in out def test_links_adds_refuri_to_files(self, filetree): out = render(".. file-tree:: .\n :links:", source(filetree)) assert 'refuri="notes.txt"' in out def test_links_path_includes_subdirectory(self, filetree): out = render(".. file-tree:: .\n :links:", source(filetree)) assert 'refuri="docs/readme.md"' in out def test_links_dirs_have_no_refuri(self, filetree): out = render(".. file-tree:: .\n :links:", source(filetree)) # directory names appear as plain text, not as references lines_with_docs = [ ln for ln in out.splitlines() if "docs" in ln ] assert any("refuri" not in ln for ln in lines_with_docs) def test_links_text_is_filename_only(self, filetree): out = render(".. file-tree:: .\n :links:", source(filetree)) assert ">notes.txt<" in out or "notes.txt" in out # full path must not appear as link text assert ">docs/readme.md<" not in out def test_links_combined_with_subdir_argument(self, filetree): # when scanning docs/ directly, paths are relative to source dir out = render(".. file-tree:: docs\n :links:", source(filetree)) assert 'refuri="docs/guide.md"' in out class TestFileTreeExclude: def test_exclude_hides_matching_files(self, filetree): out = render(".. file-tree:: .\n :exclude: *.txt", source(filetree)) assert "notes.txt" not in out def test_exclude_keeps_non_matching_files(self, filetree): out = render(".. file-tree:: .\n :exclude: *.txt", source(filetree)) assert "readme.md" in out def test_exclude_hides_dir_when_all_files_excluded(self, filetree): # images/ contains only .png — hidden when all its files are excluded out = render( ".. file-tree:: .\n :include: *.md\n :exclude: *.png", source(filetree), ) assert "images" not in out def test_include_and_exclude_combined(self, filetree): # include all .md, then exclude readme.md specifically out = render( ".. file-tree:: .\n :include: *.md\n :exclude: readme.md", source(filetree), ) assert "guide.md" in out assert "readme.md" not in out assert "notes.txt" not in out def test_exclude_alone_keeps_dirs_with_remaining_files(self, filetree): # docs/ has .md files; excluding .txt keeps docs/ visible out = render(".. file-tree:: .\n :exclude: *.txt", source(filetree)) assert "docs" in out def test_exclude_hides_dir_when_all_children_excluded(self, filetree): # exclude both .md and .png → docs/ and images/ should disappear out = render( ".. file-tree:: .\n :exclude: *.md\n :depth: 1", source(filetree), ) # notes.txt should still be present assert "notes.txt" in out # --------------------------------------------------------------------------- # mdinclude directive # --------------------------------------------------------------------------- pytest.importorskip("m2r2", reason="m2r2 not installed") class TestStandaloneMdInclude: def test_basic_md_content_included(self, tmp_path): (tmp_path / "content.md").write_text("Hello **world**") out = render( ".. mdinclude:: content.md", source(tmp_path), writer="html5", ) assert "Hello" in out assert "world" in out def test_heading_included(self, tmp_path): (tmp_path / "doc.md").write_text("# My Title\n\nSome text.") out = render(".. mdinclude:: doc.md", source(tmp_path)) assert "My Title" in out assert "Some text." in out def test_relative_path_resolved_from_source_dir(self, tmp_path): sub = tmp_path / "sub" sub.mkdir() (sub / "nested.md").write_text("Nested content") out = render(".. mdinclude:: sub/nested.md", source(tmp_path)) assert "Nested content" in out def test_nonexistent_file_produces_error(self, tmp_path): out = render(".. mdinclude:: missing.md", source(tmp_path)) assert "system_message" in out def test_no_sphinx_env_attribute_error_not_raised(self, tmp_path): """Directive must work without Sphinx env on document settings.""" (tmp_path / "simple.md").write_text("Plain text") # publish_string does not set settings.env — must not raise out = render(".. mdinclude:: simple.md", source(tmp_path)) assert "Plain text" in out def test_start_line_option(self, tmp_path): content = "\n".join(f"Line {i}" for i in range(5)) (tmp_path / "lines.md").write_text(content) rst = ".. mdinclude:: lines.md\n :start-line: 3" out = render(rst, source(tmp_path)) assert "Line 3" in out assert "Line 0" not in out def test_end_line_option(self, tmp_path): content = "\n".join(f"Line {i}" for i in range(5)) (tmp_path / "lines.md").write_text(content) rst = ".. mdinclude:: lines.md\n :end-line: 2" out = render(rst, source(tmp_path)) assert "Line 0" in out assert "Line 4" not in out ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/tests/test_format_utils.py0000664000175000017500000014716315152030611017436 0ustar00mcbigmcbig"""Tests for formiko.format_utils.compute_toggle_format.""" from typing import ClassVar import pytest from formiko.format_utils import ( RST_HEADER_CHARS, compute_toggle_format, compute_toggle_line_exclusive, compute_toggle_line_format, compute_toggle_ordered, compute_toggle_rst_header, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- # Per-parser known-formats lists (mirrors window.py _KNOWN_FORMATS) RST_FORMATS = [ (":del:`", "`"), ("**", "**"), ("``", "``"), ("*", "*"), ] MD_FORMATS = [ ("**", "**"), ("~~", "~~"), ("*", "*"), ("`", "`"), ] HTML_FORMATS = [ ("", ""), ("", ""), ("", ""), ("", ""), ] BOLD_MD = ("**", "**") ITALIC_MD = ("*", "*") STRIKE_MD = ("~~", "~~") CODE_MD = ("`", "`") BOLD_RST = ("**", "**") ITALIC_RST = ("*", "*") STRIKE_RST = (":del:`", "`") CODE_RST = ("``", "``") BOLD_HTML = ("", "") ITALIC_HTML = ("", "") STRIKE_HTML = ("", "") CODE_HTML = ("", "") def toggle(buf, sel_start, sel_end, before, after, known): """Shortcut: return (result_buf, new_sel_start, new_sel_end).""" eff_start, eff_end, result, inner_start, inner_end = compute_toggle_format( buf, sel_start, sel_end, before, after, known, ) new_buf = buf[:eff_start] + result + buf[eff_end:] new_sel_start = eff_start + inner_start new_sel_end = eff_start + inner_end return new_buf, new_sel_start, new_sel_end # --------------------------------------------------------------------------- # Markdown / RST — markers INSIDE the selection # --------------------------------------------------------------------------- class TestMarkdownInsideSelection: """Selection includes the formatting markers.""" def test_no_format_apply_bold(self): buf = "hello text world" # ^---^ sel: "text" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "hello **text** world" assert new_buf[ss:se] == "text" def test_no_format_apply_italic(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *ITALIC_MD, MD_FORMATS) assert new_buf == "hello *text* world" assert new_buf[ss:se] == "text" def test_bold_inside_sel_toggle_off(self): buf = "hello **text** world" # ^-------^ sel: "**text**" start, end = 6, 14 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_bold_inside_sel_switch_to_italic(self): buf = "hello **text** world" start, end = 6, 14 new_buf, ss, se = toggle(buf, start, end, *ITALIC_MD, MD_FORMATS) assert new_buf == "hello *text* world" assert new_buf[ss:se] == "text" def test_italic_inside_sel_toggle_off(self): buf = "hello *text* world" # ^------^ sel: "*text*" start, end = 6, 12 new_buf, ss, se = toggle(buf, start, end, *ITALIC_MD, MD_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_italic_inside_sel_switch_to_bold(self): buf = "hello *text* world" start, end = 6, 12 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "hello **text** world" assert new_buf[ss:se] == "text" # --------------------------------------------------------------------------- # Markdown / RST — markers OUTSIDE the selection # --------------------------------------------------------------------------- class TestMarkdownOutsideSelection: """User selected only the inner text; markers sit just outside.""" def test_bold_outside_sel_toggle_off(self): buf = "hello **text** world" # ^--^ sel: "text" (offsets 8..12) start, end = 8, 12 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_bold_outside_sel_switch_to_italic(self): buf = "hello **text** world" start, end = 8, 12 new_buf, ss, se = toggle(buf, start, end, *ITALIC_MD, MD_FORMATS) assert new_buf == "hello *text* world" assert new_buf[ss:se] == "text" def test_italic_outside_sel_toggle_off(self): buf = "hello *text* world" # ^--^ sel: "text" (offsets 7..11) start, end = 7, 11 new_buf, ss, se = toggle(buf, start, end, *ITALIC_MD, MD_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_italic_outside_sel_switch_to_bold(self): buf = "hello *text* world" start, end = 7, 11 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "hello **text** world" assert new_buf[ss:se] == "text" # --------------------------------------------------------------------------- # HTML — markers INSIDE the selection # --------------------------------------------------------------------------- class TestHtmlInsideSelection: def test_no_format_apply_bold(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *BOLD_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_no_format_apply_italic(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *ITALIC_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_bold_inside_sel_toggle_off(self): buf = "hello text world" # ^----------^ sel: "text" (6..17) start, end = 6, 17 new_buf, ss, se = toggle(buf, start, end, *BOLD_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_bold_inside_sel_switch_to_italic(self): buf = "hello text world" start, end = 6, 17 new_buf, ss, se = toggle(buf, start, end, *ITALIC_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_italic_inside_sel_toggle_off(self): buf = "hello text world" # ^----------^ sel: "text" (6..17) start, end = 6, 17 new_buf, ss, se = toggle(buf, start, end, *ITALIC_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_italic_inside_sel_switch_to_bold(self): buf = "hello text world" start, end = 6, 17 new_buf, ss, se = toggle(buf, start, end, *BOLD_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" # --------------------------------------------------------------------------- # HTML — markers OUTSIDE the selection # --------------------------------------------------------------------------- class TestHtmlOutsideSelection: def test_bold_outside_sel_toggle_off(self): buf = "hello text world" # ^--^ sel: "text" (9..13) start, end = 9, 13 new_buf, ss, se = toggle(buf, start, end, *BOLD_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_bold_outside_sel_switch_to_italic(self): buf = "hello text world" start, end = 9, 13 new_buf, ss, se = toggle(buf, start, end, *ITALIC_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_italic_outside_sel_toggle_off(self): buf = "hello text world" # ^--^ sel: "text" (9..13) start, end = 9, 13 new_buf, ss, se = toggle(buf, start, end, *ITALIC_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_italic_outside_sel_switch_to_bold(self): buf = "hello text world" start, end = 9, 13 new_buf, ss, se = toggle(buf, start, end, *BOLD_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" # --------------------------------------------------------------------------- # Selection preservation — inner_start / inner_end offsets # --------------------------------------------------------------------------- class TestSelectionPreservation: """After the operation the selection must cover only the inner text.""" @pytest.mark.parametrize( ("apply_bold", "apply_italic"), [ (True, False), (False, True), ], ) def test_apply_format_sel_covers_inner(self, apply_bold, apply_italic): buf = "hello text world" start, end = 6, 10 # "text" fmt = BOLD_MD if apply_bold else ITALIC_MD new_buf, ss, se = toggle(buf, start, end, *fmt, MD_FORMATS) assert new_buf[ss:se] == "text" def test_toggle_off_sel_covers_inner(self): buf = "**text**" start, end = 0, 8 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "text" assert new_buf[ss:se] == "text" def test_switch_sel_covers_inner(self): buf = "**text**" start, end = 0, 8 new_buf, ss, se = toggle(buf, start, end, *ITALIC_MD, MD_FORMATS) assert new_buf == "*text*" assert new_buf[ss:se] == "text" def test_outer_markers_sel_covers_inner(self): buf = "**text**" # Select only "text" (inside the markers) start, end = 2, 6 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "text" assert new_buf[ss:se] == "text" # --------------------------------------------------------------------------- # Edge cases # --------------------------------------------------------------------------- class TestEdgeCases: def test_no_adjacent_markers_no_change_on_toggle_off(self): """Plain text with no markers — toggle off is impossible, applies.""" buf = "text" start, end = 0, 4 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "**text**" assert new_buf[ss:se] == "text" def test_at_start_of_buffer(self): buf = "text rest" start, end = 0, 4 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "**text** rest" assert new_buf[ss:se] == "text" def test_at_end_of_buffer(self): buf = "hello text" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "hello **text**" assert new_buf[ss:se] == "text" def test_whole_buffer_bold(self): buf = "text" start, end = 0, 4 new_buf, _, _ = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "**text**" def test_empty_inner_text(self): """Toggling off bold on '****' yields empty string.""" buf = "****" start, end = 0, 4 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "" assert ss == se # empty selection def test_markers_not_adjacent_no_expand(self): """Markers elsewhere in buffer must not affect selection.""" buf = "**other** text **more**" # ^---^ "text" at 10..14 start, end = 10, 14 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "**other** **text** **more**" assert new_buf[ss:se] == "text" def test_multiword_selection(self): buf = "hello world foo" start, end = 0, 11 # "hello world" new_buf, ss, se = toggle(buf, start, end, *ITALIC_MD, MD_FORMATS) assert new_buf == "*hello world* foo" assert new_buf[ss:se] == "hello world" # --------------------------------------------------------------------------- # Markdown — strikethrough and inline code # --------------------------------------------------------------------------- class TestMarkdownStrikethrough: """Strikethrough (~~) for markdown.""" def test_no_format_apply_strikethrough(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *STRIKE_MD, MD_FORMATS) assert new_buf == "hello ~~text~~ world" assert new_buf[ss:se] == "text" def test_strikethrough_inside_sel_toggle_off(self): buf = "hello ~~text~~ world" start, end = 6, 14 new_buf, ss, se = toggle(buf, start, end, *STRIKE_MD, MD_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_strikethrough_inside_sel_switch_to_bold(self): buf = "hello ~~text~~ world" start, end = 6, 14 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "hello **text** world" assert new_buf[ss:se] == "text" def test_strikethrough_outside_sel_toggle_off(self): buf = "hello ~~text~~ world" start, end = 8, 12 # "text" inside ~~ new_buf, ss, se = toggle(buf, start, end, *STRIKE_MD, MD_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_strikethrough_outside_sel_switch_to_italic(self): buf = "hello ~~text~~ world" start, end = 8, 12 new_buf, ss, se = toggle(buf, start, end, *ITALIC_MD, MD_FORMATS) assert new_buf == "hello *text* world" assert new_buf[ss:se] == "text" def test_bold_switch_to_strikethrough(self): buf = "hello **text** world" start, end = 6, 14 new_buf, ss, se = toggle(buf, start, end, *STRIKE_MD, MD_FORMATS) assert new_buf == "hello ~~text~~ world" assert new_buf[ss:se] == "text" class TestMarkdownCode: """Inline code (`) for markdown.""" def test_no_format_apply_code(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *CODE_MD, MD_FORMATS) assert new_buf == "hello `text` world" assert new_buf[ss:se] == "text" def test_code_inside_sel_toggle_off(self): buf = "hello `text` world" start, end = 6, 12 new_buf, ss, se = toggle(buf, start, end, *CODE_MD, MD_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_code_inside_sel_switch_to_bold(self): buf = "hello `text` world" start, end = 6, 12 new_buf, ss, se = toggle(buf, start, end, *BOLD_MD, MD_FORMATS) assert new_buf == "hello **text** world" assert new_buf[ss:se] == "text" def test_code_outside_sel_toggle_off(self): buf = "hello `text` world" start, end = 7, 11 # "text" inside ` new_buf, ss, se = toggle(buf, start, end, *CODE_MD, MD_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_italic_switch_to_code(self): buf = "hello *text* world" start, end = 6, 12 new_buf, ss, se = toggle(buf, start, end, *CODE_MD, MD_FORMATS) assert new_buf == "hello `text` world" assert new_buf[ss:se] == "text" # --------------------------------------------------------------------------- # RST — all four formats (including asymmetric strikethrough) # --------------------------------------------------------------------------- class TestRstFormats: """RST-specific marker combinations.""" def test_no_format_apply_bold(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *BOLD_RST, RST_FORMATS) assert new_buf == "hello **text** world" assert new_buf[ss:se] == "text" def test_no_format_apply_italic(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *ITALIC_RST, RST_FORMATS) assert new_buf == "hello *text* world" assert new_buf[ss:se] == "text" def test_no_format_apply_code(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *CODE_RST, RST_FORMATS) assert new_buf == "hello ``text`` world" assert new_buf[ss:se] == "text" def test_no_format_apply_strikethrough(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *STRIKE_RST, RST_FORMATS) assert new_buf == "hello :del:`text` world" assert new_buf[ss:se] == "text" def test_strikethrough_inside_sel_toggle_off(self): buf = "hello :del:`text` world" # ":del:`text`" is at 6..17 start, end = 6, 17 new_buf, ss, se = toggle(buf, start, end, *STRIKE_RST, RST_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_strikethrough_inside_sel_switch_to_bold(self): buf = "hello :del:`text` world" start, end = 6, 17 new_buf, ss, se = toggle(buf, start, end, *BOLD_RST, RST_FORMATS) assert new_buf == "hello **text** world" assert new_buf[ss:se] == "text" def test_strikethrough_outside_sel_toggle_off(self): """Select only 'text' with :del:` outside.""" buf = "hello :del:`text` world" # ":del:`" ends at offset 12, "`" starts at offset 16 start, end = 12, 16 new_buf, ss, se = toggle(buf, start, end, *STRIKE_RST, RST_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_strikethrough_outside_sel_switch_to_italic(self): buf = "hello :del:`text` world" start, end = 12, 16 new_buf, ss, se = toggle(buf, start, end, *ITALIC_RST, RST_FORMATS) assert new_buf == "hello *text* world" assert new_buf[ss:se] == "text" def test_code_inside_sel_toggle_off(self): buf = "hello ``text`` world" start, end = 6, 14 new_buf, ss, se = toggle(buf, start, end, *CODE_RST, RST_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_code_outside_sel_switch_to_bold(self): buf = "hello ``text`` world" start, end = 8, 12 # "text" inside `` new_buf, ss, se = toggle(buf, start, end, *BOLD_RST, RST_FORMATS) assert new_buf == "hello **text** world" assert new_buf[ss:se] == "text" def test_bold_outside_sel_switch_to_code(self): buf = "hello **text** world" start, end = 8, 12 # "text" inside ** new_buf, ss, se = toggle(buf, start, end, *CODE_RST, RST_FORMATS) assert new_buf == "hello ``text`` world" assert new_buf[ss:se] == "text" # --------------------------------------------------------------------------- # HTML — strikethrough and code # --------------------------------------------------------------------------- class TestHtmlStrikethrough: def test_no_format_apply_strikethrough(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *STRIKE_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_strikethrough_inside_sel_toggle_off(self): buf = "hello text world" start, end = 6, 17 new_buf, ss, se = toggle(buf, start, end, *STRIKE_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_strikethrough_inside_sel_switch_to_bold(self): buf = "hello text world" start, end = 6, 17 new_buf, ss, se = toggle(buf, start, end, *BOLD_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_strikethrough_outside_sel_toggle_off(self): buf = "hello text world" start, end = 9, 13 # "text" inside new_buf, ss, se = toggle(buf, start, end, *STRIKE_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" class TestHtmlCode: def test_no_format_apply_code(self): buf = "hello text world" start, end = 6, 10 new_buf, ss, se = toggle(buf, start, end, *CODE_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_code_inside_sel_toggle_off(self): buf = "hello text world" # "text" is at 6..23 start, end = 6, 23 new_buf, ss, se = toggle(buf, start, end, *CODE_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_code_outside_sel_switch_to_italic(self): buf = "hello text world" start, end = 12, 16 # "text" inside new_buf, ss, se = toggle(buf, start, end, *ITALIC_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" def test_bold_switch_to_code(self): buf = "hello text world" start, end = 6, 17 new_buf, ss, se = toggle(buf, start, end, *CODE_HTML, HTML_FORMATS) assert new_buf == "hello text world" assert new_buf[ss:se] == "text" # --------------------------------------------------------------------------- # Cross-format switching (all → all) for markdown # --------------------------------------------------------------------------- class TestMarkdownCrossFormatSwitching: """Every format can be switched to every other format.""" FORMATS: ClassVar = [BOLD_MD, ITALIC_MD, STRIKE_MD, CODE_MD] FORMAT_NAMES: ClassVar = ["bold", "italic", "strikethrough", "code"] @pytest.mark.parametrize( ("src_fmt", "dst_fmt"), [ (src, dst) for src in [BOLD_MD, ITALIC_MD, STRIKE_MD, CODE_MD] for dst in [BOLD_MD, ITALIC_MD, STRIKE_MD, CODE_MD] if src != dst ], ) def test_switch(self, src_fmt, dst_fmt): src_b, src_a = src_fmt dst_b, dst_a = dst_fmt buf = f"{src_b}text{src_a}" start, end = 0, len(buf) new_buf, ss, se = toggle(buf, start, end, *dst_fmt, MD_FORMATS) assert new_buf == f"{dst_b}text{dst_a}" assert new_buf[ss:se] == "text" # =========================================================================== # Tests for compute_toggle_line_format # =========================================================================== class TestToggleLineFormat: """Tests for compute_toggle_line_format (block / paragraph formatting).""" # ------------------------------------------------------------------- # Markdown blockquote (before="> ", after="") # ------------------------------------------------------------------- def test_md_blockquote_toggle_on(self): result = compute_toggle_line_format("Hello world", "> ") assert result == "> Hello world" def test_md_blockquote_toggle_off(self): result = compute_toggle_line_format("> Hello world", "> ") assert result == "Hello world" def test_md_blockquote_empty_line_on(self): assert compute_toggle_line_format("", "> ") == "> " def test_md_blockquote_empty_line_off(self): assert compute_toggle_line_format("> ", "> ") == "" def test_md_blockquote_no_false_strip(self): # Line starts with ">" but not "> " — should add, not strip assert compute_toggle_line_format(">text", "> ") == "> >text" # ------------------------------------------------------------------- # RST blockquote — spaces variant (before=" ", after="") # ------------------------------------------------------------------- def test_rst_blockquote_spaces_toggle_on(self): assert compute_toggle_line_format("Hello", " ") == " Hello" def test_rst_blockquote_spaces_toggle_off(self): assert compute_toggle_line_format(" Hello", " ") == "Hello" def test_rst_blockquote_spaces_empty_on(self): assert compute_toggle_line_format("", " ") == " " def test_rst_blockquote_spaces_empty_off(self): assert compute_toggle_line_format(" ", " ") == "" # ------------------------------------------------------------------- # RST blockquote — tab variant (before="\t", after="") # ------------------------------------------------------------------- def test_rst_blockquote_tab_toggle_on(self): assert compute_toggle_line_format("Hello", "\t") == "\tHello" def test_rst_blockquote_tab_toggle_off(self): assert compute_toggle_line_format("\tHello", "\t") == "Hello" # ------------------------------------------------------------------- # HTML blockquote (before="
    ", after="
    ") # ------------------------------------------------------------------- HTML_B = "
    " HTML_A = "
    " def test_html_blockquote_toggle_on(self): result = compute_toggle_line_format( "Hello world", self.HTML_B, self.HTML_A, ) assert result == "
    Hello world
    " def test_html_blockquote_toggle_off(self): result = compute_toggle_line_format( "
    Hello world
    ", self.HTML_B, self.HTML_A, ) assert result == "Hello world" def test_html_blockquote_empty_line_on(self): result = compute_toggle_line_format("", self.HTML_B, self.HTML_A) assert result == "
    " def test_html_blockquote_empty_line_off(self): result = compute_toggle_line_format( "
    ", self.HTML_B, self.HTML_A, ) assert result == "" def test_html_blockquote_only_before_adds_both(self): # Only the before-marker is present — should add, not strip result = compute_toggle_line_format( "
    Hello world", self.HTML_B, self.HTML_A, ) assert result == ("
    Hello world
    ") def test_html_blockquote_only_after_adds_both(self): # Only the after-marker is present — should add, not strip result = compute_toggle_line_format( "Hello world
    ", self.HTML_B, self.HTML_A, ) assert result == ("
    Hello world
    ") # ------------------------------------------------------------------- # Idempotency: toggling on and then off returns original text # ------------------------------------------------------------------- @pytest.mark.parametrize( ("before", "after", "text"), [ ("> ", "", "Hello world"), ("> ", "", ""), (" ", "", "Some RST text"), ("\t", "", "Tab indented"), ("
    ", "
    ", "HTML paragraph"), ("
    ", "
    ", ""), ], ) def test_idempotent_round_trip(self, before, after, text): toggled_on = compute_toggle_line_format(text, before, after) # Must change (unless text was already empty and prefix is also empty) assert toggled_on != text or not text toggled_off = compute_toggle_line_format(toggled_on, before, after) assert toggled_off == text # =========================================================================== # Tests for compute_toggle_line_exclusive (header levels) # =========================================================================== class TestToggleLineExclusive: """Tests for compute_toggle_line_exclusive (mutually exclusive formats).""" MD_VARIANTS: ClassVar = [(f"{'#' * n} ", "") for n in range(1, 7)] HTML_VARIANTS: ClassVar = [(f"", f"") for n in range(1, 7)] # --- Markdown ----------------------------------------------------------- def test_md_h1_toggle_on(self): result = compute_toggle_line_exclusive( "Hello", "# ", "", self.MD_VARIANTS, ) assert result == "# Hello" def test_md_h3_toggle_on(self): result = compute_toggle_line_exclusive( "Hello", "### ", "", self.MD_VARIANTS, ) assert result == "### Hello" def test_md_h1_toggle_off(self): result = compute_toggle_line_exclusive( "# Hello", "# ", "", self.MD_VARIANTS, ) assert result == "Hello" def test_md_switch_h1_to_h3(self): result = compute_toggle_line_exclusive( "# Hello", "### ", "", self.MD_VARIANTS, ) assert result == "### Hello" def test_md_switch_h3_to_h6(self): result = compute_toggle_line_exclusive( "### Hello", "###### ", "", self.MD_VARIANTS, ) assert result == "###### Hello" def test_md_all_levels_round_trip(self): for level in range(1, 7): before = "#" * level + " " line = f"{before}Text" # Toggle off stripped = compute_toggle_line_exclusive( line, before, "", self.MD_VARIANTS, ) assert stripped == "Text" # --- HTML --------------------------------------------------------------- def test_html_h1_toggle_on(self): result = compute_toggle_line_exclusive( "Hello", "

    ", "

    ", self.HTML_VARIANTS, ) assert result == "

    Hello

    " def test_html_h2_toggle_off(self): result = compute_toggle_line_exclusive( "

    Hello

    ", "

    ", "

    ", self.HTML_VARIANTS, ) assert result == "Hello" def test_html_switch_h1_to_h4(self): result = compute_toggle_line_exclusive( "

    Hello

    ", "

    ", "

    ", self.HTML_VARIANTS, ) assert result == "

    Hello

    " # =========================================================================== # Tests for compute_toggle_rst_header # =========================================================================== class TestToggleRstHeader: """Tests for compute_toggle_rst_header.""" # --- Add underline (no existing underline) ------------------------------ def test_add_h1(self): underline, had = compute_toggle_rst_header("Hello", None, "=") assert underline == "=====" assert not had def test_add_h2_with_next_line_unrelated(self): underline, had = compute_toggle_rst_header("Hi", "Some text", "-") assert underline == "--" assert not had def test_underline_matches_text_length(self): text = "A longer title" underline, _ = compute_toggle_rst_header(text, None, "=") assert underline == "=" * len(text) # --- Toggle off (same underline char) ----------------------------------- def test_toggle_off_h1(self): underline, had = compute_toggle_rst_header("Hello", "=====", "=") assert underline is None assert had def test_toggle_off_longer_underline(self): # Underline longer than title — still recognised underline, had = compute_toggle_rst_header("Hi", "=====", "=") assert underline is None assert had # --- Replace (different underline char) --------------------------------- def test_replace_h1_with_h2(self): underline, had = compute_toggle_rst_header("Hello", "=====", "-") assert underline == "-----" assert had def test_replace_h3_with_h5(self): underline, had = compute_toggle_rst_header("Test", "^^^^", "~") assert underline == "~~~~" assert had # --- Edge cases --------------------------------------------------------- def test_empty_line_no_op(self): underline, had = compute_toggle_rst_header("", None, "=") assert underline is None assert not had def test_whitespace_only_line_no_op(self): underline, had = compute_toggle_rst_header(" ", None, "=") assert underline is None assert not had def test_all_known_chars_recognised(self): for char in RST_HEADER_CHARS: underline, had = compute_toggle_rst_header("X", char * 5, char) assert underline is None assert had def test_unknown_char_not_recognised(self): # '#' is not an RST underline char underline, had = compute_toggle_rst_header("Hello", "#####", "=") assert underline == "=====" assert not had # =========================================================================== # Tests for compute_toggle_bullet # =========================================================================== from formiko.format_utils import compute_toggle_bullet # noqa: E402 MD_BLOCK_VARIANTS = ( ("###### ", ""), ("##### ", ""), ("#### ", ""), ("### ", ""), ("## ", ""), ("# ", ""), ("> ", ""), ) HTML_BLOCK_VARIANTS = tuple( [(f"", f"") for n in range(1, 7)] + [("
    ", "
    ")], ) class TestToggleBullet: """Tests for compute_toggle_bullet.""" # ------------------------------------------------------------------- # Basic toggle on / off # ------------------------------------------------------------------- def test_toggle_on_plain_line(self): text, blank = compute_toggle_bullet("Hello", None, "- ") assert text == "- Hello" assert not blank def test_toggle_off(self): text, blank = compute_toggle_bullet("- Hello", None, "- ") assert text == "Hello" assert not blank def test_toggle_off_never_inserts_blank(self): # Even if prev line has content, toggle-off never requests blank line text, blank = compute_toggle_bullet("- Hello", "Some text", "- ") assert text == "Hello" assert not blank # ------------------------------------------------------------------- # Blank-line separator logic # ------------------------------------------------------------------- def test_blank_line_when_prev_has_text(self): _, blank = compute_toggle_bullet("Hello", "Previous text", "- ") assert blank def test_no_blank_when_prev_is_empty(self): _, blank = compute_toggle_bullet("Hello", "", "- ") assert not blank def test_no_blank_when_prev_is_whitespace_only(self): _, blank = compute_toggle_bullet("Hello", " ", "- ") assert not blank def test_no_blank_when_prev_is_list_item(self): _, blank = compute_toggle_bullet("Hello", "- Previous", "- ") assert not blank def test_no_blank_when_no_prev_line(self): _, blank = compute_toggle_bullet("Hello", None, "- ") assert not blank # ------------------------------------------------------------------- # Block-format stripping (Markdown) # ------------------------------------------------------------------- def test_strips_h1_before_adding_bullet(self): text, _ = compute_toggle_bullet( "# Title", None, "- ", all_block_variants=MD_BLOCK_VARIANTS, ) assert text == "- Title" def test_strips_h3_before_adding_bullet(self): text, _ = compute_toggle_bullet( "### Title", None, "- ", all_block_variants=MD_BLOCK_VARIANTS, ) assert text == "- Title" def test_strips_blockquote_before_adding_bullet(self): text, _ = compute_toggle_bullet( "> Text", None, "- ", all_block_variants=MD_BLOCK_VARIANTS, ) assert text == "- Text" def test_strips_h6_and_adds_blank(self): text, blank = compute_toggle_bullet( "###### Title", "Previous", "- ", all_block_variants=MD_BLOCK_VARIANTS, ) assert text == "- Title" assert blank # ------------------------------------------------------------------- # Block-format stripping (HTML) # ------------------------------------------------------------------- def test_html_strips_h2_tag(self): text, _ = compute_toggle_bullet( "

    Title

    ", None, "
  • ", "
  • ", HTML_BLOCK_VARIANTS, ) assert text == "
  • Title
  • " def test_html_strips_blockquote(self): text, _ = compute_toggle_bullet( "
    Text
    ", None, "
  • ", "
  • ", HTML_BLOCK_VARIANTS, ) assert text == "
  • Text
  • " def test_html_no_blank_line_logic(self): # prev_line_text=None suppresses blank line for HTML _, blank = compute_toggle_bullet( "Text", None, "
  • ", "
  • ", HTML_BLOCK_VARIANTS, ) assert not blank # ------------------------------------------------------------------- # Nested lists: leading indentation is preserved # ------------------------------------------------------------------- def test_nested_toggle_on_preserves_indent(self): text, _ = compute_toggle_bullet(" Text", None, "- ") assert text == " - Text" def test_nested_tab_indent_preserved(self): text, _ = compute_toggle_bullet("\tText", None, "- ") assert text == "\t- Text" def test_nested_toggle_off_preserves_indent(self): text, _ = compute_toggle_bullet(" - Item", None, "- ") assert text == " Item" def test_nested_blank_line_with_prev_text(self): _, blank = compute_toggle_bullet("Text", "Previous", "- ") assert blank def test_nested_no_blank_if_prev_is_indented_bullet(self): _, blank = compute_toggle_bullet(" Text", " - Prev", "- ") assert not blank # --------------------------------------------------------------------------- # TestToggleOrdered # --------------------------------------------------------------------------- _MD_BLOCK_VARIANTS = [ ("> ", ""), ("# ", ""), ("## ", ""), ("### ", ""), ("#### ", ""), ("##### ", ""), ("###### ", ""), ] class TestToggleOrdered: """Tests for compute_toggle_ordered (MD / RST / HTML via toggle_bullet).""" # ------- toggle-off ------------------------------------------------------ def test_md_toggle_off(self): text, blank = compute_toggle_ordered("1. Item", None) assert text == "Item" assert not blank def test_md_toggle_off_preserves_content(self): text, _ = compute_toggle_ordered("3. Hello world", None) assert text == "Hello world" def test_rst_toggle_off(self): text, blank = compute_toggle_ordered( "2. Next item", None, auto_number=True, ) assert text == "Next item" assert not blank # ------- toggle-on — Markdown -------------------------------------------- def test_md_toggle_on_plain(self): text, blank = compute_toggle_ordered("Plain text", None) assert text == "1. Plain text" assert not blank def test_md_toggle_on_no_prev(self): _, blank = compute_toggle_ordered("Text", None) assert not blank def test_md_toggle_on_prev_is_ordered(self): _, blank = compute_toggle_ordered("New item", "1. Previous") assert not blank def test_md_toggle_on_prev_not_ordered(self): _, blank = compute_toggle_ordered("New item", "Some text") assert blank def test_md_toggle_on_prev_empty(self): _, blank = compute_toggle_ordered("New item", "") assert not blank def test_md_toggle_on_strips_block_variant(self): text, _ = compute_toggle_ordered( "> Quoted text", None, all_block_variants=_MD_BLOCK_VARIANTS, ) assert text == "1. Quoted text" def test_md_toggle_on_strips_header(self): text, _ = compute_toggle_ordered( "## Header text", None, all_block_variants=_MD_BLOCK_VARIANTS, ) assert text == "1. Header text" # ------- toggle-on — RST ------------------------------------------------- def test_rst_toggle_on_plain(self): text, blank = compute_toggle_ordered("Item", None, auto_number=True) assert text == "1. Item" assert not blank def test_rst_toggle_on_prev_is_ordered_increments(self): text, blank = compute_toggle_ordered( "Next", "1. Previous", auto_number=True, ) assert text == "2. Next" assert not blank def test_rst_toggle_on_prev_ordered_high_number(self): text, _ = compute_toggle_ordered("Item", "9. Last", auto_number=True) assert text == "10. Item" def test_rst_toggle_on_prev_not_ordered_uses_one(self): text, blank = compute_toggle_ordered( "Item", "Some text", auto_number=True, ) assert text == "1. Item" assert blank def test_rst_nested_toggle_on_preserves_indent(self): text, _ = compute_toggle_ordered(" Indented", None, auto_number=True) assert text == " 1. Indented" def test_rst_toggle_off_no_blank(self): _, blank = compute_toggle_ordered( "4. Item", "3. Prev", auto_number=True, ) assert not blank # ------- invariants ------------------------------------------------------ def test_toggle_off_then_on_md(self): original = "Hello" toggled_on, _ = compute_toggle_ordered(original, None) toggled_off, _ = compute_toggle_ordered(toggled_on, None) assert toggled_off == original def test_toggle_off_then_on_rst(self): original = "Hello" toggled_on, _ = compute_toggle_ordered( original, None, auto_number=True, ) toggled_off, _ = compute_toggle_ordered( toggled_on, None, auto_number=True, ) assert toggled_off == original # --------------------------------------------------------------------------- # TestCrossBlockStripping # --------------------------------------------------------------------------- _MD_ALL_BLOCK = ( ("###### ", ""), ("##### ", ""), ("#### ", ""), ("### ", ""), ("## ", ""), ("# ", ""), ("> ", ""), ("- ", ""), ) class TestCrossBlockStripping: """Block formats strip each other's markers when toggling on.""" # ------- blockquote strips others ---------------------------------------- def test_blockquote_strips_bullet_md(self): text = compute_toggle_line_format( "- Item", "> ", all_block_variants=_MD_ALL_BLOCK, ) assert text == "> Item" def test_blockquote_strips_header_md(self): text = compute_toggle_line_format( "## Heading", "> ", all_block_variants=_MD_ALL_BLOCK, ) assert text == "> Heading" def test_blockquote_strips_ordered_md(self): text = compute_toggle_line_format( "3. Item", "> ", strip_ordered=True, ) assert text == "> Item" def test_blockquote_strips_ordered_and_bullet(self): # ordered takes priority (applied first), then variants loop skipped text = compute_toggle_line_format( "2. Text", "> ", all_block_variants=_MD_ALL_BLOCK, strip_ordered=True, ) assert text == "> Text" # ------- header strips others -------------------------------------------- _MD_HEADER_VARIANTS: ClassVar = [ ("###### ", ""), ("##### ", ""), ("#### ", ""), ("### ", ""), ("## ", ""), ("# ", ""), ] _EXTRA_MD: ClassVar = (("> ", ""), ("- ", "")) def test_header_strips_blockquote_md(self): text = compute_toggle_line_exclusive( "> Text", "## ", "", self._MD_HEADER_VARIANTS, extra_strip_variants=self._EXTRA_MD, ) assert text == "## Text" def test_header_strips_bullet_md(self): text = compute_toggle_line_exclusive( "- Item", "# ", "", self._MD_HEADER_VARIANTS, extra_strip_variants=self._EXTRA_MD, ) assert text == "# Item" def test_header_strips_ordered_md(self): text = compute_toggle_line_exclusive( "1. Item", "# ", "", self._MD_HEADER_VARIANTS, extra_strip_variants=self._EXTRA_MD, strip_ordered=True, ) assert text == "# Item" # ------- bullet strips others -------------------------------------------- def test_bullet_strips_header_md(self): text, _ = compute_toggle_bullet( "## Heading", None, "- ", all_block_variants=_MD_ALL_BLOCK, ) assert text == "- Heading" def test_bullet_strips_blockquote_md(self): text, _ = compute_toggle_bullet( "> Quote", None, "- ", all_block_variants=_MD_ALL_BLOCK, ) assert text == "- Quote" def test_bullet_strips_ordered_md(self): text, _ = compute_toggle_bullet( "2. Item", None, "- ", all_block_variants=_MD_ALL_BLOCK, strip_ordered=True, ) assert text == "- Item" def test_bullet_nested_preserves_indent(self): text, _ = compute_toggle_bullet(" Indented", None, "- ") assert text == " - Indented" def test_bullet_strips_ordered_rst(self): text, _ = compute_toggle_bullet( "3. Item", None, "- ", strip_ordered=True, ) assert text == "- Item" # ------- ordered strips others ------------------------------------------- def test_ordered_strips_header_md(self): text, _ = compute_toggle_ordered( "## Heading", None, all_block_variants=_MD_ALL_BLOCK, ) assert text == "1. Heading" def test_ordered_strips_blockquote_md(self): text, _ = compute_toggle_ordered( "> Quote", None, all_block_variants=_MD_ALL_BLOCK, ) assert text == "1. Quote" def test_ordered_strips_bullet_md(self): text, _ = compute_toggle_ordered( "- Item", None, all_block_variants=_MD_ALL_BLOCK, ) assert text == "1. Item" def test_ordered_strips_bullet_rst(self): text, _ = compute_toggle_ordered( "- Item", None, all_block_variants=(("- ", ""),), auto_number=True, ) assert text == "1. Item" def test_ordered_nested_preserves_indent(self): text, _ = compute_toggle_ordered( " Indented", None, all_block_variants=(("- ", ""),), auto_number=True, ) assert text == " 1. Indented" def test_nested_bullet_to_ordered(self): # indented bullet → indented ordered text, _ = compute_toggle_ordered( " - Item", None, all_block_variants=(("- ", ""),), auto_number=True, ) assert text == " 1. Item" def test_nested_ordered_to_bullet(self): # indented ordered → indented bullet text, _ = compute_toggle_bullet( " 1. Item", None, "- ", all_block_variants=(("- ", ""),), strip_ordered=True, ) assert text == " - Item" def test_nested_ordered_toggle_off_preserves_indent(self): text, _ = compute_toggle_ordered(" 3. Item", None, auto_number=True) assert text == " Item" def test_nested_auto_number_from_indented_prev(self): # previous line is also indented text, _ = compute_toggle_ordered( " Item", " 2. Prev", auto_number=True, ) assert text == " 3. Item" def test_md_with_text(self): from formiko.format_utils import compute_link assert ( compute_link("Python", "https://python.org", "m2r") == "[Python](https://python.org)" ) def test_rst_with_text(self): from formiko.format_utils import compute_link assert ( compute_link("Python", "https://python.org", "rst") == "`Python `_" ) def test_html_with_text(self): from formiko.format_utils import compute_link assert ( compute_link("Python", "https://python.org", "html") == 'Python' ) def test_html_no_text_uses_url(self): from formiko.format_utils import compute_link assert ( compute_link("", "https://python.org", "html") == 'https://python.org' ) def test_no_text_returns_url(self): from formiko.format_utils import compute_link assert ( compute_link("", "https://python.org", "m2r") == "https://python.org" ) assert ( compute_link("", "https://python.org", "rst") == "https://python.org" ) def test_whitespace_text_returns_url(self): from formiko.format_utils import compute_link assert ( compute_link(" ", "https://python.org", "m2r") == "https://python.org" ) class TestParseLink: def test_md_link(self): from formiko.format_utils import parse_link assert parse_link("[Python](https://python.org)", "m2r") == ( "Python", "https://python.org", ) def test_rst_link(self): from formiko.format_utils import parse_link assert parse_link("`Python `_", "rst") == ( "Python", "https://python.org", ) def test_html_link(self): from formiko.format_utils import parse_link assert parse_link( 'Python', "html", ) == ("Python", "https://python.org") def test_bare_url_md(self): from formiko.format_utils import parse_link assert parse_link("https://python.org", "m2r") == ( "", "https://python.org", ) def test_bare_url_rst(self): from formiko.format_utils import parse_link assert parse_link("https://python.org", "rst") == ( "", "https://python.org", ) def test_plain_text_md(self): from formiko.format_utils import parse_link assert parse_link("hello world", "m2r") == ("hello world", "") def test_plain_text_rst(self): from formiko.format_utils import parse_link assert parse_link("hello world", "rst") == ("hello world", "") def test_empty_string(self): from formiko.format_utils import parse_link assert parse_link("", "m2r") == ("", "") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1772630408.9641376 formiko-2.0.0/tests/test_sourceview.py0000664000175000017500000002514615152030611017115 0ustar00mcbigmcbig"""Headless tests for SourceView formatting methods. These tests use a bare ``GtkSource.Buffer`` without any display server — they exercise the GTK buffer manipulation logic that sits between the pure-Python ``format_utils`` functions and the UI. Run normally with ``pytest``; no ``xvfb-run`` or ``$DISPLAY`` needed. """ import contextlib import gi gi.require_version("GtkSource", "5") gi.require_version("Adw", "1") with contextlib.suppress(ValueError): gi.require_version("Spelling", "1") from gi.repository import GtkSource # noqa: E402 GtkSource.init() # ------------------------------------------------------------------ # Lightweight adapter: just enough of SourceView to test formatting # ------------------------------------------------------------------ class _BufferAdapter: """Minimal stand-in for :class:`formiko.sourceview.SourceView`. Exposes only ``text_buffer`` and the extracted helper methods so that the public ``toggle_*`` methods can be tested without creating a full widget tree. """ def __init__(self, text: str, cursor_line: int = 0, cursor_col: int = 0): self.text_buffer = GtkSource.Buffer() self.text_buffer.set_text(text) self._place_cursor(cursor_line, cursor_col) # --- import the methods under test from SourceView ----------------- from formiko.sourceview import SourceView as _SV _get_current_line = _SV._get_current_line _get_line_text = _SV._get_line_text _replace_line = _SV._replace_line _replace_range = _SV._replace_range _toggle_list_item = _SV._toggle_list_item toggle_line_format = _SV.toggle_line_format toggle_line_exclusive = _SV.toggle_line_exclusive toggle_rst_header = _SV.toggle_rst_header toggle_bullet = _SV.toggle_bullet toggle_ordered = _SV.toggle_ordered toggle_format = _SV.toggle_format get_selected_text = _SV.get_selected_text get_selection_offsets = _SV.get_selection_offsets insert_link_text = _SV.insert_link_text del _SV # --- helpers ------------------------------------------------------- def _place_cursor(self, line, col=0): buf = self.text_buffer _, it = buf.get_iter_at_line(line) it.forward_chars(col) buf.place_cursor(it) def _select(self, start_offset, end_offset): buf = self.text_buffer s = buf.get_iter_at_offset(start_offset) e = buf.get_iter_at_offset(end_offset) buf.select_range(s, e) @property def text(self): return self.text_buffer.props.text @property def selection(self): """Return selected text, or ``None``.""" bounds = self.text_buffer.get_selection_bounds() if not bounds: return None return self.text_buffer.get_text(bounds[0], bounds[1], True) # ================================================================== # Tests for private helpers # ================================================================== class TestGetCurrentLine: """Tests for ``_get_current_line``.""" def test_single_line(self): sv = _BufferAdapter("Hello world") num, _s, _e, text = sv._get_current_line() assert num == 0 assert text == "Hello world" def test_middle_line(self): sv = _BufferAdapter("a\nbb\nccc", cursor_line=1) num, _s, _e, text = sv._get_current_line() assert num == 1 assert text == "bb" def test_last_line(self): sv = _BufferAdapter("a\nb\nc", cursor_line=2) num, _s, _e, text = sv._get_current_line() assert num == 2 assert text == "c" class TestGetLineText: """Tests for ``_get_line_text``.""" def test_valid_line(self): sv = _BufferAdapter("aaa\nbbb\nccc") assert sv._get_line_text(1) == "bbb" def test_negative_returns_none(self): sv = _BufferAdapter("x") assert sv._get_line_text(-1) is None class TestReplaceLine: """Tests for ``_replace_line``.""" def test_replaces_content(self): sv = _BufferAdapter("old line") _, start, end, text = sv._get_current_line() sv._replace_line(start, end, text, "new line") assert sv.text == "new line" def test_no_op_when_same(self): sv = _BufferAdapter("same") _, start, end, text = sv._get_current_line() sv._replace_line(start, end, text, "same") assert sv.text == "same" # ================================================================== # Tests for public toggle methods (integration with GTK buffer) # ================================================================== class TestToggleLineFormat: """toggle_line_format on a real GtkSource.Buffer.""" def test_add_blockquote_md(self): sv = _BufferAdapter("Hello") sv.toggle_line_format("> ") assert sv.text == "> Hello" def test_remove_blockquote_md(self): sv = _BufferAdapter("> Hello") sv.toggle_line_format("> ") assert sv.text == "Hello" def test_strip_other_on_toggle(self): sv = _BufferAdapter("- Item") sv.toggle_line_format( "> ", all_block_variants=(("- ", ""),), ) assert sv.text == "> Item" class TestToggleLineExclusive: """toggle_line_exclusive on a real GtkSource.Buffer.""" _VARIANTS = [ ("# ", ""), ("## ", ""), ("### ", ""), ] def test_add_header(self): sv = _BufferAdapter("Title") sv.toggle_line_exclusive("## ", "", self._VARIANTS) assert sv.text == "## Title" def test_toggle_off_same_header(self): sv = _BufferAdapter("## Title") sv.toggle_line_exclusive("## ", "", self._VARIANTS) assert sv.text == "Title" def test_switch_header_level(self): sv = _BufferAdapter("## Title") sv.toggle_line_exclusive("# ", "", self._VARIANTS) assert sv.text == "# Title" class TestToggleRstHeader: """toggle_rst_header on a real GtkSource.Buffer.""" def test_add_underline(self): sv = _BufferAdapter("Title") sv.toggle_rst_header("=") assert sv.text == "Title\n=====" def test_toggle_off_same_underline(self): sv = _BufferAdapter("Title\n=====") sv.toggle_rst_header("=") assert sv.text == "Title" def test_replace_underline(self): sv = _BufferAdapter("Title\n=====") sv.toggle_rst_header("-") assert sv.text == "Title\n-----" def test_empty_line_noop(self): sv = _BufferAdapter("") sv.toggle_rst_header("=") assert sv.text == "" class TestToggleBullet: """toggle_bullet on a real GtkSource.Buffer.""" def test_add_bullet(self): sv = _BufferAdapter("Item") sv.toggle_bullet("- ", "", (), needs_blank=False) assert sv.text == "- Item" def test_remove_bullet(self): sv = _BufferAdapter("- Item") sv.toggle_bullet("- ", "", (), needs_blank=False) assert sv.text == "Item" def test_blank_line_inserted(self): sv = _BufferAdapter("Prev\nItem", cursor_line=1) sv.toggle_bullet("- ", "", (), needs_blank=True) assert sv.text == "Prev\n\n- Item" def test_no_blank_when_prev_is_bullet(self): sv = _BufferAdapter("- Prev\nItem", cursor_line=1) sv.toggle_bullet("- ", "", (), needs_blank=True) assert sv.text == "- Prev\n- Item" def test_strips_header_on_toggle(self): sv = _BufferAdapter("## Heading") sv.toggle_bullet( "- ", "", (("## ", ""), ("# ", "")), needs_blank=False, ) assert sv.text == "- Heading" class TestToggleOrdered: """toggle_ordered on a real GtkSource.Buffer.""" def test_add_ordered(self): sv = _BufferAdapter("Item") sv.toggle_ordered((), needs_blank=False) assert sv.text == "1. Item" def test_remove_ordered(self): sv = _BufferAdapter("1. Item") sv.toggle_ordered((), needs_blank=False) assert sv.text == "Item" def test_auto_number_rst(self): sv = _BufferAdapter("1. First\nSecond", cursor_line=1) sv.toggle_ordered( (), needs_blank=True, auto_number=True, ) assert sv.text == "1. First\n2. Second" def test_blank_line_inserted(self): sv = _BufferAdapter("Prev\nItem", cursor_line=1) sv.toggle_ordered((), needs_blank=True) assert sv.text == "Prev\n\n1. Item" class TestToggleFormat: """toggle_format (inline) on a real GtkSource.Buffer.""" def test_add_bold(self): sv = _BufferAdapter("hello world") sv._select(6, 11) # "world" sv.toggle_format("**", "**", [("**", "**")]) assert sv.text == "hello **world**" assert sv.selection == "world" def test_remove_bold(self): sv = _BufferAdapter("hello **world**") sv._select(8, 13) # "world" inside markers sv.toggle_format("**", "**", [("**", "**")]) assert sv.text == "hello world" assert sv.selection == "world" def test_switch_format(self): sv = _BufferAdapter("hello *world*") sv._select(7, 12) # "world" inside * known = [("**", "**"), ("*", "*")] sv.toggle_format("**", "**", known) assert sv.text == "hello **world**" assert sv.selection == "world" class TestInsertLinkText: def test_insert_at_cursor(self): sv = _BufferAdapter("hello world") sv._place_cursor(0, 5) # after "hello" sv.insert_link_text("[link](url)", 5, 5) assert sv.text == "hello [link](url) world" assert sv.selection == "[link](url)" def test_insert_replaces_selection(self): sv = _BufferAdapter("hello world") sv._select(6, 11) # "world" sv.insert_link_text("[world](url)", 6, 11) assert sv.text == "hello [world](url)" assert sv.selection == "[world](url)" def test_no_extra_space_at_line_start(self): sv = _BufferAdapter("hello") sv._place_cursor(0, 0) sv.insert_link_text("[link](url)", 0, 0) assert sv.text == "[link](url) hello" assert sv.selection == "[link](url)" def test_no_extra_space_at_line_end(self): sv = _BufferAdapter("hello") sv._place_cursor(0, 5) sv.insert_link_text("[link](url)", 5, 5) assert sv.text == "hello [link](url)" assert sv.selection == "[link](url)" def test_no_double_space(self): sv = _BufferAdapter("hello world") sv._place_cursor(0, 5) # after "hello", before space sv.insert_link_text("[x](y)", 5, 5) # space before link because "hello" ends without space; # but there IS already a space after, so no suffix assert sv.text == "hello [x](y) world" assert sv.selection == "[x](y)"