przn 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/default_theme.yml CHANGED
@@ -5,10 +5,10 @@
5
5
  # or pass `--theme path/to/your.yml` to override that.
6
6
  # All keys are optional — unspecified values fall back to these defaults.
7
7
 
8
- font:
9
- family: # body text font; terminal: OSC 66 f= (Echoes); PDF: Prawn font (auto-detected if blank)
10
- size: 18 # base font size in pt for PDF export
11
- color: # body text color (terminal: ANSI fg, PDF: Prawn fg); named ANSI or 6-digit hex
8
+ font: # body text typography (paragraphs, lists, h2–h6, code, tables, …)
9
+ family: # font family (terminal: OSC 66 f=, requires Echoes)
10
+ size: # OSC 66 scale: numeric (1–7) or named (xx-small … xxxx-large); default 2 (small)
11
+ color: # named ANSI or 6-digit hex; falls back to the terminal's default fg when unset
12
12
 
13
13
  title: # h1 typography (slide titles); each attribute is independent of `font`
14
14
  family: # font family (terminal: OSC 66 f=, requires Echoes)
@@ -18,26 +18,106 @@ title: # h1 typography (slide titles); each attribute is indepe
18
18
  bullet: # unordered-list marker; also h2–h6 heading prefix
19
19
  text: "・" # the glyph
20
20
  size: # OSC 66 scale (1–7) for the bullet; default = body text's scale
21
+ color: # named ANSI or 6-digit hex; falls back to body text color (font.color) when unset
22
+
23
+ code: # fenced code block (and inline `<code>`) typography
24
+ family: # font family (terminal: OSC 66 f=, requires Echoes); falls back to font.family
25
+ size: # OSC 66 scale: numeric (1–7) or named (xx-small … xxxx-large); default = body text's scale
26
+ color: # fg for all code text — fenced blocks and inline `<code>`; named ANSI or 6-digit hex; falls back to body fg
27
+ bg: # block background (fenced only); named ANSI or 6-digit hex; default = dim gray (ANSI 256-color 236)
28
+ highlight: # Rouge theme name for syntax highlighting (e.g. monokai, gruvbox, github.dark, base16.solarized.dark);
29
+ # unset = the built-in ANSI palette (picks up the terminal's colour theme — works on light or dark)
21
30
 
22
31
  # Default slide background, applied when a slide has no <bg .../> directive.
23
- # Echoes-only (OSC 7772). Use either `color` (solid) or `from`/`to`/`angle`
24
- # (linear gradient). Both unset = no background override.
32
+ # `color` / `from` / `to` / `angle` use Echoes' OSC 7772 (no-op on other
33
+ # terminals). `image` uses the Kitty Graphics Protocol (works on Kitty,
34
+ # Echoes, Wezterm, Ghostty — PNG only) and wins over `color` / gradient
35
+ # when both are set. All unset = no background override.
25
36
  background:
37
+ image: # background image path, relative to the deck; PNG only
26
38
  color: # solid color, e.g. "#1a1a2e"
27
39
  from: # gradient endpoint, e.g. "#1a1a2e"
28
40
  to: # gradient endpoint, e.g. "#16213e"
29
41
  angle: # gradient angle in degrees (0 = left→right, 90 = bottom→top)
30
42
  type: # gradient type; defaults to "linear"
31
43
 
