diff options
Diffstat (limited to 'content/blog/wsdiff-static-html-diffs/index.rst')
-rw-r--r-- | content/blog/wsdiff-static-html-diffs/index.rst | 127 |
1 files changed, 127 insertions, 0 deletions
diff --git a/content/blog/wsdiff-static-html-diffs/index.rst b/content/blog/wsdiff-static-html-diffs/index.rst new file mode 100644 index 0000000..51f9175 --- /dev/null +++ b/content/blog/wsdiff-static-html-diffs/index.rst @@ -0,0 +1,127 @@ +--- +title: "wsdiff: Responsive diffs in plain HTML" +date: 2025-07-25T23:42:00+01:00 +summary: > + There's many tools that render diffs on the web, but almost none that work well on small screens such as phones. I + fixed this by publishing wsdiff, a diffing tool written in Python that produces diffs as beautiful, responsive, + static, self-contained HTML pages. wsdiffs wrap text to fit the window, and dynamically switch between unified and + split diffs based on screen size using only CSS. +--- + +Demo +---- + +First off, have a demo. Because of the width of this page, the output will show an unified diff. To try out the split +diff layout, make sure your browser window is wide enough and open the demo in a separate tab using `this link +</wsdiff-example.html>`__. + +wsdiff supports dark mode, try it out by toggling dark mode in your operating system! + +.. raw:: html + + <iframe src="/wsdiff-example.html" style="width: 100%; height: 30em; border: 1px #d0d0d0 solid" id="wsdiff example diff"></iframe> + +Core Features +------------- + +There's many tools that render diffs on the web, but almost none that work well on small screens such as phones. I fixed +this by publishing `wsdiff <https://pypi.org/project/wsdiff/>`__, a diffing tool written in Python that produces diffs +as beautiful, responsive, static, self-contained HTML pages. wsdiffs wrap text to fit the window, and dynamically switch +between unified and split diffs based on screen size using only CSS. + +Responsive Line Wrapping +........................ + +The first challenge I solved was wrapping source code lines to match the available screen space. Other tools often just +show horizontal scroll bars, which is an okay workaround when you're mostly working with hard-wrapped source code on a +laptop or desktop screen, but which results in catastrophic UX on any phone. + +I solved line breaking with a combination of CSS-controlled, web-standard word breaking rules: ``overflow-wrap: +anywhere`` for source code (`MDN link <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-wrap>`__) and +``white-space: pre-wrap`` to preserve whitespace accurately (`MDN link +<https://developer.mozilla.org/en-US/docs/Web/CSS/white-space>`__). To make both sides of the split diff align, and to +align line numbers with wrapped source code lines, the diff is laid out using a `CSS grid layout`_. In side-by-side +view, the layout has four columns: two for line numbers and two for the content. In unified view, the left ("old") +content column is dropped, and the deleted or modified lines that are highlighted in it in side-by-side view are slotted +into the remaining right column. + +When soft-wrapping source code, text editors will often display a little curved arrow marker to indicate that a line was +soft-wrapped, and that there is not actually a newline character in the file at that location. wsdiff solves this +using the same technique I used for the soft-wrapping code blocks in this blog, described `here <{{<ref +"blog/css-only-code-blocks/index.rst">}}>`__. It inserts a string of ``"\a↳\a↳\a↳\a↳\a↳..."`` into the line number +element's ``::after`` pseudo-element. This string evaluates to a sequence of unicode arrows separated by line breaks, +and starting with an empty line. The ``::after`` pseudo-element is positioned using ``position: absolute``, and the +parent ``<span class="lineno">`` has ``position: relative`` set. This way, the arrow pseudo-element gets placed on top +of the lineno span without affecting the layout at all. By setting ``overflow: clip`` on the parent ``<span +class="lineno">``, the arrow pseudo-element gets cut off vertically wherever the parent line number element naturally +ends. Since both the line and the line number element share a grid row, the line number element always matches the +height of the soft-wrapped line. + +Responsive Split/Unified Layout Selection +......................................... + +To dynamically change between unified and side-by-side views, wsdiff uses a web-standard `Media Query`_. By default, the +page is laid out for side-by-side view. In the HTML source, the diff is listed as it is displayed in side-by-side view, +with the old and new lines along with their line numbers interleaved. + +The magic happens when the media query gets triggered by a narrow screen width. The media query re-adjusts the layout in +four core steps: + + 1. All unchanged lines in the left (old) column are hidden. + 2. The left content column of the grid layout is hidden, so that now there are three columns: old line number, new line + number, and unified content. + 3. All deleted or changed lines from the left (old) column are re-located to the right column. They naturally slot in + in the right spot because they already appear in the right order in the HTML source. + 4. By slotting in the old lines in the right column, we have created gaps in the line number columns. Every deleted + line has an empty cell in the new line number column, and every inserted line has one in the old line number column. + The CSS adjusts the layout of these empty cells such that the border lines align nicely, and it overrides the + newline markers so that they only show in the right (new) line number column, not both. + +Since this is all CSS, it happens automatically and near-instantly. Since it is using only web standard features, it +works across browsers and platforms. + +Unchanged Line Folding in CSS +............................. + +When showing the diff of a large file, it is usually best to hide large runs of unchanged lines. wsdiff does this +similar to code folding in text editors. When a long run of unchanged lines is detected, a marker is placed spanning the +diff. This marker contains a checkbox that can be toggled to hide the unchanged lines. This feature is done completely +in CSS using a ``:has(input[type="checkbox"]:checked)`` selector. + +The actual mechanics are quite simple. To cleanly hide the lines, they must be placed in a container ``<div>``. That div +has a CSS subgrid layout using ``display: grid; grid-template-columns: subgrid;``, meaning that its contents align to +the surrounding diff grid. + +Dark Mode +......... + +Integrating a website with the OS-level dark mode is surprisingly easy. All you need is a `Media Query`_ that selects +for ``@media (prefers-color-scheme: dark)`` and you're good. wsdiff uses named colors using `CSS Custom Properties`_, so +the actual dark mode media query only needs to override these color properties, and the rest of the CSS will be +re-computed automatically. + +Limitations: Text selection +........................... + +A limitation in having a combined, single HTML source for both side-by-side and unified diffs is that text selection +only works naturally in either mode. You can't make text selection work in both simultaneously without re-sorting the +lines in the HTML source, since there is no way to override the text selection order from pure CSS. In wsdiff, I worked +around this issue by just disabling text selection on the unchanged lines in the left (old) column, so selecting text in +the right column copies the unified diff as one would expect. + +Try it yourself! +---------------- + +You can find the demo from above at `this link </wsdiff-example.html>`__. + +You can install wsdiff yourself `from PyPI <https://pypi.org/project/wsdiff/>`__: + +.. code:: sh + + $ pip install -U wsdiff + Successfully installed wsdiff-0.3.1 + $ wsdiff old.py new.py -o diff.html + +.. _`CSS grid layout`: https://css-tricks.com/snippets/css/complete-guide-grid/ +.. _`Media Query`: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries +.. _`CSS Custom Properties`: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties |