Extracted from qwan-tracker

This commit is contained in:
2026-05-12 15:58:07 +01:00
commit e06832fb13
16 changed files with 117311 additions and 0 deletions
+428
View File
@@ -0,0 +1,428 @@
defmodule Microprints.Microprint do
@moduledoc """
Pure functions for generating microprint visualizations from source files.
A microprint is a compact visual representation where each line of code
becomes a colored stripe based on syntax elements.
"""
@type segment :: %{start: non_neg_integer(), length: non_neg_integer(), color: String.t()}
@type line_info :: %{
color: String.t(),
indent: non_neg_integer(),
length: non_neg_integer(),
segments: [segment()] | nil
}
@type microprint :: %{lines: [line_info()], line_count: non_neg_integer()}
# Color palette for different syntax elements
@colors %{
# purple - def, defmodule, if, case, etc.
keyword: "#8B5CF6",
# green - strings and charlists
string: "#10B981",
# gray - comments
comment: "#6B7280",
# red - function definitions
function_def: "#EF4444",
# amber - atoms
atom: "#F59E0B",
# blue - numbers
number: "#3B82F6",
# pink - module names
module: "#EC4899",
# purple - operators
operator: "#8B5CF6",
# light gray - default
default: "#D1D5DB"
}
@elixir_keywords ~w(def defp defmodule defmacro defmacrop defguard defguardp
defstruct defexception defprotocol defimpl defdelegate defoverridable
if unless case cond with for receive try catch rescue after raise throw
import require alias use quote unquote fn do end when and or not in)
@doc """
Generates a microprint for the given file path.
Returns `{:ok, microprint}` or `{:error, reason}`.
"""
@spec generate(String.t()) :: {:ok, microprint()} | {:error, term()}
def generate(path) do
case File.read(path) do
{:ok, content} ->
lines = String.split(content, "\n")
line_infos = analyze_lines(path, lines)
{:ok, %{lines: line_infos, line_count: length(line_infos)}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Returns the color for a given syntax type.
"""
@spec color_for(atom()) :: String.t()
def color_for(type), do: Map.get(@colors, type, @colors.default)
@doc """
Returns the color legend as a list of {label, color} tuples.
"""
@spec color_legend() :: [{String.t(), String.t()}]
def color_legend do
[
{"Function", @colors.function_def},
{"Module", @colors.module},
{"Keyword", @colors.keyword},
{"String", @colors.string},
{"Comment", @colors.comment},
{"Atom", @colors.atom},
{"Number", @colors.number},
{"Other", @colors.default}
]
end
# Private functions
defp analyze_lines(path, lines) do
if elixir_file?(path) do
analyze_elixir_lines(lines)
else
analyze_generic_lines(lines)
end
end
defp analyze_elixir_lines(lines) do
lines
|> Enum.with_index(1)
|> Enum.map(fn {line, line_num} ->
segments = tokenize_elixir_line(line, line_num)
%{
color: colorize_elixir_line(line),
indent: count_indent(line),
length: content_length(line),
segments: segments
}
end)
end
defp analyze_generic_lines(lines) do
Enum.map(lines, fn line ->
%{
color: colorize_generic_line(line),
indent: count_indent(line),
length: content_length(line),
segments: nil
}
end)
end
defp content_length(line) do
# Length of line after trimming leading whitespace
line |> String.trim_leading() |> String.length()
end
defp count_indent(line) do
# Count leading spaces (treat tab as 2 spaces)
line
|> String.graphemes()
|> Enum.take_while(&(&1 in [" ", "\t"]))
|> Enum.reduce(0, fn char, acc ->
case char do
" " -> acc + 1
"\t" -> acc + 2
end
end)
end
defp elixir_file?(path) do
ext = Path.extname(path)
ext in [".ex", ".exs"]
end
defp tokenize_elixir_line(line, line_num) do
# Handle empty or whitespace-only lines
trimmed = String.trim(line)
if trimmed == "" do
nil
else
case :elixir_tokenizer.tokenize(String.to_charlist(line), line_num, []) do
{:ok, _end_line, _end_col, _warnings, tokens, _terminators} ->
build_segments(line, Enum.reverse(tokens))
{:error, _, _, _, _} ->
# Tokenization failed, fall back to nil (use single color)
nil
end
end
end
defp build_segments(line, tokens) do
line_length = String.length(line)
# Convert tokens to segments with positions
segments =
tokens
|> Enum.map(&token_to_segment/1)
|> Enum.reject(&is_nil/1)
# Fill gaps with default color
fill_gaps(segments, line_length)
end
defp token_to_segment({type, {_line, col, token_chars}, _value}) when is_list(token_chars) do
# col is 1-indexed, convert to 0-indexed
start = col - 1
length = length(token_chars)
color = token_type_to_color(type)
%{start: start, length: length, color: color}
end
defp token_to_segment({type, {_line, col, _}, value}) when is_atom(value) do
start = col - 1
length = value |> Atom.to_string() |> String.length()
color = token_type_to_color(type)
%{start: start, length: length, color: color}
end
defp token_to_segment({type, {_line, col, nil}, _value}) do
# Single-character tokens like (, ), etc.
start = col - 1
color = token_type_to_color(type)
%{start: start, length: 1, color: color}
end
defp token_to_segment({type, {_line, col, length}, _value}) when is_integer(length) do
start = col - 1
color = token_type_to_color(type)
%{start: start, length: max(1, length), color: color}
end
defp token_to_segment(_), do: nil
defp token_type_to_color(type) do
case type do
# Keywords
t when t in [:do, :end, :fn, :when, :and, :or, :not, :in] ->
@colors.keyword
# Identifiers that are keywords
:identifier ->
@colors.default
:kw_identifier ->
@colors.keyword
:paren_identifier ->
@colors.function_def
:do_identifier ->
@colors.function_def
# Atoms
:atom ->
@colors.atom
:kw_identifier_unsafe ->
@colors.atom
# Strings
:bin_string ->
@colors.string
:list_string ->
@colors.string
:bin_heredoc ->
@colors.string
:list_heredoc ->
@colors.string
:interpolation_start ->
@colors.string
:interpolation_end ->
@colors.string
# Numbers
:int ->
@colors.number
:flt ->
@colors.number
# Operators
t
when t in [
:dual_op,
:mult_op,
:power_op,
:concat_op,
:range_op,
:xor_op,
:pipe_op,
:match_op,
:assoc_op,
:arrow_op,
:rel_op,
:comp_op,
:and_op,
:or_op,
:not_op,
:capture_op,
:ternary_op,
:at_op,
:unary_op,
:type_op,
:stab_op
] ->
@colors.operator
# Module aliases
:alias ->
@colors.module
# Comments are handled separately (not tokenized)
# Punctuation - use default
_ ->
@colors.default
end
end
defp fill_gaps(segments, line_length) do
# Sort by start position
sorted = Enum.sort_by(segments, & &1.start)
# Build list with gaps filled
{filled, last_end} =
Enum.reduce(sorted, {[], 0}, fn segment, {acc, current_pos} ->
# Add gap segment if needed
acc =
if segment.start > current_pos do
gap = %{
start: current_pos,
length: segment.start - current_pos,
color: @colors.default
}
[gap | acc]
else
acc
end
segment_end = segment.start + segment.length
{[segment | acc], max(current_pos, segment_end)}
end)
# Add trailing gap if needed
filled =
if last_end < line_length do
gap = %{start: last_end, length: line_length - last_end, color: @colors.default}
[gap | filled]
else
filled
end
Enum.reverse(filled)
end
defp colorize_elixir_line(line) do
trimmed = String.trim(line)
cond do
# Empty line
trimmed == "" ->
@colors.default
# Comment line
String.starts_with?(trimmed, "#") ->
@colors.comment
# Function definition
Regex.match?(~r/^\s*(def|defp|defmacro|defmacrop|defguard|defguardp)\s+\w+/, line) ->
@colors.function_def
# Module definition
Regex.match?(~r/^\s*defmodule\s+/, line) ->
@colors.module
# String line (starts with quote or contains prominent string)
Regex.match?(~r/^\s*["']/, line) || Regex.match?(~r/~[a-zA-Z]\[|~[a-zA-Z]{/, line) ->
@colors.string
# Doc attribute
Regex.match?(~r/^\s*@(moduledoc|doc|spec|type|typep|typedoc)/, line) ->
@colors.comment
# Keyword-heavy line
keyword_line?(trimmed) ->
@colors.keyword
# Atom-heavy line
Regex.match?(~r/^\s*:[\w_]+/, line) || Regex.match?(~r/@\w+/, line) ->
@colors.atom
# Default
true ->
dominant_color(line)
end
end
defp keyword_line?(trimmed) do
first_word = trimmed |> String.split(~r/[\s(]/, parts: 2) |> List.first() || ""
first_word in @elixir_keywords
end
defp dominant_color(line) do
cond do
# Has numbers
Regex.match?(~r/\b\d+\.?\d*\b/, line) && !Regex.match?(~r/[a-zA-Z]/, line) ->
@colors.number
# Has operators
Regex.match?(~r/[|><+\-*\/=]+/, line) ->
@colors.operator
# Has function calls (word followed by parenthesis)
Regex.match?(~r/\w+\(/, line) ->
@colors.default
true ->
@colors.default
end
end
defp colorize_generic_line(line) do
trimmed = String.trim(line)
cond do
trimmed == "" ->
@colors.default
# Common comment patterns
String.starts_with?(trimmed, "//") ||
String.starts_with?(trimmed, "#") ||
String.starts_with?(trimmed, "/*") ||
String.starts_with?(trimmed, "*") ->
@colors.comment
# String-heavy line
Regex.match?(~r/["'`]/, line) ->
@colors.string
# Keywords for various languages
Regex.match?(
~r/\b(function|const|let|var|class|import|export|return|if|else|for|while)\b/,
line
) ->
@colors.keyword
true ->
@colors.default
end
end
end
+103
View File
@@ -0,0 +1,103 @@
defmodule Microprints.MicroprintCache do
@moduledoc """
GenServer that caches microprint visualizations in ETS.
Subscribes to a PubSub topic and invalidates cache entries when files
change via LiveReload (or any other file watcher).
## Configuration
The PubSub module and topic can be configured via `Application` environment:
config :microprints,
pubsub: MyApp.PubSub,
pubsub_topic: "dev_tools_files"
Defaults to `Phoenix.PubSub` and `"dev_tools_files"`.
"""
use GenServer
alias Microprints.Microprint
@table_name :microprint_cache
@live_reload_topic "dev_tools_files"
# Client API
@doc """
Starts the MicroprintCache GenServer.
## Options
* `:pubsub` - The PubSub module to use (defaults to `Phoenix.PubSub`)
* `:pubsub_topic` - The PubSub topic to subscribe to (defaults to `"dev_tools_files"`)
"""
def start_link(opts \\ []) do
pubsub = opts[:pubsub] || Application.get_env(:microprints, :pubsub, Phoenix.PubSub)
pubsub_topic = opts[:pubsub_topic] || Application.get_env(:microprints, :pubsub_topic, @live_reload_topic)
GenServer.start_link(__MODULE__, %{pubsub: pubsub, pubsub_topic: pubsub_topic}, name: __MODULE__)
end
@doc """
Returns the microprint for the given file path.
Returns cached version if available, otherwise generates and caches it.
"""
@spec get_microprint(String.t()) :: {:ok, Microprint.microprint()} | {:error, term()}
def get_microprint(path) do
absolute_path = Path.expand(path)
case :ets.lookup(@table_name, absolute_path) do
[{^absolute_path, microprint}] ->
{:ok, microprint}
[] ->
case Microprint.generate(absolute_path) do
{:ok, microprint} ->
:ets.insert(@table_name, {absolute_path, microprint})
{:ok, microprint}
error ->
error
end
end
end
@doc """
Invalidates the cache entry for the given path.
"""
@spec invalidate(String.t()) :: true
def invalidate(path) do
absolute_path = Path.expand(path)
:ets.delete(@table_name, absolute_path)
end
@doc """
Clears all cached microprints.
"""
@spec clear_all() :: true
def clear_all do
:ets.delete_all_objects(@table_name)
end
# Server callbacks
@impl true
def init(%{pubsub: pubsub, pubsub_topic: pubsub_topic}) do
table = :ets.new(@table_name, [:named_table, :public, :set, read_concurrency: true])
Phoenix.PubSub.subscribe(pubsub, pubsub_topic)
{:ok, %{table: table, pubsub_topic: pubsub_topic}}
end
@impl true
def handle_info({:phoenix_live_reload, _topic, path}, state) do
invalidate(path)
{:noreply, state}
end
def handle_info(_msg, state) do
{:noreply, state}
end
end
+234
View File
@@ -0,0 +1,234 @@
defmodule Microprints.MicroprintComponent do
@moduledoc """
Phoenix Component for rendering microprint SVG visualizations.
Renders each line of code as a colored horizontal stripe,
creating a compact visual fingerprint of the file's structure.
"""
use Phoenix.Component
alias Microprints.Microprint
# Pixels per indent level (1 space = 1 indent unit)
@indent_scale 2
# Maximum indent in pixels (cap at 40% of width)
@max_indent_ratio 0.4
@doc """
Renders a microprint visualization as an inline SVG.
## Attributes
* `:microprint` - The microprint map with `:lines` (list of line info maps)
* `:width` - SVG width in pixels (default: 200)
* `:max_height` - Maximum SVG height in pixels (default: 100)
* `:clickable` - Whether lines are clickable to highlight (default: false)
* `:file_path` - File path for click events (required if clickable)
* `:highlighted_line` - Currently highlighted line number (0-indexed)
"""
attr :microprint, :map, required: true
attr :width, :integer, default: 200
attr :max_height, :integer, default: 100
attr :clickable, :boolean, default: false
attr :file_path, :string, default: nil
attr :highlighted_line, :integer, default: nil
def microprint(assigns) do
lines = assigns.microprint.lines
line_count = length(lines)
# Each line is 1px, but cap at max_height
height = min(line_count, assigns.max_height)
# If we have more lines than max_height, we need to sample
{displayed_lines, line_mapping} =
if line_count > assigns.max_height do
{sample_lines(lines, assigns.max_height),
sample_line_mapping(line_count, assigns.max_height)}
else
{lines, Enum.to_list(0..(line_count - 1))}
end
max_indent = trunc(assigns.width * @max_indent_ratio)
# Find max line length for proportional scaling
max_length = displayed_lines |> Enum.map(& &1.length) |> Enum.max(fn -> 1 end) |> max(1)
# Calculate x offset and width for each line
rendered_lines =
displayed_lines
|> Enum.with_index()
|> Enum.map(fn {line_info, index} ->
base_x = min(line_info.indent * @indent_scale, max_indent)
available_width = assigns.width - base_x
# Map display index to actual line number
actual_line = Enum.at(line_mapping, index, index)
highlighted = assigns.highlighted_line == actual_line
# Build segments for this line
segments =
if line_info[:segments] && line_info.segments != [] do
# Multi-color: render each segment
scale = if line_info.length > 0, do: available_width / line_info.length, else: 1
line_info.segments
|> Enum.map(fn seg ->
seg_x = base_x + trunc(seg.start * scale)
seg_width = max(1, trunc(seg.length * scale))
%{
x: seg_x,
width: seg_width,
color: seg.color,
y: index,
line_number: actual_line,
highlighted: highlighted
}
end)
else
# Single color fallback
width =
if line_info.length == 0 do
2
else
max(2, trunc(line_info.length / max_length * available_width))
end
[
%{
x: base_x,
width: width,
color: line_info.color,
y: index,
line_number: actual_line,
highlighted: highlighted
}
]
end
%{y: index, line_number: actual_line, highlighted: highlighted, segments: segments}
end)
assigns =
assigns
|> assign(:height, height)
|> assign(:rendered_lines, rendered_lines)
~H"""
<svg
width={@width}
height={@height}
viewBox={"0 0 #{@width} #{@height}"}
class={"block mt-1 #{if @clickable, do: "cursor-pointer", else: ""}"}
>
<%= for line <- @rendered_lines do %>
<%= for segment <- line.segments do %>
<rect
x={segment.x}
y={segment.y}
width={segment.width}
height="1"
fill={segment.color}
opacity={if segment.highlighted, do: "1", else: "0.8"}
phx-click={if @clickable, do: "highlight_line"}
phx-value-line={if @clickable, do: segment.line_number}
phx-value-path={if @clickable, do: @file_path}
/>
<% end %>
<% end %>
<%= if @highlighted_line do %>
<rect
x="0"
y={highlight_y(@rendered_lines, @highlighted_line)}
width={@width}
height="3"
fill="none"
stroke="#000"
stroke-width="1"
pointer-events="none"
/>
<% end %>
</svg>
"""
end
defp highlight_y(rendered_lines, highlighted_line) do
case Enum.find(rendered_lines, &(&1.line_number == highlighted_line)) do
nil -> 0
line -> max(0, line.y - 1)
end
end
defp sample_line_mapping(line_count, max_count) do
step = line_count / max_count
0..(max_count - 1)
|> Enum.map(fn i -> trunc(i * step) end)
end
# Sample lines evenly when we have more lines than pixels available
defp sample_lines(lines, max_count) do
line_count = length(lines)
step = line_count / max_count
0..(max_count - 1)
|> Enum.map(fn i ->
index = trunc(i * step)
Enum.at(lines, index)
end)
end
@doc """
Renders a color legend for microprint visualizations.
Shows each syntax type with its corresponding color.
"""
def microprint_legend(assigns) do
assigns = assign(assigns, :legend, Microprint.color_legend())
~H"""
<div class="flex flex-wrap gap-3 text-xs">
<%= for {label, color} <- @legend do %>
<div class="flex items-center gap-1">
<span
class="w-3 h-3 inline-block rounded-sm"
style={"background: #{color};"}
>
</span>
<span class="text-base-content/60">{label}</span>
</div>
<% end %>
</div>
"""
end
@doc """
Renders source code with line numbers and optional highlighting.
## Attributes
* `:content` - The source code content as a string
* `:highlighted_line` - Line number to highlight (0-indexed, optional)
* `:language` - Language for syntax highlighting (e.g., "elixir", "javascript", "typescript", "markdown")
"""
attr :content, :string, required: true
attr :highlighted_line, :integer, default: nil
attr :language, :string, default: nil
def source_viewer(assigns) do
~H"""
<div
id="source-viewer"
class="source-viewer mt-2 max-h-96 overflow-auto rounded font-mono text-xs"
phx-hook="SourceViewer"
data-highlighted-line={@highlighted_line}
data-language={@language}
style="background: var(--sv-bg); color: var(--sv-text);"
>
<pre id="source-viewer-pre" phx-update="ignore" class="m-0 p-2"><code class={"language-#{@language || "plaintext"}"}><%= @content %></code></pre>
</div>
"""
end
end
+70
View File
@@ -0,0 +1,70 @@
defmodule Microprints do
@moduledoc """
Microprints — compact visual representations of source code files.
A microprint is a compact visual fingerprint where each line of code becomes
a colored stripe based on its syntax elements (function definitions, strings,
comments, etc.).
## Installation
Add to your `mix.exs`:
```elixir
def deps do
[
{:microprints, "~> 0.1", only: :dev}
]
end
```
## Usage
### Generate a microprint
Microprints.generate("path/to/file.ex")
# => {:ok, %{lines: [...], line_count: 42}}
### Get color for a syntax type
Microprints.color_for(:function_def)
# => "#EF4444"
### Get the color legend
Microprints.color_legend()
# => [{"Function", "#EF4444"}, {"Module", "#EC4899"}, ...]
## Components
The `MicroprintComponent` module provides Phoenix LiveView components for
rendering microprint visualizations as SVG.
The `MicroprintCache` module provides ETS-based caching with LiveReload
support for automatic cache invalidation.
## Examples
See the test suite for more usage examples.
"""
alias Microprints.Microprint
@doc """
Delegates to `Microprint.generate/1`.
"""
@spec generate(String.t()) :: {:ok, Microprint.microprint()} | {:error, term()}
def generate(path), do: Microprint.generate(path)
@doc """
Delegates to `Microprint.color_for/1`.
"""
@spec color_for(atom()) :: String.t()
def color_for(type), do: Microprint.color_for(type)
@doc """
Delegates to `Microprint.color_legend/0`.
"""
@spec color_legend() :: [{String.t(), String.t()}]
def color_legend(), do: Microprint.color_legend()
end