32
- colors:
33
- code_bg: "313244"
34
- dim: "6c7086"
35
- inline_code: "a6e3a1"
36
-
37
- # Bottom-of-screen progress indicator (a nod to the Rabbit presentation tool).
38
- # Without this key, przn shows a simple " N / M " counter at the bottom-right.
39
- # To opt in: uncomment the block. The 🐇 rabbit anchors slide progress between
40
- # the left (current) and right (total) numbers. Set `duration:` to enable the
41
- # 🐢 turtle tracking elapsed time against the goal.
42
- # rabbit:
43
- # duration: # "30m", "1h30m", "1800s", or plain integer seconds
44
+ # Bottom-of-screen slide counter. Without `duration:`, przn shows a
45
+ # plain " N / M " counter at the bottom-right. Set `duration:` to opt
46
+ # into the 🐇 / 🐢 runner bar (a nod to the Rabbit presentation tool):
47
+ # slide-progress (🐇) and elapsed-vs-goal (🐢) anchored between the
48
+ # left (current) and right (total) numbers.
49
+ #
50
+ # `rabbit:` / `turtle:` are the raw escape sequences emitted at the
51
+ # runner positions. The defaults are Echoes-private OSC 7772 multicell
52
+ # frames with `flip=h` so the emojis face right (towards the goal at the
53
+ # right edge); on terminals that don't speak OSC 7772 (plain kitty,
54
+ # wezterm, …) the runner emojis don't render. To stay portable, override
55
+ # with a plain glyph or a standard OSC 66 multicell — at the cost of the
56
+ # flip. The renderer writes the value verbatim; whatever lands here goes
57
+ # straight to the terminal.
58
+ counter:
59
+ color: # named ANSI or 6-digit hex; default = dim
60
+ duration: # "30m", "1h30m", "1800s", or plain integer seconds; opt in to the runner bar
61
+ rabbit: "\e]7772;multicell;s=2:flip=h;🐇\a"
62
+ turtle: "\e]7772;multicell;s=2:flip=h;🐢\a"
63
+
64
+ # Slide-to-slide fade transition. Opt-in per slide via the h1 IAL —
65
+ # `# Title {transition=fade}`. The slide carrying the attribute fades
66
+ # out when leaving it and fades in when arriving at it; the two halves
67
+ # are independent (slide A → B can fade-out from A and snap into B if
68
+ # only A opts in). `duration:` controls the total ms of one half. The
69
+ # fade only paints text — Kitty-graphics images and shapes stay opaque
70
+ # in v1. Echoes-only (uses OSC 7772 cell-alpha); other terminals snap.
71
+ transition:
72
+ duration: 200 # ms per fade half — full A→B fade is twice this when both sides opt in
73
+
74
+ # Fragment animations (`<at fragment="fade-up">`, etc.). `duration:` is
75
+ # the per-fragment animation length in ms — drives the lerp from the
76
+ # fragment's initial offset position + alpha 0 to its declared position
77
+ # + alpha 1. Same OSC 7772 cell-alpha plumbing as `<action opacity>`;
78
+ # the x/y move uses the existing action interpolation so non-Echoes
79
+ # terminals still see the slide-in even when the fade portion snaps.
80
+ fragment:
81
+ duration: 300 # ms per fragment animation
82
+
83
+ # Slide layouts. Each layout names an ordered list of slots; a slot is a
84
+ # rectangular region with x / y / width / height (1-based cells, or N%
85
+ # of the terminal width/height). A slide picks a layout via an h1 IAL,
86
+ # e.g. `# Title {layout=two-column}`. The `title` slot, when present,
87
+ # is auto-filled from the h1; other slots fill in declaration order,
88
+ # advanced by a `<slot/>` (or `<slot name="..."/>`) marker.
89
+ #
90
+ # Two layouts have special semantics:
91
+ # `default` — theme-wide fallback for slides without an
92
+ # `{layout=...}` IAL. Shipped identical to
93
+ # `title-content`: centered title band across the top,
94
+ # content below. Override it in your own theme to give
95
+ # every plain slide a different layout.
96
+ # `cover` — automatic for slide 0 when no IAL is set, roughly
97
+ # emulating Keynote's "Title" slide: big centered
98
+ # heading near the middle, smaller subtitle near the
99
+ # bottom. Override / delete from your own theme to opt
100
+ # out — explicit `{layout=...}` on slide 0 still wins.
101
+ # Other named layouts are opt-in via `# Title {layout=name}`.
102
+ layouts:
103
+ default:
104
+ - {name: title, x: center, y: 3, width: 90%, height: 6}
105
+ - {name: content, x: 5, y: 10, width: 90%, height: 80%}
106
+ cover:
107
+ - {name: title, x: center, y: 35%, width: 100%, height: 25%, size: xxx-large}
108
+ - {name: subtitle, x: center, y: 80%, width: 100%, height: 15%}
109
+ title-only:
110
+ - {name: title, x: center, y: 40%, width: 90%, height: 20%}
111
+ takahashi:
112
+ - {name: title, x: center, y: center, width: 100%, height: 100%, size: xxxx-large}
113
+ title-content:
114
+ - {name: title, x: center, y: 3, width: 90%, height: 6}
115
+ - {name: content, x: 5, y: 10, width: 90%, height: 80%}
116
+ two-column:
117
+ - {name: title, x: center, y: 3, width: 90%, height: 6}
118
+ - {name: left, x: 5, y: 10, width: 45%, height: 80%}
119
+ - {name: right, x: 50%, y: 10, width: 45%, height: 80%}
120
+ photo-caption:
121
+ - {name: title, x: center, y: 3, width: 90%, height: 6}
122
+ - {name: image, x: 5, y: 10, width: 60%, height: 70%}
123
+ - {name: caption, x: 65%, y: 10, width: 30%, height: 70%}
data/exe/przn CHANGED
@@ -17,11 +17,16 @@ OptionParser.new do |opts|
17
17
  end
