mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
initial
This commit is contained in:
commit
4b0ea68d7a
28 changed files with 2229 additions and 0 deletions
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
debug.log
|
||||||
|
|
||||||
|
# Binary output directory
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Local environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
.termai
|
59
cmd/termai/main.go
Normal file
59
cmd/termai/main.go
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/logging"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = logging.Get()
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.Info("Starting termai...")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
app := tea.NewProgram(
|
||||||
|
tui.New(),
|
||||||
|
tea.WithAltScreen(),
|
||||||
|
)
|
||||||
|
log.Info("Setting up subscriptions...")
|
||||||
|
ch, unsub := setupSubscriptions(ctx)
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for msg := range ch {
|
||||||
|
app.Send(msg)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if _, err := app.Run(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSubscriptions(ctx context.Context) (chan tea.Msg, func()) {
|
||||||
|
ch := make(chan tea.Msg)
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
{
|
||||||
|
sub := log.Subscribe(ctx)
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
for ev := range sub {
|
||||||
|
ch <- ev
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// cleanup function to be invoked when program is terminated.
|
||||||
|
return ch, func() {
|
||||||
|
cancel()
|
||||||
|
// Wait for relays to finish before closing channel, to avoid sends
|
||||||
|
// to a closed channel, which would result in a panic.
|
||||||
|
wg.Wait()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
43
go.mod
Normal file
43
go.mod
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
module github.com/kujtimiihoxha/termai
|
||||||
|
|
||||||
|
go 1.23.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/catppuccin/go v0.3.0
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.4
|
||||||
|
github.com/charmbracelet/glamour v0.9.1
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||||
|
golang.org/x/net v0.33.0 // indirect
|
||||||
|
golang.org/x/sync v0.12.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
)
|
84
go.sum
Normal file
84
go.sum
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||||
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
|
||||||
|
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||||
|
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-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||||
|
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=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
|
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||||
|
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
12
internal/logging/default.go
Normal file
12
internal/logging/default.go
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
package logging
|
||||||
|
|
||||||
|
var defaultLogger Interface
|
||||||
|
|
||||||
|
func Get() Interface {
|
||||||
|
if defaultLogger == nil {
|
||||||
|
defaultLogger = NewLogger(Options{
|
||||||
|
Level: "info",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return defaultLogger
|
||||||
|
}
|
100
internal/logging/logger.go
Normal file
100
internal/logging/logger.go
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/pubsub"
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultLevel = "info"
|
||||||
|
|
||||||
|
var levels = map[string]slog.Level{
|
||||||
|
"debug": slog.LevelDebug,
|
||||||
|
DefaultLevel: slog.LevelInfo,
|
||||||
|
"warn": slog.LevelWarn,
|
||||||
|
"error": slog.LevelError,
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidLevels() []string {
|
||||||
|
keys := maps.Keys(levels)
|
||||||
|
slices.SortFunc(keys, func(a, b string) int {
|
||||||
|
if a == DefaultLevel {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if b == DefaultLevel {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if a < b {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogger(opts Options) *Logger {
|
||||||
|
logger := &Logger{}
|
||||||
|
broker := pubsub.NewBroker[Message]()
|
||||||
|
writer := &writer{
|
||||||
|
messages: []Message{},
|
||||||
|
Broker: broker,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewTextHandler(
|
||||||
|
io.MultiWriter(append(opts.AdditionalWriters, writer)...),
|
||||||
|
&slog.HandlerOptions{
|
||||||
|
Level: slog.Level(levels[opts.Level]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.logger = slog.New(handler)
|
||||||
|
logger.writer = writer
|
||||||
|
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Level string
|
||||||
|
AdditionalWriters []io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
writer *writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Debug(msg string, args ...any) {
|
||||||
|
l.logger.Debug(msg, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Info(msg string, args ...any) {
|
||||||
|
l.logger.Info(msg, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Warn(msg string, args ...any) {
|
||||||
|
l.logger.Warn(msg, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Error(msg string, args ...any) {
|
||||||
|
l.logger.Error(msg, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) List() []Message {
|
||||||
|
return l.writer.messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Get(id string) (Message, error) {
|
||||||
|
for _, msg := range l.writer.messages {
|
||||||
|
if msg.ID == id {
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Message{}, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Subscribe(ctx context.Context) <-chan pubsub.Event[Message] {
|
||||||
|
return l.writer.Subscribe(ctx)
|
||||||
|
}
|
17
internal/logging/logging.go
Normal file
17
internal/logging/logging.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/pubsub"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Interface interface {
|
||||||
|
Debug(msg string, args ...any)
|
||||||
|
Info(msg string, args ...any)
|
||||||
|
Warn(msg string, args ...any)
|
||||||
|
Error(msg string, args ...any)
|
||||||
|
Subscribe(ctx context.Context) <-chan pubsub.Event[Message]
|
||||||
|
|
||||||
|
List() []Message
|
||||||
|
}
|
19
internal/logging/message.go
Normal file
19
internal/logging/message.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is the event payload for a log message
|
||||||
|
type Message struct {
|
||||||
|
ID string
|
||||||
|
Time time.Time
|
||||||
|
Level string
|
||||||
|
Message string `json:"msg"`
|
||||||
|
Attributes []Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attr struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
}
|
49
internal/logging/writer.go
Normal file
49
internal/logging/writer.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-logfmt/logfmt"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/pubsub"
|
||||||
|
)
|
||||||
|
|
||||||
|
type writer struct {
|
||||||
|
messages []Message
|
||||||
|
*pubsub.Broker[Message]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writer) Write(p []byte) (int, error) {
|
||||||
|
d := logfmt.NewDecoder(bytes.NewReader(p))
|
||||||
|
for d.ScanRecord() {
|
||||||
|
msg := Message{
|
||||||
|
ID: time.Now().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
for d.ScanKeyval() {
|
||||||
|
switch string(d.Key()) {
|
||||||
|
case "time":
|
||||||
|
parsed, err := time.Parse(time.RFC3339, string(d.Value()))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("parsing time: %w", err)
|
||||||
|
}
|
||||||
|
msg.Time = parsed
|
||||||
|
case "level":
|
||||||
|
msg.Level = string(d.Value())
|
||||||
|
case "msg":
|
||||||
|
msg.Message = string(d.Value())
|
||||||
|
default:
|
||||||
|
msg.Attributes = append(msg.Attributes, Attr{
|
||||||
|
Key: string(d.Key()),
|
||||||
|
Value: string(d.Value()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.messages = append(w.messages, msg)
|
||||||
|
w.Publish(pubsub.CreatedEvent, msg)
|
||||||
|
}
|
||||||
|
if d.Err() != nil {
|
||||||
|
return 0, d.Err()
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
101
internal/pubsub/broker.go
Normal file
101
internal/pubsub/broker.go
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
package pubsub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bufferSize = 1024
|
||||||
|
|
||||||
|
type Logger interface {
|
||||||
|
Debug(msg string, args ...any)
|
||||||
|
Info(msg string, args ...any)
|
||||||
|
Warn(msg string, args ...any)
|
||||||
|
Error(msg string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broker allows clients to publish events and subscribe to events
|
||||||
|
type Broker[T any] struct {
|
||||||
|
subs map[chan Event[T]]struct{} // subscriptions
|
||||||
|
mu sync.Mutex // sync access to map
|
||||||
|
done chan struct{} // close when broker is shutting down
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBroker constructs a pub/sub broker.
|
||||||
|
func NewBroker[T any]() *Broker[T] {
|
||||||
|
b := &Broker[T]{
|
||||||
|
subs: make(map[chan Event[T]]struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown the broker, terminating any subscriptions.
|
||||||
|
func (b *Broker[T]) Shutdown() {
|
||||||
|
close(b.done)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
// Remove each subscriber entry, so Publish() cannot send any further
|
||||||
|
// messages, and close each subscriber's channel, so the subscriber cannot
|
||||||
|
// consume any more messages.
|
||||||
|
for ch := range b.subs {
|
||||||
|
delete(b.subs, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe subscribes the caller to a stream of events. The returned channel
|
||||||
|
// is closed when the broker is shutdown.
|
||||||
|
func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
// Check if broker has shutdown and if so return closed channel
|
||||||
|
select {
|
||||||
|
case <-b.done:
|
||||||
|
ch := make(chan Event[T])
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe
|
||||||
|
sub := make(chan Event[T], bufferSize)
|
||||||
|
b.subs[sub] = struct{}{}
|
||||||
|
|
||||||
|
// Unsubscribe when context is done.
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
// Check if broker has shutdown and if so do nothing
|
||||||
|
select {
|
||||||
|
case <-b.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(b.subs, sub)
|
||||||
|
close(sub)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish an event to subscribers.
|
||||||
|
func (b *Broker[T]) Publish(t EventType, payload T) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
for sub := range b.subs {
|
||||||
|
select {
|
||||||
|
case sub <- Event[T]{Type: t, Payload: payload}:
|
||||||
|
case <-b.done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
internal/pubsub/events.go
Normal file
22
internal/pubsub/events.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
package pubsub
|
||||||
|
|
||||||
|
const (
|
||||||
|
CreatedEvent EventType = "created"
|
||||||
|
UpdatedEvent EventType = "updated"
|
||||||
|
DeletedEvent EventType = "deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// EventType identifies the type of event
|
||||||
|
EventType string
|
||||||
|
|
||||||
|
// Event represents an event in the lifecycle of a resource
|
||||||
|
Event[T any] struct {
|
||||||
|
Type EventType
|
||||||
|
Payload T
|
||||||
|
}
|
||||||
|
|
||||||
|
Publisher[T any] interface {
|
||||||
|
Publish(EventType, T)
|
||||||
|
}
|
||||||
|
)
|
0
internal/tui/components/core/status.go
Normal file
0
internal/tui/components/core/status.go
Normal file
131
internal/tui/components/logs/table.go
Normal file
131
internal/tui/components/logs/table.go
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/logging"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/pubsub"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TableComponent interface {
|
||||||
|
tea.Model
|
||||||
|
layout.Focusable
|
||||||
|
layout.Sizeable
|
||||||
|
layout.Bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
var logger = logging.Get()
|
||||||
|
|
||||||
|
type tableCmp struct {
|
||||||
|
table table.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) Init() tea.Cmd {
|
||||||
|
i.setRows()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if i.table.Focused() {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case pubsub.Event[logging.Message]:
|
||||||
|
i.setRows()
|
||||||
|
return i, nil
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if msg.String() == "ctrl+s" {
|
||||||
|
logger.Info("Saving logs...",
|
||||||
|
"rows", len(i.table.Rows()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t, cmd := i.table.Update(msg)
|
||||||
|
i.table = t
|
||||||
|
return i, cmd
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) View() string {
|
||||||
|
return i.table.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) Blur() tea.Cmd {
|
||||||
|
i.table.Blur()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) Focus() tea.Cmd {
|
||||||
|
i.table.Focus()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) IsFocused() bool {
|
||||||
|
return i.table.Focused()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) GetSize() (int, int) {
|
||||||
|
return i.table.Width(), i.table.Height()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) SetSize(width int, height int) {
|
||||||
|
i.table.SetWidth(width)
|
||||||
|
i.table.SetHeight(height)
|
||||||
|
cloumns := i.table.Columns()
|
||||||
|
for i, col := range cloumns {
|
||||||
|
col.Width = (width / len(cloumns)) - 2
|
||||||
|
cloumns[i] = col
|
||||||
|
}
|
||||||
|
i.table.SetColumns(cloumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) BindingKeys() []key.Binding {
|
||||||
|
return layout.KeyMapToSlice(i.table.KeyMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *tableCmp) setRows() {
|
||||||
|
rows := []table.Row{}
|
||||||
|
|
||||||
|
logs := logger.List()
|
||||||
|
slices.SortFunc(logs, func(a, b logging.Message) int {
|
||||||
|
if a.Time.Before(b.Time) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if a.Time.After(b.Time) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, log := range logs {
|
||||||
|
bm, _ := json.Marshal(log.Attributes)
|
||||||
|
|
||||||
|
row := table.Row{
|
||||||
|
log.Time.Format("15:04:05"),
|
||||||
|
log.Level,
|
||||||
|
log.Message,
|
||||||
|
string(bm),
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
i.table.SetRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogsTable() TableComponent {
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: "Time", Width: 4},
|
||||||
|
{Title: "Level", Width: 10},
|
||||||
|
{Title: "Message", Width: 10},
|
||||||
|
{Title: "Attributes", Width: 10},
|
||||||
|
}
|
||||||
|
tableModel := table.New(
|
||||||
|
table.WithColumns(columns),
|
||||||
|
)
|
||||||
|
return &tableCmp{
|
||||||
|
table: tableModel,
|
||||||
|
}
|
||||||
|
}
|
21
internal/tui/components/repl/editor.go
Normal file
21
internal/tui/components/repl/editor.go
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
type editorCmp struct{}
|
||||||
|
|
||||||
|
func (i *editorCmp) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *editorCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *editorCmp) View() string {
|
||||||
|
return "Editor"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEditorCmp() tea.Model {
|
||||||
|
return &editorCmp{}
|
||||||
|
}
|
21
internal/tui/components/repl/messages.go
Normal file
21
internal/tui/components/repl/messages.go
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
type messagesCmp struct{}
|
||||||
|
|
||||||
|
func (i *messagesCmp) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *messagesCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *messagesCmp) View() string {
|
||||||
|
return "Messages"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMessagesCmp() tea.Model {
|
||||||
|
return &messagesCmp{}
|
||||||
|
}
|
21
internal/tui/components/repl/threads.go
Normal file
21
internal/tui/components/repl/threads.go
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
type threadsCmp struct{}
|
||||||
|
|
||||||
|
func (i *threadsCmp) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *threadsCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *threadsCmp) View() string {
|
||||||
|
return "Threads"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewThreadsCmp() tea.Model {
|
||||||
|
return &threadsCmp{}
|
||||||
|
}
|
361
internal/tui/layout/bento.go
Normal file
361
internal/tui/layout/bento.go
Normal file
|
@ -0,0 +1,361 @@
|
||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type paneID string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BentoLeftPane paneID = "left"
|
||||||
|
BentoRightTopPane paneID = "right-top"
|
||||||
|
BentoRightBottomPane paneID = "right-bottom"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BentoPanes map[paneID]tea.Model
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultLeftWidthRatio = 0.2
|
||||||
|
defaultRightTopHeightRatio = 0.85
|
||||||
|
|
||||||
|
minLeftWidth = 10
|
||||||
|
minRightBottomHeight = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
type BentoLayout interface {
|
||||||
|
tea.Model
|
||||||
|
Sizeable
|
||||||
|
Bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
type BentoKeyBindings struct {
|
||||||
|
SwitchPane key.Binding
|
||||||
|
SwitchPaneBack key.Binding
|
||||||
|
HideCurrentPane key.Binding
|
||||||
|
ShowAllPanes key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultBentoKeyBindings = BentoKeyBindings{
|
||||||
|
SwitchPane: key.NewBinding(
|
||||||
|
key.WithKeys("tab"),
|
||||||
|
key.WithHelp("tab", "switch pane"),
|
||||||
|
),
|
||||||
|
SwitchPaneBack: key.NewBinding(
|
||||||
|
key.WithKeys("shift+tab"),
|
||||||
|
key.WithHelp("shift+tab", "switch pane back"),
|
||||||
|
),
|
||||||
|
HideCurrentPane: key.NewBinding(
|
||||||
|
key.WithKeys("X"),
|
||||||
|
key.WithHelp("X", "hide current pane"),
|
||||||
|
),
|
||||||
|
ShowAllPanes: key.NewBinding(
|
||||||
|
key.WithKeys("R"),
|
||||||
|
key.WithHelp("R", "show all panes"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
type bentoLayout struct {
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
|
||||||
|
leftWidthRatio float64
|
||||||
|
rightTopHeightRatio float64
|
||||||
|
|
||||||
|
currentPane paneID
|
||||||
|
panes map[paneID]SinglePaneLayout
|
||||||
|
hiddenPanes map[paneID]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bentoLayout) GetSize() (int, int) {
|
||||||
|
return b.width, b.height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b bentoLayout) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
b.SetSize(msg.Width, msg.Height)
|
||||||
|
return b, nil
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
|
||||||
|
return b, b.SwitchPane(false)
|
||||||
|
case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
|
||||||
|
return b, b.SwitchPane(true)
|
||||||
|
case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
|
||||||
|
return b, b.HidePane(b.currentPane)
|
||||||
|
case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
|
||||||
|
for id := range b.hiddenPanes {
|
||||||
|
delete(b.hiddenPanes, id)
|
||||||
|
}
|
||||||
|
b.SetSize(b.width, b.height)
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pane, ok := b.panes[b.currentPane]; ok {
|
||||||
|
u, cmd := pane.Update(msg)
|
||||||
|
b.panes[b.currentPane] = u.(SinglePaneLayout)
|
||||||
|
return b, cmd
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b bentoLayout) View() string {
|
||||||
|
if b.width <= 0 || b.height <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, pane := range b.panes {
|
||||||
|
if b.currentPane == id {
|
||||||
|
pane.Focus()
|
||||||
|
} else {
|
||||||
|
pane.Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leftVisible := false
|
||||||
|
rightTopVisible := false
|
||||||
|
rightBottomVisible := false
|
||||||
|
|
||||||
|
var leftPane, rightTopPane, rightBottomPane string
|
||||||
|
|
||||||
|
if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
|
||||||
|
leftPane = pane.View()
|
||||||
|
leftVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
|
||||||
|
rightTopPane = pane.View()
|
||||||
|
rightTopVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
|
||||||
|
rightBottomPane = pane.View()
|
||||||
|
rightBottomVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if leftVisible {
|
||||||
|
if rightTopVisible || rightBottomVisible {
|
||||||
|
rightSection := ""
|
||||||
|
if rightTopVisible && rightBottomVisible {
|
||||||
|
rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
|
||||||
|
} else if rightTopVisible {
|
||||||
|
rightSection = rightTopPane
|
||||||
|
} else {
|
||||||
|
rightSection = rightBottomPane
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
|
||||||
|
lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
|
||||||
|
}
|
||||||
|
} else if rightTopVisible || rightBottomVisible {
|
||||||
|
if rightTopVisible && rightBottomVisible {
|
||||||
|
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
|
||||||
|
lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
|
||||||
|
)
|
||||||
|
} else if rightTopVisible {
|
||||||
|
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
|
||||||
|
} else {
|
||||||
|
return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bentoLayout) SetSize(width int, height int) {
|
||||||
|
if width < 0 || height < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.width = width
|
||||||
|
b.height = height
|
||||||
|
|
||||||
|
// Check which panes are available
|
||||||
|
leftExists := false
|
||||||
|
rightTopExists := false
|
||||||
|
rightBottomExists := false
|
||||||
|
|
||||||
|
if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
|
||||||
|
leftExists = true
|
||||||
|
}
|
||||||
|
if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
|
||||||
|
rightTopExists = true
|
||||||
|
}
|
||||||
|
if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
|
||||||
|
rightBottomExists = true
|
||||||
|
}
|
||||||
|
|
||||||
|
leftWidth := 0
|
||||||
|
rightWidth := 0
|
||||||
|
rightTopHeight := 0
|
||||||
|
rightBottomHeight := 0
|
||||||
|
|
||||||
|
if leftExists && (rightTopExists || rightBottomExists) {
|
||||||
|
leftWidth = int(float64(width) * b.leftWidthRatio)
|
||||||
|
if leftWidth < minLeftWidth && width >= minLeftWidth {
|
||||||
|
leftWidth = minLeftWidth
|
||||||
|
}
|
||||||
|
rightWidth = width - leftWidth
|
||||||
|
|
||||||
|
if rightTopExists && rightBottomExists {
|
||||||
|
rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
|
||||||
|
rightBottomHeight = height - rightTopHeight
|
||||||
|
|
||||||
|
// Ensure minimum height for bottom pane
|
||||||
|
if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
|
||||||
|
rightBottomHeight = minRightBottomHeight
|
||||||
|
rightTopHeight = height - rightBottomHeight
|
||||||
|
}
|
||||||
|
} else if rightTopExists {
|
||||||
|
rightTopHeight = height
|
||||||
|
} else if rightBottomExists {
|
||||||
|
rightBottomHeight = height
|
||||||
|
}
|
||||||
|
} else if leftExists {
|
||||||
|
leftWidth = width
|
||||||
|
} else if rightTopExists || rightBottomExists {
|
||||||
|
rightWidth = width
|
||||||
|
|
||||||
|
if rightTopExists && rightBottomExists {
|
||||||
|
rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
|
||||||
|
rightBottomHeight = height - rightTopHeight
|
||||||
|
|
||||||
|
if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
|
||||||
|
rightBottomHeight = minRightBottomHeight
|
||||||
|
rightTopHeight = height - rightBottomHeight
|
||||||
|
}
|
||||||
|
} else if rightTopExists {
|
||||||
|
rightTopHeight = height
|
||||||
|
} else if rightBottomExists {
|
||||||
|
rightBottomHeight = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
|
||||||
|
pane.SetSize(leftWidth, height)
|
||||||
|
}
|
||||||
|
if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
|
||||||
|
pane.SetSize(rightWidth, rightTopHeight)
|
||||||
|
}
|
||||||
|
if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
|
||||||
|
pane.SetSize(rightWidth, rightBottomHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
|
||||||
|
if len(b.panes)-len(b.hiddenPanes) == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := b.panes[pane]; ok {
|
||||||
|
b.hiddenPanes[pane] = true
|
||||||
|
}
|
||||||
|
b.SetSize(b.width, b.height)
|
||||||
|
return b.SwitchPane(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
|
||||||
|
if back {
|
||||||
|
switch b.currentPane {
|
||||||
|
case BentoLeftPane:
|
||||||
|
b.currentPane = BentoRightBottomPane
|
||||||
|
case BentoRightTopPane:
|
||||||
|
b.currentPane = BentoLeftPane
|
||||||
|
case BentoRightBottomPane:
|
||||||
|
b.currentPane = BentoRightTopPane
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch b.currentPane {
|
||||||
|
case BentoLeftPane:
|
||||||
|
b.currentPane = BentoRightTopPane
|
||||||
|
case BentoRightTopPane:
|
||||||
|
b.currentPane = BentoRightBottomPane
|
||||||
|
case BentoRightBottomPane:
|
||||||
|
b.currentPane = BentoLeftPane
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
for id, pane := range b.panes {
|
||||||
|
if _, ok := b.hiddenPanes[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id == b.currentPane {
|
||||||
|
cmds = append(cmds, pane.Focus())
|
||||||
|
} else {
|
||||||
|
cmds = append(cmds, pane.Blur())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *bentoLayout) BindingKeys() []key.Binding {
|
||||||
|
bindings := KeyMapToSlice(defaultBentoKeyBindings)
|
||||||
|
if b, ok := s.panes[s.currentPane].(Bindings); ok {
|
||||||
|
bindings = append(bindings, b.BindingKeys()...)
|
||||||
|
}
|
||||||
|
return bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
type BentoLayoutOption func(*bentoLayout)
|
||||||
|
|
||||||
|
func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
|
||||||
|
p := make(map[paneID]SinglePaneLayout, len(panes))
|
||||||
|
for id, pane := range panes {
|
||||||
|
// Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
|
||||||
|
if _, ok := pane.(SinglePaneLayout); !ok {
|
||||||
|
p[id] = NewSinglePane(
|
||||||
|
pane,
|
||||||
|
WithSinglePaneFocusable(true),
|
||||||
|
WithSinglePaneBordered(true),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
p[id] = pane.(SinglePaneLayout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p) == 0 {
|
||||||
|
panic("no panes provided for BentoLayout")
|
||||||
|
}
|
||||||
|
layout := &bentoLayout{
|
||||||
|
panes: p,
|
||||||
|
hiddenPanes: make(map[paneID]bool),
|
||||||
|
currentPane: BentoLeftPane,
|
||||||
|
leftWidthRatio: defaultLeftWidthRatio,
|
||||||
|
rightTopHeightRatio: defaultRightTopHeightRatio,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
|
||||||
|
return func(b *bentoLayout) {
|
||||||
|
if ratio > 0 && ratio < 1 {
|
||||||
|
b.leftWidthRatio = ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
|
||||||
|
return func(b *bentoLayout) {
|
||||||
|
if ratio > 0 && ratio < 1 {
|
||||||
|
b.rightTopHeightRatio = ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
|
||||||
|
return func(b *bentoLayout) {
|
||||||
|
b.currentPane = pane
|
||||||
|
}
|
||||||
|
}
|
99
internal/tui/layout/border.go
Normal file
99
internal/tui/layout/border.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui/styles"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BorderPosition int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TopLeftBorder BorderPosition = iota
|
||||||
|
TopMiddleBorder
|
||||||
|
TopRightBorder
|
||||||
|
BottomLeftBorder
|
||||||
|
BottomMiddleBorder
|
||||||
|
BottomRightBorder
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ActiveBorder = styles.Blue
|
||||||
|
InactivePreviewBorder = styles.Grey
|
||||||
|
)
|
||||||
|
|
||||||
|
func Borderize(content string, active bool, embeddedText map[BorderPosition]string) string {
|
||||||
|
if embeddedText == nil {
|
||||||
|
embeddedText = make(map[BorderPosition]string)
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
thickness = map[bool]lipgloss.Border{
|
||||||
|
true: lipgloss.Border(lipgloss.ThickBorder()),
|
||||||
|
false: lipgloss.Border(lipgloss.NormalBorder()),
|
||||||
|
}
|
||||||
|
color = map[bool]lipgloss.TerminalColor{
|
||||||
|
true: ActiveBorder,
|
||||||
|
false: InactivePreviewBorder,
|
||||||
|
}
|
||||||
|
border = thickness[active]
|
||||||
|
style = lipgloss.NewStyle().Foreground(color[active])
|
||||||
|
width = lipgloss.Width(content)
|
||||||
|
)
|
||||||
|
|
||||||
|
encloseInSquareBrackets := func(text string) string {
|
||||||
|
if text != "" {
|
||||||
|
return fmt.Sprintf("%s%s%s",
|
||||||
|
style.Render(border.TopRight),
|
||||||
|
text,
|
||||||
|
style.Render(border.TopLeft),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
buildHorizontalBorder := func(leftText, middleText, rightText, leftCorner, inbetween, rightCorner string) string {
|
||||||
|
leftText = encloseInSquareBrackets(leftText)
|
||||||
|
middleText = encloseInSquareBrackets(middleText)
|
||||||
|
rightText = encloseInSquareBrackets(rightText)
|
||||||
|
// Calculate length of border between embedded texts
|
||||||
|
remaining := max(0, width-lipgloss.Width(leftText)-lipgloss.Width(middleText)-lipgloss.Width(rightText))
|
||||||
|
leftBorderLen := max(0, (width/2)-lipgloss.Width(leftText)-(lipgloss.Width(middleText)/2))
|
||||||
|
rightBorderLen := max(0, remaining-leftBorderLen)
|
||||||
|
// Then construct border string
|
||||||
|
s := leftText +
|
||||||
|
style.Render(strings.Repeat(inbetween, leftBorderLen)) +
|
||||||
|
middleText +
|
||||||
|
style.Render(strings.Repeat(inbetween, rightBorderLen)) +
|
||||||
|
rightText
|
||||||
|
// Make it fit in the space available between the two corners.
|
||||||
|
s = lipgloss.NewStyle().
|
||||||
|
Inline(true).
|
||||||
|
MaxWidth(width).
|
||||||
|
Render(s)
|
||||||
|
// Add the corners
|
||||||
|
return style.Render(leftCorner) + s + style.Render(rightCorner)
|
||||||
|
}
|
||||||
|
// Stack top border, content and horizontal borders, and bottom border.
|
||||||
|
return strings.Join([]string{
|
||||||
|
buildHorizontalBorder(
|
||||||
|
embeddedText[TopLeftBorder],
|
||||||
|
embeddedText[TopMiddleBorder],
|
||||||
|
embeddedText[TopRightBorder],
|
||||||
|
border.TopLeft,
|
||||||
|
border.Top,
|
||||||
|
border.TopRight,
|
||||||
|
),
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
BorderForeground(color[active]).
|
||||||
|
Border(border, false, true, false, true).Render(content),
|
||||||
|
buildHorizontalBorder(
|
||||||
|
embeddedText[BottomLeftBorder],
|
||||||
|
embeddedText[BottomMiddleBorder],
|
||||||
|
embeddedText[BottomRightBorder],
|
||||||
|
border.BottomLeft,
|
||||||
|
border.Bottom,
|
||||||
|
border.BottomRight,
|
||||||
|
),
|
||||||
|
}, "\n")
|
||||||
|
}
|
39
internal/tui/layout/layout.go
Normal file
39
internal/tui/layout/layout.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Focusable interface {
|
||||||
|
Focus() tea.Cmd
|
||||||
|
Blur() tea.Cmd
|
||||||
|
IsFocused() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bordered interface {
|
||||||
|
BorderText() map[BorderPosition]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sizeable interface {
|
||||||
|
SetSize(width, height int)
|
||||||
|
GetSize() (int, int)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bindings interface {
|
||||||
|
BindingKeys() []key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func KeyMapToSlice(t any) (bindings []key.Binding) {
|
||||||
|
typ := reflect.TypeOf(t)
|
||||||
|
if typ.Kind() != reflect.Struct {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range typ.NumField() {
|
||||||
|
v := reflect.ValueOf(t).Field(i)
|
||||||
|
bindings = append(bindings, v.Interface().(key.Binding))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
172
internal/tui/layout/single.go
Normal file
172
internal/tui/layout/single.go
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SinglePaneLayout interface {
|
||||||
|
tea.Model
|
||||||
|
Focusable
|
||||||
|
Sizeable
|
||||||
|
Bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
type singlePaneLayout struct {
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
|
||||||
|
focusable bool
|
||||||
|
focused bool
|
||||||
|
|
||||||
|
bordered bool
|
||||||
|
borderText map[BorderPosition]string
|
||||||
|
|
||||||
|
content tea.Model
|
||||||
|
|
||||||
|
padding []int
|
||||||
|
}
|
||||||
|
|
||||||
|
type SinglePaneOption func(*singlePaneLayout)
|
||||||
|
|
||||||
|
func (s singlePaneLayout) Init() tea.Cmd {
|
||||||
|
return s.content.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
s.SetSize(msg.Width, msg.Height)
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
u, cmd := s.content.Update(msg)
|
||||||
|
s.content = u
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s singlePaneLayout) View() string {
|
||||||
|
style := lipgloss.NewStyle().Width(s.width).Height(s.height)
|
||||||
|
if s.bordered {
|
||||||
|
style = style.Width(s.width).Height(s.height)
|
||||||
|
}
|
||||||
|
if s.padding != nil {
|
||||||
|
style = style.Padding(s.padding...)
|
||||||
|
}
|
||||||
|
content := style.Render(s.content.View())
|
||||||
|
if s.bordered {
|
||||||
|
if s.borderText == nil {
|
||||||
|
s.borderText = map[BorderPosition]string{}
|
||||||
|
}
|
||||||
|
if bordered, ok := s.content.(Bordered); ok {
|
||||||
|
s.borderText = bordered.BorderText()
|
||||||
|
}
|
||||||
|
return Borderize(content, s.focused, s.borderText)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singlePaneLayout) Blur() tea.Cmd {
|
||||||
|
if s.focusable {
|
||||||
|
s.focused = false
|
||||||
|
}
|
||||||
|
if blurable, ok := s.content.(Focusable); ok {
|
||||||
|
return blurable.Blur()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singlePaneLayout) Focus() tea.Cmd {
|
||||||
|
if s.focusable {
|
||||||
|
s.focused = true
|
||||||
|
}
|
||||||
|
if focusable, ok := s.content.(Focusable); ok {
|
||||||
|
return focusable.Focus()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singlePaneLayout) SetSize(width, height int) {
|
||||||
|
s.width = width
|
||||||
|
s.height = height
|
||||||
|
if s.bordered {
|
||||||
|
s.width -= 2
|
||||||
|
s.height -= 2
|
||||||
|
}
|
||||||
|
if s.padding != nil {
|
||||||
|
if len(s.padding) == 1 {
|
||||||
|
s.width -= s.padding[0] * 2
|
||||||
|
s.height -= s.padding[0] * 2
|
||||||
|
} else if len(s.padding) == 2 {
|
||||||
|
s.width -= s.padding[0] * 2
|
||||||
|
s.height -= s.padding[1] * 2
|
||||||
|
} else if len(s.padding) == 3 {
|
||||||
|
s.width -= s.padding[0] * 2
|
||||||
|
s.height -= s.padding[1] + s.padding[2]
|
||||||
|
} else if len(s.padding) == 4 {
|
||||||
|
s.width -= s.padding[0] + s.padding[2]
|
||||||
|
s.height -= s.padding[1] + s.padding[3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.content != nil {
|
||||||
|
if c, ok := s.content.(Sizeable); ok {
|
||||||
|
c.SetSize(s.width, s.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singlePaneLayout) IsFocused() bool {
|
||||||
|
return s.focused
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singlePaneLayout) GetSize() (int, int) {
|
||||||
|
return s.width, s.height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singlePaneLayout) BindingKeys() []key.Binding {
|
||||||
|
if b, ok := s.content.(Bindings); ok {
|
||||||
|
return b.BindingKeys()
|
||||||
|
}
|
||||||
|
return []key.Binding{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
|
||||||
|
layout := &singlePaneLayout{
|
||||||
|
content: content,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(layout)
|
||||||
|
}
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSignlePaneSize(width, height int) SinglePaneOption {
|
||||||
|
return func(opts *singlePaneLayout) {
|
||||||
|
opts.width = width
|
||||||
|
opts.height = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSinglePaneFocusable(focusable bool) SinglePaneOption {
|
||||||
|
return func(opts *singlePaneLayout) {
|
||||||
|
opts.focusable = focusable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSinglePaneBordered(bordered bool) SinglePaneOption {
|
||||||
|
return func(opts *singlePaneLayout) {
|
||||||
|
opts.bordered = bordered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSignlePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
|
||||||
|
return func(opts *singlePaneLayout) {
|
||||||
|
opts.borderText = borderText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSinglePanePadding(padding ...int) SinglePaneOption {
|
||||||
|
return func(opts *singlePaneLayout) {
|
||||||
|
opts.padding = padding
|
||||||
|
}
|
||||||
|
}
|
37
internal/tui/page/init.go
Normal file
37
internal/tui/page/init.go
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
var InitPage PageID = "init"
|
||||||
|
|
||||||
|
type initPage struct {
|
||||||
|
layout layout.SinglePaneLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i initPage) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i initPage) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i initPage) View() string {
|
||||||
|
return "Initializing..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInitPage() tea.Model {
|
||||||
|
return layout.NewSinglePane(
|
||||||
|
&initPage{},
|
||||||
|
layout.WithSinglePaneFocusable(true),
|
||||||
|
layout.WithSinglePaneBordered(true),
|
||||||
|
layout.WithSignlePaneBorderText(
|
||||||
|
map[layout.BorderPosition]string{
|
||||||
|
layout.TopMiddleBorder: "Welcome to termai",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
25
internal/tui/page/logs.go
Normal file
25
internal/tui/page/logs.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui/components/logs"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
var LogsPage PageID = "logs"
|
||||||
|
|
||||||
|
func NewLogsPage() tea.Model {
|
||||||
|
p := layout.NewSinglePane(
|
||||||
|
logs.NewLogsTable(),
|
||||||
|
layout.WithSinglePaneFocusable(true),
|
||||||
|
layout.WithSinglePaneBordered(true),
|
||||||
|
layout.WithSignlePaneBorderText(
|
||||||
|
map[layout.BorderPosition]string{
|
||||||
|
layout.TopMiddleBorder: "Logs",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
layout.WithSinglePanePadding(1),
|
||||||
|
)
|
||||||
|
p.Focus()
|
||||||
|
return p
|
||||||
|
}
|
3
internal/tui/page/page.go
Normal file
3
internal/tui/page/page.go
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
package page
|
||||||
|
|
||||||
|
type PageID string
|
19
internal/tui/page/repl.go
Normal file
19
internal/tui/page/repl.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui/components/repl"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ReplPage PageID = "repl"
|
||||||
|
|
||||||
|
func NewReplPage() tea.Model {
|
||||||
|
return layout.NewBentoLayout(
|
||||||
|
layout.BentoPanes{
|
||||||
|
layout.BentoLeftPane: repl.NewThreadsCmp(),
|
||||||
|
layout.BentoRightTopPane: repl.NewMessagesCmp(),
|
||||||
|
layout.BentoRightBottomPane: repl.NewEditorCmp(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
12
internal/tui/styles/icons.go
Normal file
12
internal/tui/styles/icons.go
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
package styles
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionsIcon string = ""
|
||||||
|
ChatIcon string = ""
|
||||||
|
|
||||||
|
BotIcon string = ""
|
||||||
|
ToolIcon string = ""
|
||||||
|
UserIcon string = ""
|
||||||
|
|
||||||
|
SleepIcon string = ""
|
||||||
|
)
|
498
internal/tui/styles/markdown.go
Normal file
498
internal/tui/styles/markdown.go
Normal file
|
@ -0,0 +1,498 @@
|
||||||
|
package styles
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/glamour/ansi"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultMargin = 2
|
||||||
|
|
||||||
|
// Helper functions for style pointers
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
func stringPtr(s string) *string { return &s }
|
||||||
|
func uintPtr(u uint) *uint { return &u }
|
||||||
|
|
||||||
|
// CatppuccinMarkdownStyle is the Catppuccin Mocha style for Glamour markdown rendering.
|
||||||
|
func CatppuccinMarkdownStyle() ansi.StyleConfig {
|
||||||
|
isDark := lipgloss.HasDarkBackground()
|
||||||
|
if isDark {
|
||||||
|
return catppuccinDark
|
||||||
|
}
|
||||||
|
return catppuccinLight
|
||||||
|
}
|
||||||
|
|
||||||
|
var catppuccinDark = ansi.StyleConfig{
|
||||||
|
Document: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: "\n",
|
||||||
|
BlockSuffix: "\n",
|
||||||
|
Color: stringPtr(dark.Text().Hex),
|
||||||
|
},
|
||||||
|
Margin: uintPtr(defaultMargin),
|
||||||
|
},
|
||||||
|
BlockQuote: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Yellow().Hex),
|
||||||
|
Italic: boolPtr(true),
|
||||||
|
Prefix: "┃ ",
|
||||||
|
},
|
||||||
|
Indent: uintPtr(1),
|
||||||
|
Margin: uintPtr(defaultMargin),
|
||||||
|
},
|
||||||
|
List: ansi.StyleList{
|
||||||
|
LevelIndent: defaultMargin,
|
||||||
|
StyleBlock: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Text().Hex),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Heading: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
BlockSuffix: "\n",
|
||||||
|
Color: stringPtr(dark.Mauve().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H1: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "# ",
|
||||||
|
Color: stringPtr(dark.Lavender().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
BlockPrefix: "\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H2: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "## ",
|
||||||
|
Color: stringPtr(dark.Mauve().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H3: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "### ",
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H4: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "#### ",
|
||||||
|
Color: stringPtr(dark.Flamingo().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H5: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "##### ",
|
||||||
|
Color: stringPtr(dark.Rosewater().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H6: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "###### ",
|
||||||
|
Color: stringPtr(dark.Rosewater().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Strikethrough: ansi.StylePrimitive{
|
||||||
|
CrossedOut: boolPtr(true),
|
||||||
|
Color: stringPtr(dark.Overlay1().Hex),
|
||||||
|
},
|
||||||
|
Emph: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Yellow().Hex),
|
||||||
|
Italic: boolPtr(true),
|
||||||
|
},
|
||||||
|
Strong: ansi.StylePrimitive{
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
Color: stringPtr(dark.Peach().Hex),
|
||||||
|
},
|
||||||
|
HorizontalRule: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Overlay0().Hex),
|
||||||
|
Format: "\n─────────────────────────────────────────\n",
|
||||||
|
},
|
||||||
|
Item: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: "• ",
|
||||||
|
Color: stringPtr(dark.Blue().Hex),
|
||||||
|
},
|
||||||
|
Enumeration: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: ". ",
|
||||||
|
Color: stringPtr(dark.Sky().Hex),
|
||||||
|
},
|
||||||
|
Task: ansi.StyleTask{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{},
|
||||||
|
Ticked: "[✓] ",
|
||||||
|
Unticked: "[ ] ",
|
||||||
|
},
|
||||||
|
Link: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Sky().Hex),
|
||||||
|
Underline: boolPtr(true),
|
||||||
|
},
|
||||||
|
LinkText: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
Image: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Sapphire().Hex),
|
||||||
|
Underline: boolPtr(true),
|
||||||
|
Format: "🖼 {{.text}}",
|
||||||
|
},
|
||||||
|
ImageText: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
Format: "{{.text}}",
|
||||||
|
},
|
||||||
|
Code: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Green().Hex),
|
||||||
|
Prefix: " ",
|
||||||
|
Suffix: " ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CodeBlock: ansi.StyleCodeBlock{
|
||||||
|
StyleBlock: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: " ",
|
||||||
|
Color: stringPtr(dark.Text().Hex),
|
||||||
|
},
|
||||||
|
|
||||||
|
Margin: uintPtr(defaultMargin),
|
||||||
|
},
|
||||||
|
Chroma: &ansi.Chroma{
|
||||||
|
Text: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Text().Hex),
|
||||||
|
},
|
||||||
|
Error: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Text().Hex),
|
||||||
|
},
|
||||||
|
Comment: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Overlay1().Hex),
|
||||||
|
},
|
||||||
|
CommentPreproc: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
},
|
||||||
|
Keyword: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
},
|
||||||
|
KeywordReserved: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
},
|
||||||
|
KeywordNamespace: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
},
|
||||||
|
KeywordType: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Sky().Hex),
|
||||||
|
},
|
||||||
|
Operator: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
},
|
||||||
|
Punctuation: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Text().Hex),
|
||||||
|
},
|
||||||
|
Name: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Sky().Hex),
|
||||||
|
},
|
||||||
|
NameBuiltin: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Sky().Hex),
|
||||||
|
},
|
||||||
|
NameTag: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
},
|
||||||
|
NameAttribute: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Green().Hex),
|
||||||
|
},
|
||||||
|
NameClass: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Sky().Hex),
|
||||||
|
},
|
||||||
|
NameConstant: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Mauve().Hex),
|
||||||
|
},
|
||||||
|
NameDecorator: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Green().Hex),
|
||||||
|
},
|
||||||
|
NameFunction: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Green().Hex),
|
||||||
|
},
|
||||||
|
LiteralNumber: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Teal().Hex),
|
||||||
|
},
|
||||||
|
LiteralString: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Yellow().Hex),
|
||||||
|
},
|
||||||
|
LiteralStringEscape: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Pink().Hex),
|
||||||
|
},
|
||||||
|
GenericDeleted: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Red().Hex),
|
||||||
|
},
|
||||||
|
GenericEmph: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Yellow().Hex),
|
||||||
|
Italic: boolPtr(true),
|
||||||
|
},
|
||||||
|
GenericInserted: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Green().Hex),
|
||||||
|
},
|
||||||
|
GenericStrong: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Peach().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
GenericSubheading: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(dark.Mauve().Hex),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Table: ansi.StyleTable{
|
||||||
|
StyleBlock: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: "\n",
|
||||||
|
BlockSuffix: "\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CenterSeparator: stringPtr("┼"),
|
||||||
|
ColumnSeparator: stringPtr("│"),
|
||||||
|
RowSeparator: stringPtr("─"),
|
||||||
|
},
|
||||||
|
DefinitionDescription: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: "\n ❯ ",
|
||||||
|
Color: stringPtr(dark.Sapphire().Hex),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var catppuccinLight = ansi.StyleConfig{
|
||||||
|
Document: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: "\n",
|
||||||
|
BlockSuffix: "\n",
|
||||||
|
Color: stringPtr(light.Text().Hex),
|
||||||
|
},
|
||||||
|
Margin: uintPtr(defaultMargin),
|
||||||
|
},
|
||||||
|
BlockQuote: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Yellow().Hex),
|
||||||
|
Italic: boolPtr(true),
|
||||||
|
Prefix: "┃ ",
|
||||||
|
},
|
||||||
|
Indent: uintPtr(1),
|
||||||
|
Margin: uintPtr(defaultMargin),
|
||||||
|
},
|
||||||
|
List: ansi.StyleList{
|
||||||
|
LevelIndent: defaultMargin,
|
||||||
|
StyleBlock: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Text().Hex),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Heading: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
BlockSuffix: "\n",
|
||||||
|
Color: stringPtr(light.Mauve().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H1: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "# ",
|
||||||
|
Color: stringPtr(light.Lavender().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
BlockPrefix: "\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H2: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "## ",
|
||||||
|
Color: stringPtr(light.Mauve().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H3: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "### ",
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H4: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "#### ",
|
||||||
|
Color: stringPtr(light.Flamingo().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H5: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "##### ",
|
||||||
|
Color: stringPtr(light.Rosewater().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
H6: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: "###### ",
|
||||||
|
Color: stringPtr(light.Rosewater().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Strikethrough: ansi.StylePrimitive{
|
||||||
|
CrossedOut: boolPtr(true),
|
||||||
|
Color: stringPtr(light.Overlay1().Hex),
|
||||||
|
},
|
||||||
|
Emph: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Yellow().Hex),
|
||||||
|
Italic: boolPtr(true),
|
||||||
|
},
|
||||||
|
Strong: ansi.StylePrimitive{
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
Color: stringPtr(light.Peach().Hex),
|
||||||
|
},
|
||||||
|
HorizontalRule: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Overlay0().Hex),
|
||||||
|
Format: "\n─────────────────────────────────────────\n",
|
||||||
|
},
|
||||||
|
Item: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: "• ",
|
||||||
|
Color: stringPtr(light.Blue().Hex),
|
||||||
|
},
|
||||||
|
Enumeration: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: ". ",
|
||||||
|
Color: stringPtr(light.Sky().Hex),
|
||||||
|
},
|
||||||
|
Task: ansi.StyleTask{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{},
|
||||||
|
Ticked: "[✓] ",
|
||||||
|
Unticked: "[ ] ",
|
||||||
|
},
|
||||||
|
Link: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Sky().Hex),
|
||||||
|
Underline: boolPtr(true),
|
||||||
|
},
|
||||||
|
LinkText: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
Image: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Sapphire().Hex),
|
||||||
|
Underline: boolPtr(true),
|
||||||
|
Format: "🖼 {{.text}}",
|
||||||
|
},
|
||||||
|
ImageText: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
Format: "{{.text}}",
|
||||||
|
},
|
||||||
|
Code: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Green().Hex),
|
||||||
|
Prefix: " ",
|
||||||
|
Suffix: " ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CodeBlock: ansi.StyleCodeBlock{
|
||||||
|
StyleBlock: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
Prefix: " ",
|
||||||
|
Color: stringPtr(light.Text().Hex),
|
||||||
|
},
|
||||||
|
|
||||||
|
Margin: uintPtr(defaultMargin),
|
||||||
|
},
|
||||||
|
Chroma: &ansi.Chroma{
|
||||||
|
Text: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Text().Hex),
|
||||||
|
},
|
||||||
|
Error: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Text().Hex),
|
||||||
|
},
|
||||||
|
Comment: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Overlay1().Hex),
|
||||||
|
},
|
||||||
|
CommentPreproc: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
},
|
||||||
|
Keyword: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
},
|
||||||
|
KeywordReserved: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
},
|
||||||
|
KeywordNamespace: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
},
|
||||||
|
KeywordType: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Sky().Hex),
|
||||||
|
},
|
||||||
|
Operator: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
},
|
||||||
|
Punctuation: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Text().Hex),
|
||||||
|
},
|
||||||
|
Name: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Sky().Hex),
|
||||||
|
},
|
||||||
|
NameBuiltin: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Sky().Hex),
|
||||||
|
},
|
||||||
|
NameTag: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
},
|
||||||
|
NameAttribute: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Green().Hex),
|
||||||
|
},
|
||||||
|
NameClass: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Sky().Hex),
|
||||||
|
},
|
||||||
|
NameConstant: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Mauve().Hex),
|
||||||
|
},
|
||||||
|
NameDecorator: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Green().Hex),
|
||||||
|
},
|
||||||
|
NameFunction: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Green().Hex),
|
||||||
|
},
|
||||||
|
LiteralNumber: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Teal().Hex),
|
||||||
|
},
|
||||||
|
LiteralString: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Yellow().Hex),
|
||||||
|
},
|
||||||
|
LiteralStringEscape: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Pink().Hex),
|
||||||
|
},
|
||||||
|
GenericDeleted: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Red().Hex),
|
||||||
|
},
|
||||||
|
GenericEmph: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Yellow().Hex),
|
||||||
|
Italic: boolPtr(true),
|
||||||
|
},
|
||||||
|
GenericInserted: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Green().Hex),
|
||||||
|
},
|
||||||
|
GenericStrong: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Peach().Hex),
|
||||||
|
Bold: boolPtr(true),
|
||||||
|
},
|
||||||
|
GenericSubheading: ansi.StylePrimitive{
|
||||||
|
Color: stringPtr(light.Mauve().Hex),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Table: ansi.StyleTable{
|
||||||
|
StyleBlock: ansi.StyleBlock{
|
||||||
|
StylePrimitive: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: "\n",
|
||||||
|
BlockSuffix: "\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CenterSeparator: stringPtr("┼"),
|
||||||
|
ColumnSeparator: stringPtr("│"),
|
||||||
|
RowSeparator: stringPtr("─"),
|
||||||
|
},
|
||||||
|
DefinitionDescription: ansi.StylePrimitive{
|
||||||
|
BlockPrefix: "\n ❯ ",
|
||||||
|
Color: stringPtr(light.Sapphire().Hex),
|
||||||
|
},
|
||||||
|
}
|
121
internal/tui/styles/styles.go
Normal file
121
internal/tui/styles/styles.go
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
package styles
|
||||||
|
|
||||||
|
import (
|
||||||
|
catppuccin "github.com/catppuccin/go"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
light = catppuccin.Latte
|
||||||
|
dark = catppuccin.Mocha
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Regular = lipgloss.NewStyle()
|
||||||
|
Bold = Regular.Bold(true)
|
||||||
|
Padded = Regular.Padding(0, 1)
|
||||||
|
|
||||||
|
Border = Regular.Border(lipgloss.NormalBorder())
|
||||||
|
ThickBorder = Regular.Border(lipgloss.ThickBorder())
|
||||||
|
DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
|
||||||
|
// Colors
|
||||||
|
|
||||||
|
Surface0 = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Surface0().Hex,
|
||||||
|
Light: light.Surface0().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Overlay0 = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Overlay0().Hex,
|
||||||
|
Light: light.Overlay0().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ovelay1 = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Overlay1().Hex,
|
||||||
|
Light: light.Overlay1().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Text = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Text().Hex,
|
||||||
|
Light: light.Text().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
SubText0 = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Subtext0().Hex,
|
||||||
|
Light: light.Subtext0().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
SubText1 = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Subtext1().Hex,
|
||||||
|
Light: light.Subtext1().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
LightGrey = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Surface0().Hex,
|
||||||
|
Light: light.Surface0().Hex,
|
||||||
|
}
|
||||||
|
Grey = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Surface1().Hex,
|
||||||
|
Light: light.Surface1().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
DarkGrey = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Surface2().Hex,
|
||||||
|
Light: light.Surface2().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Base = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Base().Hex,
|
||||||
|
Light: light.Base().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Crust = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Crust().Hex,
|
||||||
|
Light: light.Crust().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Blue = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Blue().Hex,
|
||||||
|
Light: light.Blue().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Red = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Red().Hex,
|
||||||
|
Light: light.Red().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Green = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Green().Hex,
|
||||||
|
Light: light.Green().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Mauve = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Mauve().Hex,
|
||||||
|
Light: light.Mauve().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Teal = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Teal().Hex,
|
||||||
|
Light: light.Teal().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Rosewater = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Rosewater().Hex,
|
||||||
|
Light: light.Rosewater().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Flamingo = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Flamingo().Hex,
|
||||||
|
Light: light.Flamingo().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Lavender = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Lavender().Hex,
|
||||||
|
Light: light.Lavender().Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
Peach = lipgloss.AdaptiveColor{
|
||||||
|
Dark: dark.Peach().Hex,
|
||||||
|
Light: light.Peach().Hex,
|
||||||
|
}
|
||||||
|
)
|
99
internal/tui/tui.go
Normal file
99
internal/tui/tui.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||||
|
"github.com/kujtimiihoxha/termai/internal/tui/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyMap struct {
|
||||||
|
Logs key.Binding
|
||||||
|
Back key.Binding
|
||||||
|
Quit key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = keyMap{
|
||||||
|
Logs: key.NewBinding(
|
||||||
|
key.WithKeys("L"),
|
||||||
|
key.WithHelp("L", "logs"),
|
||||||
|
),
|
||||||
|
Back: key.NewBinding(
|
||||||
|
key.WithKeys("esc"),
|
||||||
|
key.WithHelp("esc", "back"),
|
||||||
|
),
|
||||||
|
Quit: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+c", "q"),
|
||||||
|
key.WithHelp("ctrl+c/q", "quit"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
type appModel struct {
|
||||||
|
width, height int
|
||||||
|
currentPage page.PageID
|
||||||
|
previousPage page.PageID
|
||||||
|
pages map[page.PageID]tea.Model
|
||||||
|
loadedPages map[page.PageID]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a appModel) Init() tea.Cmd {
|
||||||
|
cmd := a.pages[a.currentPage].Init()
|
||||||
|
a.loadedPages[a.currentPage] = true
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
a.width, a.height = msg.Width, msg.Height
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if key.Matches(msg, keys.Quit) {
|
||||||
|
return a, tea.Quit
|
||||||
|
}
|
||||||
|
if key.Matches(msg, keys.Back) {
|
||||||
|
if a.previousPage != "" {
|
||||||
|
return a, a.moveToPage(a.previousPage)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
if key.Matches(msg, keys.Logs) {
|
||||||
|
return a, a.moveToPage(page.LogsPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, cmd := a.pages[a.currentPage].Update(msg)
|
||||||
|
if p != nil {
|
||||||
|
a.pages[a.currentPage] = p
|
||||||
|
}
|
||||||
|
return a, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if _, ok := a.loadedPages[pageID]; !ok {
|
||||||
|
cmd = a.pages[pageID].Init()
|
||||||
|
a.loadedPages[pageID] = true
|
||||||
|
}
|
||||||
|
a.previousPage = a.currentPage
|
||||||
|
a.currentPage = pageID
|
||||||
|
if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
|
||||||
|
sizable.SetSize(a.width, a.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a appModel) View() string {
|
||||||
|
return a.pages[a.currentPage].View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() tea.Model {
|
||||||
|
return &appModel{
|
||||||
|
currentPage: page.ReplPage,
|
||||||
|
loadedPages: make(map[page.PageID]bool),
|
||||||
|
pages: map[page.PageID]tea.Model{
|
||||||
|
page.LogsPage: page.NewLogsPage(),
|
||||||
|
page.InitPage: page.NewInitPage(),
|
||||||
|
page.ReplPage: page.NewReplPage(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue