Sublime Forum

NeoCodeMap for Typst (and other editors) outlining

#1

After extensive testing, I am pleased to share that I have refined the NeoCodeMap format so that it can serve as a proper outlining tool for Typst. You can click on the Firefox-rendered document, the source.typ file, or any location within the outline, and the three of them synchronise dynamically. It behaves like a dedicated editor (though many exist, few match the capabilities of ST Latest Typst IDEs).

"""
Indenter and patches for Typst in NeoCodeMap (Sublime Text).
https://github.com/klmp200/NeoCodeMap

Installation:
    Preferences -> Browse Packages... -> User/
    Copy this file as-is into that folder (any .py filename is acceptable).
    The plugin reloads automatically when you save; no need to restart Sublime Text.

What it does:

1. Indentation by nesting level (tested and confirmed working).
   Registers an indenter for the "text.typst" scope that calculates the nesting
   level by counting the "=" characters in each heading (=, ==, === ...), using
   the same approach that NeoCodeMap already applies to Markdown (counting the
   number in "markup.heading.N"). Typst does not include the level in the scope
   name, so we extract it directly from the line text using a regex.

2. Hide the "kind" badge (the yellow "S") in front of each entry.
   The initial attempt (remapping the kind_id MARKUP to the already-hidden
   "kind_ambiguous" class) had no visible effect. Rather than guessing which
   kind_id Sublime uses for Typst sections, we directly hide the CSS class
   ".kind" for all entries by adding a rule to the stylesheet that NeoCodeMap
   already loads. This is done in "plugin_loaded" with a set_timeout(...,0) to
   ensure that NeoCodeMap's own plugin_loaded() has already run and populated
   its global "css" variable; otherwise, our change would be overwritten
   immediately afterwards.
   Note: this hides the kind badge for ALL languages, not just Typst
   (NeoCodeMap uses the same stylesheet for everything). If you ever want to
   restore it for other languages, delete block 2 entirely.

3. Select the entire line when clicking an entry in the map, rather than just
   moving the cursor (a single point with no selection). NeoCodeMap by default
   leaves a "simple" cursor (sublime.View.sel().add(region.begin())), and this
   does not trigger the Tinymist/LSP jump during rendering, as you mentioned
   happened before fixing it in nextprevsect.py. We patch
   CodeMapManager.move_to_region so that after the goto_line, it selects the
   entire heading line (view.line(...)) instead of a single point. If
   nextprevsect.py selects something more specific (for example, just the
   heading text), let me know and I will adjust it accordingly.

4. Remove the "⧉" icon (goto reference) at the end of each entry.
   This icon is the second <a> element generated by get_html() for each symbol,
   with no dedicated class to reliably hide it via CSS (and Sublime's HTML
   engine — minihtml — does not reliably support selectors like :last-child).
   Therefore, we wrap get_html() and strip that <a> from the resulting HTML
   using a regex, matching the "neo_code_map_goto_reference" command name in
   the href.

5. Smaller font size in the outline (adjust OUTLINE_FONT_SIZE below).
   Note: minihtml (Sublime's CSS engine) supports only simple selectors
   (a single class or tag), nothing like comma-separated lists or descendant
   combinators (".item, .item a" does not work; you must use ".item {...}" and
   "a {...}" separately). The rest of NeoCodeMap's CSS follows this same pattern.

Note on synchronisation loss after multiple consecutive saves:
   Sublime reloads this file on the fly each time you save it, but it does not
   reload NeoCodeMap or undo our previous patches. If each reload were to wrap
   an already-patched method (for example, get_html) over itself again, a new
   wrapping layer would accumulate with each save, potentially leaving the
   plugin in an unstable state after several consecutive edits (which is why a
   Sublime Text restart would fix it). Therefore, all patches below check
   whether they have already been applied before reapplying them (idempotent):
   you can save this file as many times as you like without needing to restart.
"""

import re

import sublime

from NeoCodeMap import indenter
from NeoCodeMap import NeoCodeMap as _neocodemap
from NeoCodeMap.NeoCodeMap import CodeMapManager

# Adjust this scope if your Typst syntax package uses a different one.
# Check it with "Tools -> Developer -> Show Scope Name" (ctrl+alt+shift+p)
# on a .typ file: it should start with something like "text.typst".
TYPST_SCOPE = "text.typst"

_HEADING_RE = re.compile(r"^\s*(=+)\s")


def typst_indenter(view: sublime.View, symbol: sublime.SymbolRegion) -> int:
    """Nesting level of a Typst heading according to its number of '='.

    '=' Title        -> level 0
    '== Section'       -> level 1
    '=== Subsection'   -> level 2
    etc.

    If the line is not a recognisable heading (e.g. it comes from another type of
    symbol), we delegate to the default indenter by raising IndenterError,
    just as the Markdown indenter included in NeoCodeMap does.
    """
    line_text = view.substr(view.line(symbol.region.a))
    match = _HEADING_RE.match(line_text)
    if not match:
        raise indenter.IndenterError

    level = len(match.group(1))
    return level - 1


indenter.register_indenter(TYPST_SCOPE, typst_indenter)


