Skip to content

feat(markdown): colour fenced code blocks - #190

Merged
matej21 merged 2 commits into
mainfrom
feat/md-code-highlight
Aug 18, 2026
Merged

feat(markdown): colour fenced code blocks#190
matej21 merged 2 commits into
mainfrom
feat/md-code-highlight

Conversation

@matej21

@matej21 matej21 commented Aug 18, 2026

Copy link
Copy Markdown
Member

Problem

Open a .md file in the viewer and the two panes disagree with each other. The source view is fully highlighted; the rendered view next to it draws every fenced code block in one flat colour, whatever the fence says the language is.

Nothing about the rendered view justified the difference — the highlighter simply lived somewhere okena-markdown could not reach.

Why a crate move was needed

The syntect setup — the shared SyntaxSet, the dark/light theme pair, the per-line span builder — lived in okena-files, and okena-views-git reached into it for the diff viewer. But okena-files already depends on okena-markdown, so the markdown renderer could not depend back on it.

The alternatives were worse: a second syntect dependency inside okena-markdown means a second SyntaxSet in memory (megabytes, for a copy of data the process already holds), and injecting a highlighter callback from the app threads a hook through every caller for one feature.

okena-highlight now sits below both. syntax.rs and markdown_highlight.rs move across unchanged, build_styled_text_with_backgrounds moves out of code_view for the same reason, and okena-files re-exports all three — so every okena_files::syntax::… and code_view::build_styled_text_with_backgrounds import elsewhere is untouched.

Name: okena-highlight, not okena-syntax, because #188 is adding an okena-syntax for something else entirely (tree-sitter facts about code structure — symbols, calls, visibility; no colours). Checked: that branch does not touch the highlighting modules, so there is nothing to share between the two and nothing to take over from it.

What the markdown side does

Node::CodeBlock carries the spans for each of its lines, filled by MarkdownDocument::highlight_code_blocks(is_dark).

That is deliberately a separate step from parsing. Colours come from the syntax theme, and the parser has no business knowing whether the app is dark or light. The file viewer calls it after parsing, and again from update_config when the theme flips — the same place the source view re-highlights. A document that never gets the call renders exactly as it does today.

Two constraints that shaped the implementation

Spans must reproduce their line verbatim. Selection offsets are character counts into the raw code, so a span set that drops or rewrites characters silently shifts every selection on the line. This is why highlight_code_block does not expand tabs, unlike the file path which always has: four spaces where a tab was would move the selection. A test pins the invariant.

An unlabelled fence keeps the document's colour. Highlighting it as plain text would not leave it alone — it would repaint it in the syntax theme's foreground, which is a different colour from the document's. So a fence with no language, or one syntect cannot place, comes back with no spans at all and renders through the old path.

The one visible change to existing behaviour: a highlighted line is drawn as a single StyledText with the selection as a background run, instead of the three plain divs a selected line used to split into. Unhighlighted lines keep the old path.

Validation

  • cargo check --workspace --all-targets, cargo clippy --workspace --all-targets, cargo fmt --all --check — clean
  • cargo testokena-markdown 13, okena-highlight 6, okena-files 63, okena-views-git 17, all passing
  • New tests: spans reproduce each line verbatim (tabs included); unknown and missing fence languages stay unhighlighted
  • Not done: no visual check in the running app. The colours and the StyledText-inside-a-styled-parent pattern are the diff viewer's, so this is low-risk, but somebody should still look at a rendered .md before merging.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AWSp87PWoqnyB6prN3U3qD

matej21 and others added 2 commits August 18, 2026 16:08
`okena-files` owned the syntect setup — the shared `SyntaxSet`, the theme
pair, the per-line span builder — and `okena-views-git` reached into it for
the diff viewer. That works while only viewers need colour, but the markdown
renderer needs it too, and `okena-files` already depends on `okena-markdown`,
so the dependency cannot run that way.

`okena-highlight` now sits below both. `syntax.rs` and `markdown_highlight.rs`
move across unchanged; `build_styled_text_with_backgrounds` moves out of
`code_view` for the same reason. `okena-files` re-exports all three, so every
`okena_files::syntax::…` and `code_view::build_styled_text_with_backgrounds`
import keeps working. Loading the `SyntaxSet` twice would have cost megabytes
for nothing, which is what a second copy in `okena-markdown` would have meant.

The name says highlighting rather than syntax because #188 is adding an
`okena-syntax` for something else — tree-sitter facts about code structure,
no colours involved.

`highlight_code_block` is the entry point a fenced block needs: it resolves a
language token rather than a file path, and loads the `SyntaxSet` itself since
the markdown renderer holds no file. It returns nothing for a fence with no
language or one syntect cannot place — plain-text highlighting would repaint
such a block in the syntax theme's foreground, and a block with nothing to
colour should keep the document's own text colour. Tabs are left alone there:
the caller maps character offsets onto the spans, so expanding a tab to four
spaces would shift every selection on the line. The file path keeps expanding
them, as it always has.

`syntax_for_lang` in `markdown_highlight` was the same mapping as the new
resolver and is now that resolver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWSp87PWoqnyB6prN3U3qD
A rendered markdown file showed every code block in one flat colour, while
the same file's source view next to it was fully highlighted. There was no
reason for the difference beyond where the highlighter lived.

`Node::CodeBlock` now carries the spans for each of its lines, filled by
`MarkdownDocument::highlight_code_blocks`. That is a separate step from
parsing rather than part of it: the colours come from the syntax theme, and
the parser has no business knowing whether the app is dark or light. The file
viewer calls it after parsing and again from `update_config` when the theme
flips — the same place the source view re-highlights.

Selection is what constrains the shape of the spans. Offsets are character
counts into the raw code, so each line's spans must reproduce that line
verbatim; a test pins it. The line is drawn as one `StyledText` with the
selection as a background run, instead of the three plain divs a selected line
used to split into. Lines with no spans keep the old path, so an unlabelled
fence still renders in the document's text colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWSp87PWoqnyB6prN3U3qD
@matej21
matej21 merged commit 1c8c174 into main Aug 18, 2026
10 checks passed
@matej21
matej21 deleted the feat/md-code-highlight branch August 18, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant