Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,6 @@ All contributions are welcome! If you're planning a significant change or you're
- ~~Add support for more style options like text color and background color~~ ✅ **Done!**
- ~~Allow choosing from any glamour themes~~ ✅ **Done!**
- ~~Support for custom JSON theme files~~ ✅ **Done!**
- Create grid-based slide layouts with transitions for each pane
- ~~Create grid-based slide layouts~~ ✅ **Done!**
- Add more transition effects
- ~~Support image rendering in terminals (e.g., via the Kitty protocol)~~ ✅ **Done!**
43 changes: 43 additions & 0 deletions docs/presentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,49 @@ title: More ways to navigate
- **Go to slide**: `g` or `:` - Jump directly to a specific slide number
- **Jump slides**: `1-9` + `h`/`←` or `l`/`→` - Jump multiple slides backward/forward (e.g., `5h` jumps 5 slides back)

----
---
title: Grid layouts
transition: swipeLeft
image_backend: docs
---

# Grid Layout

You can create grid layouts by using the `[grid]` and `[column]` tags with their
respective closing tags that start with a backslash `/`

[grid]
[column]
```go
package main

import "fmt"

func main() {
fmt.Println("Hello World")
}
```
[/column]
[column]
```c
#include <stdio.h>

int main(void) {
printf("Hello World\n");
return 0;
}
```
[/column]
[column]
```rust
fn main() {
println!("Hello World");
}
```
[/column]
[/grid]

