feat(markdown): colour fenced code blocks - #190
Merged
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Open a
.mdfile 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-markdowncould 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 inokena-files, andokena-views-gitreached into it for the diff viewer. Butokena-filesalready depends onokena-markdown, so the markdown renderer could not depend back on it.The alternatives were worse: a second syntect dependency inside
okena-markdownmeans a secondSyntaxSetin 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-highlightnow sits below both.syntax.rsandmarkdown_highlight.rsmove across unchanged,build_styled_text_with_backgroundsmoves out ofcode_viewfor the same reason, andokena-filesre-exports all three — so everyokena_files::syntax::…andcode_view::build_styled_text_with_backgroundsimport elsewhere is untouched.Name:
okena-highlight, notokena-syntax, because #188 is adding anokena-syntaxfor 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::CodeBlockcarries the spans for each of its lines, filled byMarkdownDocument::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_configwhen 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_blockdoes 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
StyledTextwith 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— cleancargo test—okena-markdown13,okena-highlight6,okena-files63,okena-views-git17, all passingStyledText-inside-a-styled-parent pattern are the diff viewer's, so this is low-risk, but somebody should still look at a rendered.mdbefore merging.🤖 Generated with Claude Code
https://claude.ai/code/session_01AWSp87PWoqnyB6prN3U3qD