18
18
  }
19
19
  opts.on('-o', '--output FILE', 'Output file path for export') { |v| options[:output] = v }
20
+ opts.on('--pdf-quality LEVEL',
21
+ 'PDF size/quality preset for --export pdf: lossless|low|medium|high|max (default: medium)') { |v|
22
+ options[:pdf_quality] = v
23
+ }
20
24
  opts.on('--theme FILE', 'Theme file (YAML)') { |v| options[:theme] = v }
21
25
  opts.on('--generate-theme', 'Generate theme.yml in current directory') { options[:generate_theme] = true }
22
26
  opts.on('--present', 'Open the deck in presenter mode (auto-spawns an audience window on the secondary display)') { options[:present] = true }
23
27
  opts.on('--audience', 'Run as the audience receiver (spawned by --present; expects --socket)') { options[:audience] = true }
24
28
  opts.on('--socket PATH', 'Unix socket path used by --present/--audience to coordinate') { |v| options[:socket] = v }
29
+ opts.on('--no-watch', 'Disable hot reload (default on; auto-reloads the deck and theme.yml when they change on disk)') { options[:watch] = false }
25
30
  end.parse!
26
31
 
27
32
  if options[:generate_theme]
@@ -56,7 +61,7 @@ when 'pdf'
56
61
  require_relative '../lib/przn/screenshot_pdf_exporter'
57
62
 
58
63
  output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
59
- Przn.export_pdf(file, output, theme: theme)
64
+ Przn.export_pdf(file, output, theme: theme, quality: options[:pdf_quality])
60
65
  when 'prawn'
61
66
  require_relative '../lib/przn/prawn_pdf_exporter'
62
67
 
@@ -72,6 +77,7 @@ else
72
77
  elsif options[:present]
73
78
  Przn.present(file, theme: theme, theme_path: options[:theme]).run
74
79
  else
75
- Przn.start(file, theme: theme, start_at: start_at).run
80
+ Przn.start(file, theme: theme, theme_path: options[:theme], start_at: start_at,
81
+ watch: options.fetch(:watch, true)).run
76
82
  end
77
83
  end
@@ -21,7 +21,7 @@ module Przn
21
21
  File.unlink(path) if File.exist?(path)
22
22
  server = UNIXServer.new(path)
23
23
  client = server.accept
24
- send(client, {type: "ready"})
24
+ send_message(client, {type: "ready"})
25
25
  while (line = client.gets)
26
26
  msg = JSON.parse(line.chomp, symbolize_names: true)
27
27
  break if msg[:type] == "quit"
@@ -36,13 +36,13 @@ module Przn
36
36
  end
37
37
 
38
38
  # Presenter-side: connect to an audience socket at `path` and return a
39
- # client object that responds to `#send` and `#close`. Caller drives the
39
+ # UNIXSocket to hand to `send_message` and `#close`. Caller drives the
40
40
  # protocol from the controller.
41
41
  def connect(path)
42
42
  UNIXSocket.new(path)
43
43
  end
44
44
 
45
- def send(io, msg)
45
+ def send_message(io, msg)
46
46
  io.puts(JSON.generate(msg))