# ---------------------------------------------------------------------------
# 2) + 5) Visual adjustments: hide the kind badge and reduce font size
#    (+ matching line spacing). Added as extra CSS rules to the sheet
#    loaded by NeoCodeMap.
#
#    Odd finding (for the ST forum): a rule ".item { font-size: ... }"
#    alone, in its own block, had no effect—not even after restarting ST—.
#    But if in the SAME block ".item { ... }" you also change some other
#    property already present in the base sheet (here, "padding-bottom",
#    which NeoCodeMap's original CSS already sets to 5px), then font-size
#    IS applied. It appears that set_contents() on an HtmlSheet does not
#    recalculate font metrics if the only property that changes from the
#    previous render is font-size by itself—you need to "touch" some other
#    property of the same selector to force a complete relayout—. And even
#    then, line-height was not recalculated automatically; you also have to
#    set it explicitly (absolute units; minihtml does not accept line-height
#    without a unit).
# ---------------------------------------------------------------------------
OUTLINE_FONT_SIZE = "0.7rem"    # baja/sube este valor a tu gusto
OUTLINE_LINE_HEIGHT = "0.8rem"   # ajustalo junto con el de arriba
OUTLINE_PADDING_BOTTOM = "1px"   # distinto del 5px original: es lo que
                                 # parece forzar a minihtml a recalcular

_CSS_OVERRIDES_MARKER = "/* --- typst_neocodemap overrides --- */"
_CSS_OVERRIDES = f"""
{_CSS_OVERRIDES_MARKER}
.kind {{ display: none; }}
.item {{
    padding-bottom: {OUTLINE_PADDING_BOTTOM};
    font-size: {OUTLINE_FONT_SIZE};
    line-height: {OUTLINE_LINE_HEIGHT};
}}
"""
# No separate rule is needed for "a": the font-size of ".item" is inherited by
# the <a> elements inside it (normal CSS inheritance works fine; the problem was
# specifically that the property wasn't being recalculated).


def plugin_loaded():
    sublime.set_timeout(_apply_css_overrides, 0)


def _apply_css_overrides():
    # If we had already added overrides in a previous reload, we
    # cut them and add them again (allows changing OUTLINE_FONT_SIZE
    # and seeing the result just by saving, without accumulating old rules).
    marker_index = _neocodemap.css.find(_CSS_OVERRIDES_MARKER)
    base_css = _neocodemap.css if marker_index == -1 else _neocodemap.css[:marker_index]
    _neocodemap.css = base_css + _CSS_OVERRIDES
    if _neocodemap.map_manager:
        _neocodemap.map_manager.refresh_all()


# ---------------------------------------------------------------------------
# 3) Select the complete line when jumping from the map, so that
#    Tinymist/LSP sync listener reacts the same way as with
#    nextprevsect.py.
# ---------------------------------------------------------------------------
def _move_to_region_full_line(self, view: sublime.View, region: sublime.Region) -> None:
    if window := view.window():
        window.focus_view(view)

    view.sel().clear()
    view.run_command("goto_line", {"line": view.rowcol(region.begin())[0] + 1})

    view.sel().clear()
    view.sel().add(view.line(region.begin()))


CodeMapManager.move_to_region = _move_to_region_full_line


# ---------------------------------------------------------------------------
# 4) Remove the "⧉" icon (goto reference) at the end of each entry, and
#    5b) reduce the indentation per level.
#    Wrap get_html() ONLY once: if you save this file several times in
#    succession (hot reload), we don't want to wrap an already-wrapped
#    version on each save (that's what probably caused the synchronisation
#    loss you saw, and why restarting ST was necessary). The "_typst_patched"
#    marker prevents this stacking.
#
#    The indentation is calculated internally by NeoCodeMap, it's not an
#    exposed setting: each entry carries an inline style='margin-left: Xrem;'
#    where X = 0.5 + level * 1.6 (see CodeMapManager.get_html/indent_css in
#    NeoCodeMap.py). Since we can't touch that formula without rewriting the
#    entire method, the generated HTML is rewritten with a regex, scaling each
#    "margin-left: Xrem;" by INDENT_SCALE.
# ---------------------------------------------------------------------------
INDENT_SCALE = 0.3  # 1.0 = indentacion original, 0.5 = mitad, etc.

_REFERENCE_LINK_RE = re.compile(
    r"<a[^>]*neo_code_map_goto_reference[^>]*>\s*⧉\s*</a>"
)
_MARGIN_LEFT_RE = re.compile(r"margin-left:\s*([\d.]+)rem;")


def _scale_margin_left(match: "re.Match[str]") -> str:
    value = float(match.group(1)) * INDENT_SCALE
    return f"margin-left: {value}rem;"


if not getattr(CodeMapManager.get_html, "_typst_patched", False):
    _original_get_html = CodeMapManager.get_html

    def _get_html_without_reference_link(self, view: sublime.View = None) -> str:
        html = _original_get_html(self, view)
        html = _REFERENCE_LINK_RE.sub("", html)
        html = _MARGIN_LEFT_RE.sub(_scale_margin_left, html)
        return html

    _get_html_without_reference_link._typst_patched = True
    CodeMapManager.get_html = _get_html_without_reference_link
0 Likes