diff --git a/README.md b/README.md index a4f5ab9..0581c02 100644 --- a/README.md +++ b/README.md @@ -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!** diff --git a/docs/presentation.md b/docs/presentation.md index 8e415c5..9c79d46 100644 --- a/docs/presentation.md +++ b/docs/presentation.md @@ -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 + +int main(void) { + printf("Hello World\n"); + return 0; +} +``` +[/column] +[column] +```rust +fn main() { + println!("Hello World"); +} +``` +[/column] +[/grid] + ---- --- title: Achievements diff --git a/go.mod b/go.mod index bfde797..b70661f 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 ) diff --git a/go.sum b/go.sum index e1faaca..b665f59 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= @@ -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= diff --git a/internal/markdown/codeblock_parser.go b/internal/markdown/codeblock_parser.go index a96cbe4..7fbebe3 100644 --- a/internal/markdown/codeblock_parser.go +++ b/internal/markdown/codeblock_parser.go @@ -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") diff --git a/internal/markdown/codeblock_parser_test.go b/internal/markdown/codeblock_parser_test.go index 270b4bc..f911717 100644 --- a/internal/markdown/codeblock_parser_test.go +++ b/internal/markdown/codeblock_parser_test.go @@ -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)) } diff --git a/internal/markdown/grid_parser.go b/internal/markdown/grid_parser.go new file mode 100644 index 0000000..e376315 --- /dev/null +++ b/internal/markdown/grid_parser.go @@ -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() + } + } +} diff --git a/internal/markdown/grid_parser_test.go b/internal/markdown/grid_parser_test.go new file mode 100644 index 0000000..d9feeba --- /dev/null +++ b/internal/markdown/grid_parser_test.go @@ -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)) + } + }) + } + +} diff --git a/internal/markdown/image_parser.go b/internal/markdown/image_parser.go index fbeee4e..40f03cb 100644 --- a/internal/markdown/image_parser.go +++ b/internal/markdown/image_parser.go @@ -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() diff --git a/internal/markdown/image_parser_test.go b/internal/markdown/image_parser_test.go index 34fe609..b9191f3 100644 --- a/internal/markdown/image_parser_test.go +++ b/internal/markdown/image_parser_test.go @@ -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)) } diff --git a/internal/markdown/node.go b/internal/markdown/node.go index 0b11b63..5d9adac 100644 --- a/internal/markdown/node.go +++ b/internal/markdown/node.go @@ -8,82 +8,117 @@ import ( type NodeKind uint8 const ( - NodeKindGlamour NodeKind = iota + NodeKindMarkdownRoot NodeKind = iota + NodeKindGlamour NodeKindImage NodeKindCodeBlock + NodeKindGrid + NodeKindGridColumn ) type Node interface { fmt.Stringer Kind() NodeKind - Next() Node - SetNext(Node) + Children() []Node + AddChild(Node) + Parent() Node + SetParent(Node) } func Dump(n Node) string { var b strings.Builder + dumpNode(n, "", &b) + return b.String() +} + +func dumpNode(n Node, prefix string, b *strings.Builder) { + if n == nil { + return + } + + b.WriteString(strings.ReplaceAll(n.String(), "\n", "\\n")) + + for i, c := range n.Children() { + if c == nil { + continue + } - indent := 0 - for n != nil { - b.WriteString(strings.ReplaceAll(n.String(), "\n", "\\n")) + b.WriteString("\n" + prefix) - n = n.Next() - if n != nil { - b.WriteString("\n" + strings.Repeat(" ", indent) + "└-") + if i == len(n.Children())-1 { + b.WriteString("└-") + dumpNode(c, prefix+" ", b) + } else { + b.WriteString("|-") + dumpNode(c, prefix+"| ", b) } - indent++ } +} - return b.String() +type BaseNode struct { + parent Node + children []Node } -type GlamourNode struct { - Text string +func (n BaseNode) Children() []Node { + return n.children +} - next Node +func (n *BaseNode) AddChild(node Node) { + n.children = append(n.children, node) } -func (n GlamourNode) Kind() NodeKind { - return NodeKindGlamour +func (n BaseNode) Parent() Node { + return n.parent } -func (n GlamourNode) Next() Node { - return n.next +func (n *BaseNode) SetParent(node Node) { + n.parent = node } -func (n *GlamourNode) SetNext(node Node) { - n.next = node +type MarkdownRootNode struct { + BaseNode +} + +func (n MarkdownRootNode) Kind() NodeKind { + return NodeKindMarkdownRoot +} + +func (n MarkdownRootNode) String() string { + return "MarkdownRoot()" +} + +type GlamourNode struct { + BaseNode + + Text string +} + +func (n GlamourNode) Kind() NodeKind { + return NodeKindGlamour } func (n GlamourNode) String() string { - return fmt.Sprintf(`GlamourNode(Text: "%s")`, n.Text) + return fmt.Sprintf(`Glamour(Text: "%s")`, n.Text) } type ImageNode struct { + BaseNode + Label string Path string Width int Height int - - next Node } func (n ImageNode) Kind() NodeKind { return NodeKindImage } -func (n ImageNode) Next() Node { - return n.next -} - -func (n *ImageNode) SetNext(node Node) { - n.next = node -} - func (n ImageNode) String() string { return fmt.Sprintf( - `ImageNode(Label: "%s", Path: "%s", Width: %d, Height: %d)`, + `Image(Label: "%s", Path: "%s", Width: %d, Height: %d)`, n.Label, n.Path, n.Width, @@ -97,30 +132,22 @@ type CodeBlockLineRange struct { } type CodeBlockNode struct { + BaseNode + Language string Ranges []CodeBlockLineRange ShowLineNumbers bool StartLine int Code string - - next Node } func (n CodeBlockNode) Kind() NodeKind { return NodeKindCodeBlock } -func (n CodeBlockNode) Next() Node { - return n.next -} - -func (n *CodeBlockNode) SetNext(node Node) { - n.next = node -} - func (n CodeBlockNode) String() string { return fmt.Sprintf( - `CodeBlockNode(Language: %s, Ranges: %v, ShowLineNumbers: %t, StartLine: %d, Code: %s)`, + `CodeBlock(Language: %s, Ranges: %v, ShowLineNumbers: %t, StartLine: %d, Code: %s)`, n.Language, n.Ranges, n.ShowLineNumbers, @@ -128,3 +155,38 @@ func (n CodeBlockNode) String() string { n.Code, ) } + +type GridNode struct { + BaseNode + + ColumnCount int +} + +func (n GridNode) Kind() NodeKind { + return NodeKindGrid +} + +func (n *GridNode) AddChild(node Node) { + if node.Kind() != NodeKindGridColumn { + return + } + n.children = append(n.children, node) +} + +func (n GridNode) String() string { + return fmt.Sprintf(`Grid(ColumnCount: %d)`, n.ColumnCount) +} + +type GridColumnNode struct { + BaseNode + + Span int +} + +func (n GridColumnNode) Kind() NodeKind { + return NodeKindGridColumn +} + +func (n GridColumnNode) String() string { + return fmt.Sprintf(`GridColumn(Span: %d)`, n.Span) +} diff --git a/internal/markdown/node_test.go b/internal/markdown/node_test.go new file mode 100644 index 0000000..90ac091 --- /dev/null +++ b/internal/markdown/node_test.go @@ -0,0 +1,70 @@ +package markdown + +import "testing" + +func TestDump(t *testing.T) { + tests := []struct { + name string + root Node + want string + }{ + { + name: "simple", + root: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "test"}, + &ImageNode{ + Label: "img", + Path: "./image.png", + Width: 100, + Height: 50, + }, + &GlamourNode{Text: "test2"}, + ), + want: `MarkdownRoot() +|-Glamour(Text: "test") +|-Image(Label: "img", Path: "./image.png", Width: 100, Height: 50) +└-Glamour(Text: "test2")`, + }, + { + name: "grid", + root: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "test"}, + node( + &GridNode{ColumnCount: 3}, + node( + &GridColumnNode{Span: 1}, + &GlamourNode{Text: "Col1"}, + &GlamourNode{Text: "Col1a"}, + ), + node( + &GridColumnNode{Span: 2}, + &GlamourNode{Text: "Col2"}, + &GlamourNode{Text: "Col2a"}, + ), + ), + &GlamourNode{Text: "test2"}, + ), + want: `MarkdownRoot() +|-Glamour(Text: "test") +|-Grid(ColumnCount: 3) +| |-GridColumn(Span: 1) +| | |-Glamour(Text: "Col1") +| | └-Glamour(Text: "Col1a") +| └-GridColumn(Span: 2) +| |-Glamour(Text: "Col2") +| └-Glamour(Text: "Col2a") +└-Glamour(Text: "test2")`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Dump(tt.root) + if got != tt.want { + t.Errorf("Dump() got:\n%s\nwant:\n%s", got, tt.want) + } + }) + } + +} diff --git a/internal/markdown/parser.go b/internal/markdown/parser.go index ad6d322..2096fe9 100644 --- a/internal/markdown/parser.go +++ b/internal/markdown/parser.go @@ -18,7 +18,7 @@ type Parser interface { // Parse attempts to parse a [Node] from the reader. It should return nil if // parsing fails, allowing [MarkdownParser.Parse] to try the next parser. // Note: The trigger byte has already been consumed before calling Parse. - Parse(r *bytes.Reader) Node + Parse(r *bytes.Reader, m *MarkdownParser) Node } // MarkdownParser is an extensible parser that converts a markdown string into @@ -45,20 +45,16 @@ func (p *MarkdownParser) Register(parser PrioritizedValue[Parser]) { } } -// Parse processes the input byte-by-byte and constructs a [Node] list, -// starting from a root [Node]. For each byte, it checks for registered Parsers +// Parse processes the input byte-by-byte and constructs a [Node] tree, +// starting from a [MarkdownRootNode]. For each byte, it checks for registered Parsers // triggered by that byte and attempts to parse using them. If no parser succeeds, // the byte is added to a buffer. Buffered text is eventually wrapped in a // [GlamourNode], the default [Node] type. func (p MarkdownParser) Parse(in []byte) Node { r := bytes.NewReader(in) + root := &MarkdownRootNode{} - var ( - root Node - curr Node - chunk bytes.Buffer - ) - + var chunk bytes.Buffer for { b, err := r.ReadByte() if err != nil { @@ -68,55 +64,45 @@ func (p MarkdownParser) Parse(in []byte) Node { slog.Error("failed advancing reader", slog.Any("error", err)) } - parsers, ok := p.registry[b] - if !ok { + n := p.parseNode(r, b) + if n == nil { chunk.WriteByte(b) continue } - parsed := false - for _, parser := range parsers { - markedPos, _ := r.Seek(0, io.SeekCurrent) - n := parser.Value.Parse(r) - if n == nil { - _, _ = r.Seek(markedPos, io.SeekStart) - continue - } - - if chunk.String() != "" { - if root == nil { - root = &GlamourNode{Text: chunk.String()} - curr = root - } else { - curr.SetNext(&GlamourNode{Text: chunk.String()}) - curr = curr.Next() - } - } - + if chunk.String() != "" { + child := &GlamourNode{Text: chunk.String()} + child.SetParent(root) + root.AddChild(child) chunk.Reset() - - if root == nil { - root = n - curr = root - } else { - curr.SetNext(n) - curr = curr.Next() - } - - parsed = true - } - if !parsed { - chunk.WriteByte(b) } + n.SetParent(root) + root.AddChild(n) } if chunk.String() != "" { - if root == nil { - root = &GlamourNode{Text: chunk.String()} - } else { - curr.SetNext(&GlamourNode{Text: chunk.String()}) - } + child := &GlamourNode{Text: chunk.String()} + child.SetParent(root) + root.AddChild(child) } return root } + +func (p MarkdownParser) parseNode(r *bytes.Reader, b byte) Node { + parsers, ok := p.registry[b] + if !ok { + return nil + } + + for _, parser := range parsers { + markedPos, _ := r.Seek(0, io.SeekCurrent) + n := parser.Value.Parse(r, &p) + if n != nil { + return n + } + _, _ = r.Seek(markedPos, io.SeekStart) + } + + return nil +} diff --git a/internal/markdown/parser_test.go b/internal/markdown/parser_test.go index e58cf66..912c888 100644 --- a/internal/markdown/parser_test.go +++ b/internal/markdown/parser_test.go @@ -11,39 +11,62 @@ func TestMarkdownParser_Parse(t *testing.T) { { name: "Basic single node", in: []byte("# This is a string"), - want: &GlamourNode{Text: "# This is a string"}, + want: node(&MarkdownRootNode{}, &GlamourNode{Text: "# This is a string"}), }, { name: "Text followed by image", in: []byte("# This is a string\n![alt text](./image.png)"), - want: &GlamourNode{ - Text: "# This is a string\n", - next: &ImageNode{ - Label: "alt text", - Path: "./image.png", - }, - }, + want: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "# This is a string\n"}, + &ImageNode{Label: "alt text", Path: "./image.png"}, + ), }, { name: "Image in between text", in: []byte("# This is a string\n![alt text](./image.png)\n> Some other string"), - want: &GlamourNode{ - Text: "# This is a string\n", - next: &ImageNode{ - Label: "alt text", - Path: "./image.png", - next: &GlamourNode{ - Text: "\n> Some other string", - }, - }, - }, + want: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "# This is a string\n"}, + &ImageNode{Label: "alt text", Path: "./image.png"}, + &GlamourNode{Text: "\n> Some other string"}, + ), }, { name: "Try parse invalid image node", in: []byte("# This is a string\n![not_an_image\n> Some other string"), - want: &GlamourNode{ - Text: "# This is a string\n![not_an_image\n> Some other string", - }, + want: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "# This is a string\n![not_an_image\n> Some other string"}, + ), + }, + { + name: "Valid grid layout", + in: []byte(`# This is a string +[grid] +[column]# Some other text[/column] + +[column] +![image](./image.png) +[/column] + +[column] +# Some other column stuff +[/column] +[/grid] + +> And another string`), + want: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "# This is a string\n"}, + node( + &GridNode{}, + node(&GridColumnNode{}, &GlamourNode{Text: "# Some other text"}), + node(&GridColumnNode{}, &ImageNode{Label: "image", Path: "./image.png"}), + node(&GridColumnNode{}, &GlamourNode{Text: "# Some other column stuff"}), + ), + &GlamourNode{Text: "> And another string"}, + ), }, } for _, tt := range tests { @@ -51,6 +74,8 @@ func TestMarkdownParser_Parse(t *testing.T) { p := NewMarkdownParser() p.Register(Prioritized[Parser](NewImageParser(), 1)) p.Register(Prioritized[Parser](NewCodeBlockParser(), 1)) + p.Register(Prioritized[Parser](NewGridParser(), 1)) + p.Register(Prioritized[Parser](NewGridColumnParser(), 2)) got := p.Parse(tt.in) if Dump(got) != Dump(tt.want) { diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 98d9e03..2512b87 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + "github.com/muesli/reflow/wrap" "github.com/museslabs/kyma/internal/config" "github.com/museslabs/kyma/internal/img" @@ -38,6 +39,8 @@ func NewRenderer(theme string, options ...RendererOption) (*Renderer, error) { p := NewMarkdownParser() p.Register(Prioritized[Parser](NewImageParser(), 1)) p.Register(Prioritized[Parser](NewCodeBlockParser(), 1)) + p.Register(Prioritized[Parser](NewGridParser(), 1)) + p.Register(Prioritized[Parser](NewGridColumnParser(), 2)) r := &Renderer{ tr: tr, @@ -55,11 +58,11 @@ func NewRenderer(theme string, options ...RendererOption) (*Renderer, error) { return r, nil } -func (r *Renderer) Render(in string, animating bool) (string, error) { - return r.RenderBytes([]byte(in), animating) +func (r *Renderer) Render(in string, animating bool, width, height int) (string, error) { + return r.RenderBytes([]byte(in), animating, width, height) } -func (r *Renderer) RenderBytes(in []byte, animating bool) (string, error) { +func (r *Renderer) RenderBytes(in []byte, animating bool, width, height int) (string, error) { var b strings.Builder // Clear kitty images @@ -67,75 +70,129 @@ func (r *Renderer) RenderBytes(in []byte, animating bool) (string, error) { b.WriteString("\x1b_Ga=d\x1b\\") } - for n := r.parser.Parse(in); n != nil; n = n.Next() { - switch n.Kind() { - case NodeKindGlamour: - n := n.(*GlamourNode) - out, err := r.tr.Render(n.Text) - if err != nil { - return "", err - } - b.WriteString(out) + if err := r.renderNode(r.parser.Parse(in), animating, width, height, &b); err != nil { + return "", err + } - case NodeKindImage: - n := n.(*ImageNode) + return b.String(), nil +} - limg, err := r.options.imgBackend.Render(n.Path, n.Width, n.Height, true) - if err != nil { - b.WriteString(fmt.Sprintf("[Error rendering image: %s]", n.Label)) - continue - } +func (r *Renderer) renderNode(n Node, animating bool, width, height int, b *strings.Builder) error { + if n == nil { + return nil + } - if r.options.imgBackend.SymbolsOnly() { - b.WriteString(limg) - continue - } + switch n.Kind() { + case NodeKindMarkdownRoot: + break + + case NodeKindGlamour: + n := n.(*GlamourNode) + out, err := r.tr.Render(n.Text) + if err != nil { + return err + } + b.WriteString(out) - himg, err := r.options.imgBackend.Render(n.Path, n.Width, n.Height, false) - if err != nil { - b.WriteString(fmt.Sprintf("[Error rendering image: %s]", n.Label)) - continue + case NodeKindImage: + n := n.(*ImageNode) + + limg, err := r.options.imgBackend.Render(n.Path, n.Width, n.Height, true) + if err != nil { + fmt.Fprintf(b, "[Error rendering image: %s]", n.Label) + break + } + + if r.options.imgBackend.SymbolsOnly() { + b.WriteString(limg) + break + } + + himg, err := r.options.imgBackend.Render(n.Path, n.Width, n.Height, false) + if err != nil { + fmt.Fprintf(b, "[Error rendering image: %s]", n.Label) + break + } + + if !animating { + b.WriteString(ansi.SaveCursor) + b.WriteString(himg) + b.WriteString(ansi.RestoreCursor) + b.WriteString(limg) + } else { + b.WriteString(limg) + } + + case NodeKindCodeBlock: + n := n.(*CodeBlockNode) + + lines := strings.Split(n.Code, "\n") + + var renderedContent string + if n.Language != "" { + lexer := lexers.Get(n.Language) + if lexer == nil { + lexer = lexers.Fallback } + lexer = chroma.Coalesce(lexer) + style := config.GetChromaStyle(r.options.theme) - if !animating { - b.WriteString(ansi.SaveCursor) - b.WriteString(limg) - b.WriteString(ansi.RestoreCursor) - b.WriteString(himg) - } else { - b.WriteString(limg) + renderedContent = r.renderHighlightedCode(n.Code, lines, n, lexer, style) + } else { + renderedContent = r.renderPlainCode(lines, n) + } + + // Apply consistent styling + codeStyle := lipgloss.NewStyle().Width(78) + + b.WriteString(codeStyle.Render(renderedContent)) + + case NodeKindGrid: + var ( + gridBuilder strings.Builder + parts []string + ) + + for _, c := range n.Children() { + if err := r.renderNode(c, animating, width, height, &gridBuilder); err != nil { + return err } + parts = append(parts, gridBuilder.String()) + gridBuilder.Reset() + } - case NodeKindCodeBlock: - n := n.(*CodeBlockNode) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, parts...)) + return nil - lines := strings.Split(n.Code, "\n") + case NodeKindGridColumn: + var columnBuilder strings.Builder - var renderedContent string - if n.Language != "" { - lexer := lexers.Get(n.Language) - if lexer == nil { - lexer = lexers.Fallback - } - lexer = chroma.Coalesce(lexer) - style := config.GetChromaStyle(r.options.theme) + columnWidth := (width / len(n.Parent().Children())) - 1 + for _, c := range n.Children() { + if err := r.renderNode(c, animating, width, height, &columnBuilder); err != nil { + return err + } - renderedContent = r.renderHighlightedCode(n.Code, lines, n, lexer, style) + if c.Kind() == NodeKindImage { + b.WriteString(columnBuilder.String()) } else { - renderedContent = r.renderPlainCode(lines, n) + b.WriteString(wrap.String(columnBuilder.String(), columnWidth)) } + columnBuilder.Reset() + } + return nil - // Apply consistent styling - codeStyle := lipgloss.NewStyle().Width(78) - - b.WriteString(codeStyle.Render(renderedContent)) + default: + return fmt.Errorf("invalid node kind: %d", n.Kind()) + } - default: - return "", fmt.Errorf("invalid node kind: %d", n.Kind()) + for _, c := range n.Children() { + if err := r.renderNode(c, animating, width, height, b); err != nil { + return err } } - return b.String(), nil + return nil } func WithImageBackend(backend string) RendererOption { diff --git a/internal/markdown/renderer_test.go b/internal/markdown/renderer_test.go index b483b27..93a7012 100644 --- a/internal/markdown/renderer_test.go +++ b/internal/markdown/renderer_test.go @@ -37,7 +37,7 @@ _carduus_ in Carthage and Cordoba. --Samuel Taylor Coleridge, [The Rime of the Ancient Mariner][rime] [rime]: https://poetryfoundation.org/poems/43997/ - `, false) + `, false, 0, 0) if gotErr != nil { t.Errorf("Render() failed: %v", gotErr) } @@ -60,7 +60,7 @@ func main() { } ` - got, gotErr := r.Render(fmt.Sprintf("# Slide\n```go{3,6}\n%s\n```", codeBlock), false) + got, gotErr := r.Render(fmt.Sprintf("# Slide\n```go{3,6}\n%s\n```", codeBlock), false, 0, 0) if gotErr != nil { t.Errorf("Render() failed: %v", gotErr) } @@ -84,7 +84,7 @@ func main() { got, gotErr := r.Render( fmt.Sprintf("# Slide\n```go{3,6} --numbered\n%s\n```\n", codeBlock), - false, + false, 0, 0, ) if gotErr != nil { t.Errorf("Render() failed: %v", gotErr) @@ -111,7 +111,7 @@ func TestRenderer_RenderCodeBlockWithStartFromLine(t *testing.T) { got, gotErr := r.Render( fmt.Sprintf("# Slide\n```go{2-8} --numbered --start-at-line 10\n%s\n```\n", codeBlock), - false, + false, 0, 0, ) if gotErr != nil { t.Errorf("Render() failed: %v", gotErr) @@ -119,3 +119,43 @@ func TestRenderer_RenderCodeBlockWithStartFromLine(t *testing.T) { golden.RequireEqual(t, got) } + +func TestRenderer_GridLayout(t *testing.T) { + r, err := NewRenderer("dark") + if err != nil { + t.Fatalf("could not construct receiver type: %v", err) + } + + got, gotErr := r.Render( + ` +Heading +======= + +[grid] +[column] +## Grid column 1 data +[/column] + +[column] +## Grid column 2 + +> Lorem ipsum dolor sit amet, consectetur adipiscing elit. +[/column] + +[column] +## Grid column 3 + +- item 1 +- item 2 +- item 3 +[/column] +[/grid] + +[Epilogue](https://kyma.ink) + `, false, 0, 0) + if gotErr != nil { + t.Errorf("Render() failed: %v", gotErr) + } + + golden.RequireEqual(t, got) +} diff --git a/internal/markdown/testdata/TestRenderer_GridLayout.golden b/internal/markdown/testdata/TestRenderer_GridLayout.golden new file mode 100644 index 0000000..7beaa06 --- /dev/null +++ b/internal/markdown/testdata/TestRenderer_GridLayout.golden @@ -0,0 +1,13 @@ +_Ga=d\ +  Heading                                                                         + + + ## Grid column 1 data                                                            ## Grid column 2                                                                 ## Grid column 3                                                                 +                                                                                                                                                                    +  │ Lorem ipsum dolor sit amet, consectetur adipiscing elit.                       • item 1                                                                         +  • item 2                                                                         +  • item 3                                                                         + + + Epilogue https://kyma.ink                                                        + diff --git a/internal/markdown/util.go b/internal/markdown/util.go index 889c3c9..8fd7171 100644 --- a/internal/markdown/util.go +++ b/internal/markdown/util.go @@ -24,3 +24,11 @@ func (s PrioritizedSlice[T]) Sort() { func Prioritized[T any](v T, priority int) PrioritizedValue[T] { return PrioritizedValue[T]{v, priority} } + +func node(parent Node, children ...Node) Node { + for _, c := range children { + c.SetParent(parent) + parent.AddChild(c) + } + return parent +} diff --git a/internal/tui/slide.go b/internal/tui/slide.go index 6fc0e74..be568e5 100644 --- a/internal/tui/slide.go +++ b/internal/tui/slide.go @@ -56,12 +56,13 @@ func (s *Slide) Update() (*Slide, tea.Cmd) { return s, tea.Batch(cmd) } -func (s *Slide) View(animating bool) string { +func (s *Slide) View(animating bool, width, height int) string { var b strings.Builder out, _ := s.renderer.Render( s.Data, (s.ActiveTransition != nil && s.ActiveTransition.Animating()) || animating, + width, height, ) if s.ActiveTransition != nil && s.ActiveTransition.Animating() { @@ -70,11 +71,17 @@ func (s *Slide) View(animating bool) string { if s.Next == nil { panic("backwards transition at the last slide") } else { - b.WriteString(s.ActiveTransition.View(s.Next.View(true), s.Style.LipGlossStyle.Render(out))) + b.WriteString(s.ActiveTransition.View( + s.Next.View(true, width, height), + s.Style.LipGlossStyle.Render(out), + )) } } else { if s.Prev != nil { - b.WriteString(s.ActiveTransition.View(s.Prev.View(true), s.Style.LipGlossStyle.Render(out))) + b.WriteString(s.ActiveTransition.View( + s.Prev.View(true, width, height), + s.Style.LipGlossStyle.Render(out), + )) } else { b.WriteString(s.Style.LipGlossStyle.Render(out)) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 51790b4..d739102 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -346,6 +346,8 @@ func (m model) View() string { lipgloss.Center, m.slide.View( (m.slide.ActiveTransition != nil && m.slide.ActiveTransition.Animating()) || hasOverlay, + m.width, + m.height, ), )