----
---
title: Achievements
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.8.0
github.com/charmbracelet/x/exp/golden v0.0.0-20250714123521-bc8a1995e079
github.com/fsnotify/fsnotify v1.8.0
github.com/go-viper/mapstructure/v2 v2.2.1
github.com/goccy/go-yaml v1.17.1
Expand All @@ -21,7 +22,7 @@ require (

require (
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20250714123521-bc8a1995e079 // indirect
github.com/google/go-cmp v0.7.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
)

Expand Down
10 changes: 2 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
Expand All @@ -30,8 +28,6 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/golden v0.0.0-20250714123521-bc8a1995e079 h1:HDsCK4LvvlqM6Jc+skOte2IdO7GPpLmj1SGTHbZqt8I=
github.com/charmbracelet/x/exp/golden v0.0.0-20250714123521-bc8a1995e079/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
Expand All @@ -54,8 +50,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
Expand Down Expand Up @@ -89,8 +85,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/ploMP4/chafa-go v0.1.0 h1:na1uideVGSzclKdInQLkv/7v+37cUWXYqPK8Jhs9v7Q=
github.com/ploMP4/chafa-go v0.1.0/go.mod h1:IFfnozJSo6uj7UrnfsPnIWhLuOpqkIi+XNqDEg9hbAY=
github.com/ploMP4/chafa-go v0.2.0 h1:5t34lQrMa14u7jZjezG8rm3NE0f8Erw6ZFLmzqbv1Ik=
github.com/ploMP4/chafa-go v0.2.0/go.mod h1:IFfnozJSo6uj7UrnfsPnIWhLuOpqkIi+XNqDEg9hbAY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
2 changes: 1 addition & 1 deletion internal/markdown/codeblock_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (p CodeBlockParser) Trigger() []byte {
// return 0;
// }
// ```
func (p *CodeBlockParser) Parse(r *bytes.Reader) Node {
func (p *CodeBlockParser) Parse(r *bytes.Reader, _ *MarkdownParser) Node {
for range 2 {
if b, err := r.ReadByte(); err != nil || b != '`' {
slog.Warn("failed to parse codeblock node")
Expand Down
2 changes: 1 addition & 1 deletion internal/markdown/codeblock_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func TestCodeblockParser_Parse(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
p := NewCodeBlockParser()

got := p.Parse(bytes.NewReader(tt.in))
got := p.Parse(bytes.NewReader(tt.in), nil)
if Dump(got) != Dump(tt.want) {
t.Errorf("Parse() got:\n%s\nwant:\n%s", Dump(got), Dump(tt.want))
}
Expand Down
184 changes: 184 additions & 0 deletions internal/markdown/grid_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package markdown

import (
"bytes"
"io"
"log/slog"
"strings"
)

type GridParser struct{}

func NewGridParser() *GridParser {
return &GridParser{}
}

func (p GridParser) Trigger() []byte {
return []byte{'['}
}

func (p GridParser) Parse(r *bytes.Reader, m *MarkdownParser) Node {
gridNode := &GridNode{}

if ok, err := isTag(r, "grid"); !ok || err != nil {
return nil
}

if err := consumeWhitespace(r); err != nil {
slog.Warn("failed to consume whitespace", slog.Any("error", err))
return nil
}

var chunk bytes.Buffer
for {
b, err := r.ReadByte()
if err != nil {
slog.Warn("failed to advance reader", slog.Any("error", err))
return nil
}

if b == '[' {
marked, _ := r.Seek(0, io.SeekCurrent)
ok, err := isTag(r, "/grid")
if err != nil {
return nil
}
if ok {
if err := consumeWhitespace(r); err != nil {
slog.Warn("failed to consume whitespace", slog.Any("error", err))
return nil
}
break
}
_, _ = r.Seek(marked, io.SeekStart)
}

n := m.parseNode(r, b)
if n == nil {
chunk.WriteByte(b)
continue
}

if c := strings.Trim(chunk.String(), " \n"); c != "" {
child := &GlamourNode{Text: chunk.String()}
child.SetParent(gridNode)
gridNode.AddChild(child)
chunk.Reset()
}
n.SetParent(gridNode)
gridNode.AddChild(n)
}

if c := strings.Trim(chunk.String(), " \n"); c != "" {
child := &GlamourNode{Text: chunk.String()}
child.SetParent(gridNode)
gridNode.AddChild(child)
}

return gridNode
}

type GridColumnParser struct{}

func NewGridColumnParser() *GridColumnParser {
return &GridColumnParser{}
}

func (p GridColumnParser) Trigger() []byte {
return []byte{'['}
}

func (p GridColumnParser) Parse(r *bytes.Reader, m *MarkdownParser) Node {
columnNode := &GridColumnNode{}

if ok, err := isTag(r, "column"); !ok || err != nil {
return nil
}

if err := consumeWhitespace(r); err != nil {
slog.Warn("failed to consume whitespace", slog.Any("error", err))
return nil
}

var chunk bytes.Buffer
for {
b, err := r.ReadByte()
if err != nil {
slog.Warn("failed to advance reader", slog.Any("error", err))
return nil
}

if b == '[' {
marked, _ := r.Seek(0, io.SeekCurrent)
ok, err := isTag(r, "/column")
if err != nil {
return nil
}
if ok {
if err := consumeWhitespace(r); err != nil {
slog.Warn("failed to consume whitespace", slog.Any("error", err))
return nil
}
break
}
_, _ = r.Seek(marked, io.SeekStart)
}

n := m.parseNode(r, b)
if n == nil {
chunk.WriteByte(b)
continue
}

if c := strings.Trim(chunk.String(), " \n"); c != "" {
child := &GlamourNode{Text: c}
child.SetParent(columnNode)
columnNode.AddChild(child)
chunk.Reset()
}
n.SetParent(columnNode)
columnNode.AddChild(n)
}

if c := strings.Trim(chunk.String(), " \n"); c != "" {
child := &GlamourNode{Text: c}
child.SetParent(columnNode)
columnNode.AddChild(child)
}

return columnNode
}

func isTag(r *bytes.Reader, tagName string) (bool, error) {
var (
tag bytes.Buffer
b byte
err error
)

for b != ']' {
b, err = r.ReadByte()
if err != nil {
slog.Warn("failed to advance reader", slog.Any("error", err))
return false, err
}

tag.WriteByte(b)
}

return strings.Trim(tag.String(), "[]") == tagName, nil
}

func consumeWhitespace(r *bytes.Reader) error {
for {
b, err := r.ReadByte()
if err != nil {
slog.Warn("failed to advance reader", slog.Any("error", err))
return nil
}

if b != '\n' && b != ' ' {
return r.UnreadByte()
}
}
}
61 changes: 61 additions & 0 deletions internal/markdown/grid_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package markdown

import (
"bytes"
"testing"
)

func TestGridParser_Parse(t *testing.T) {
tests := []struct {
name string
in []byte
want Node
}{
{
name: "simple grid",
in: []byte("[grid][column]Some text[/column][column]More text[/column][/grid]"),
want: node(
&GridNode{},
node(&GridColumnNode{}, &GlamourNode{Text: "Some text"}),
node(&GridColumnNode{}, &GlamourNode{Text: "More text"}),
),
},
{
name: "nested grid",
in: []byte(`[grid]
[column]Some text[/column]

[column]
[grid]
[column]Nested text[/column]
[column]Nested text 2[/column]
[/grid]
[/column]
[/grid]`),
want: node(
&GridNode{},
node(&GridColumnNode{}, &GlamourNode{Text: "Some text"}),
node(&GridColumnNode{}, node(
&GridNode{},
node(&GridColumnNode{}, &GlamourNode{Text: "Nested text"}),
node(&GridColumnNode{}, &GlamourNode{Text: "Nested text 2"}),
)),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewGridParser()
m := NewMarkdownParser()

m.Register(Prioritized[Parser](NewGridParser(), 1))
m.Register(Prioritized[Parser](NewGridColumnParser(), 2))

got := p.Parse(bytes.NewReader(tt.in), m)
if Dump(got) != Dump(tt.want) {
t.Errorf("Parse() got:\n%s\nwant:\n%s", Dump(got), Dump(tt.want))
}
})
}

}
2 changes: 1 addition & 1 deletion internal/markdown/image_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func (p ImageParser) Trigger() []byte {
// Parse attempts to extract an [ImageNode] from the input, matching the markdown
// image syntax: [alt text|widthxheight](image-path) where width and height
// values are optional.
func (p ImageParser) Parse(r *bytes.Reader) Node {
func (p ImageParser) Parse(r *bytes.Reader, _ *MarkdownParser) Node {
var altText bytes.Buffer

b, err := r.ReadByte()
Expand Down
2 changes: 1 addition & 1 deletion internal/markdown/image_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func TestImageParser_Parse(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
p := NewImageParser()

got := p.Parse(bytes.NewReader(tt.in))
got := p.Parse(bytes.NewReader(tt.in), nil)
if Dump(got) != Dump(tt.want) {
t.Errorf("Parse() got:\n%s\nwant:\n%s", Dump(got), Dump(tt.want))
}
Expand Down
Loading