47
47
  rescue Errno::EPIPE, IOError
48
48
  # Other side hung up — caller decides whether to keep going.
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Przn
4
+ # Tokenizer wrapper around Rouge for fenced code blocks. Maps Rouge's
5
+ # token tree to either a hand-tuned ANSI-name palette (the default —
6
+ # picks up the user's terminal colour theme so code reads naturally
7
+ # against light or dark backgrounds), or to a named Rouge theme like
8
+ # `monokai` / `gruvbox` / `github.dark` whose styles return absolute
9
+ # `#RRGGBB` colours. The choice rides on `theme.code.highlight` in
10
+ # theme.yml; unset keeps the ANSI palette.
11
+ module CodeHighlighter
12
+ # Rouge is required lazily so a `require 'przn'` doesn't pull in
13
+ # the full 200-language lexer tree at startup for decks that don't
14
+ # use fenced code blocks at all.
15
+ @loaded = false
16
+
17
+ # Memoized tokenization keyed by `[code, language]`. Warmed in the
18
+ # background by `Renderer#preload` so that by the time the user
19
+ # navigates to a slide, its fenced blocks are already tokenized —
20
+ # the first visit is now as fast as the second. Cache entries are
21
+ # small (a few KB per block) and never invalidated; the keys
22
+ # change automatically when slide content changes.
23
+ @cache = {}
24
+ @cache_mutex = Mutex.new
25
+
26
+ # Memoised Rouge::Theme instances keyed by name. `Rouge::Theme.find`
27
+ # is cheap but `.new` allocates per call; one instance per theme is
28
+ # plenty since `style_for` is pure. Returns nil for unknown names —
29
+ # caller falls through to TOKEN_COLORS, matching the same
30
+ # silent-fallback philosophy as an unknown lexer.
31
+ @rouge_theme_cache = {}
32
+ @rouge_theme_mutex = Mutex.new
33
+
34
+ # Mapping from Rouge token qualnames to ANSI color names (resolved
35
+ # through Parser::NAMED_COLORS by the renderer's `color_code`).
36
+ # Lookups walk up the dotted hierarchy — `Literal.String.Single`
37
+ # falls back to `Literal.String`, then `Literal`, before giving up.
38
+ TOKEN_COLORS = {
39
+ 'Keyword' => 'cyan',
40
+ 'Keyword.Constant' => 'magenta',
41
+ 'Keyword.Declaration' => 'cyan',
42
+ 'Keyword.Namespace' => 'cyan',
43
+ 'Keyword.Pseudo' => 'cyan',
44
+ 'Keyword.Reserved' => 'cyan',
45
+ 'Keyword.Type' => 'yellow',
46
+ 'Name.Builtin' => 'cyan',
47
+ 'Name.Builtin.Pseudo' => 'magenta',
48
+ 'Name.Class' => 'yellow',
49
+ 'Name.Constant' => 'yellow',
50
+ 'Name.Decorator' => 'magenta',
51
+ 'Name.Exception' => 'yellow',
52
+ 'Name.Function' => 'green',
53
+ 'Name.Function.Magic' => 'magenta',
54
+ 'Name.Namespace' => 'yellow',
55
+ 'Name.Tag' => 'cyan',
56
+ 'Name.Variable.Class' => 'yellow',
57
+ 'Name.Variable.Global' => 'red',
58
+ 'Name.Variable.Instance' => 'red',
59
+ 'Literal.String' => 'green',
60
+ 'Literal.String.Doc' => 'green',
61
+ 'Literal.String.Symbol' => 'yellow',
62
+ 'Literal.String.Regex' => 'magenta',
63
+ 'Literal.String.Escape' => 'magenta',
64
+ 'Literal.String.Interpol' => 'yellow',
65
+ 'Literal.Number' => 'magenta',
66
+ 'Comment' => 'bright_black',
67
+ 'Operator' => 'red',
68
+ 'Operator.Word' => 'cyan',
69
+ # Diff / patch hunks, coloured the way `git diff` does: removed
70
+ # lines red, added lines green, file headers stand out.
71
+ 'Generic.Deleted' => 'red',
72
+ 'Generic.Inserted' => 'green',
73
+ 'Generic.Heading' => 'yellow',
74
+ 'Generic.Subheading' => 'cyan'
75
+ }.freeze
76
+
77
+ module_function
78
+
79
+ # Tokenize `code` with the Rouge lexer for `language` and return an
80
+ # array of `[color_or_nil, value_string]` token pairs in source
81
+ # order. `nil` color means "use the default foreground". Returns
82
+ # nil if Rouge isn't available, the language is unknown, or
83
+ # `language` is blank — in any of those cases the renderer falls
84
+ # back to its plain-text path.
85
+ #
86
+ # `theme:` picks the colour mapping. `nil` (the default) walks the
87
+ # built-in ANSI-name TOKEN_COLORS table. A string like `"monokai"` /
88
+ # `"gruvbox"` / `"github.dark"` resolves to a Rouge theme and uses
89
+ # its per-token `#RRGGBB` styles instead. Unknown theme names
90
+ # silently fall through to TOKEN_COLORS.
91
+ def highlight(code, language, theme: nil)
92
+ return nil if code.nil? || language.nil? || language.empty?
93
+ lang = language.to_s.downcase
94
+ # Theme rides in the cache key so two themes can coexist for the
95
+ # same `[code, lang]` without trampling each other.
96
+ key = [code, lang, theme]
97
+ cached = @cache_mutex.synchronize { @cache[key] }
98
+ return cached if cached
99
+
100
+ load_rouge
101
+ return nil unless @loaded
102
+ lexer = Rouge::Lexer.find(lang)
103
+ return nil unless lexer
104
+
105
+ rouge_theme = theme && resolve_rouge_theme(theme)
106
+
107
+ tokens = []
108
+ lexer.lex(code) { |tok, val| tokens << [color_for(tok, rouge_theme), val] }
109
+ @cache_mutex.synchronize { @cache[key] = tokens }
110
+ tokens
111
+ rescue StandardError
112
+ # A lexer crash shouldn't kill the slide — fall back to plain.
113
+ nil
114
+ end
115
+
116
+ # Resolve a Rouge theme name to a memoised theme instance.
117
+ # `Rouge::Theme.find` returns the theme CLASS (or nil); we `.new`
118
+ # it once and reuse. Anything Rouge doesn't recognise (typos,
119
+ # retired names, garbage) returns nil and the caller falls back
120
+ # to TOKEN_COLORS.
121
+ def resolve_rouge_theme(name)
122
+ cached = @rouge_theme_mutex.synchronize { @rouge_theme_cache[name] }
123
+ return cached if cached
124
+ return nil if @rouge_theme_mutex.synchronize { @rouge_theme_cache.key?(name) }
125
+
126
+ klass = Rouge::Theme.find(name.to_s)
127
+ instance = klass && klass.new
128
+ @rouge_theme_mutex.synchronize { @rouge_theme_cache[name] = instance }
129
+ instance
130
+ rescue StandardError
131
+ nil
132
+ end
133
+
134
+ # Resolve a token's colour. With a Rouge theme, ask the theme for
135
+ # its `style_for(token).fg` — a `#RRGGBB` string or nil. Without
136
+ # one, walk the qualname up the dotted hierarchy through
137
+ # TOKEN_COLORS: `Literal.String.Single` → `Literal.String` →
138
+ # `Literal` → nil. nil means "use the default fg."
139
+ def color_for(token, rouge_theme = nil)
140
+ if rouge_theme
141
+ style = rouge_theme.style_for(token)
142
+ return style && style.fg
143
+ end
144
+
145
+ name = token.qualname
146
+ while name && !name.empty?
147
+ return TOKEN_COLORS[name] if TOKEN_COLORS.key?(name)
148
+ i = name.rindex('.')
149
+ return nil unless i
150
+ name = name[0, i]
151
+ end
152
+ nil
153
+ end
154
+
155
+ def load_rouge
156
+ return if @loaded
157
+ begin
158
+ require 'rouge'
159
+ @loaded = true
160
+ rescue LoadError
161
+ @loaded = false
162
+ end
163
+ end
164
+ end
165
+ end