This is an automated email from the ASF dual-hosted git repository. sruehl pushed a commit to branch feat/charmbraclet_migration in repository https://gitbox.apache.org/repos/asf/plc4x-extras.git
commit ee188f5e72bcede567cd29e5e8b819e3a21cc249 Author: Sebastian Rühl <[email protected]> AuthorDate: Tue Nov 25 12:51:04 2025 +0100 feat(plc4go): charmbracelet support for plc4xbrowser --- plc4go/go.mod | 16 + plc4go/go.sum | 32 ++ plc4go/tools/plc4xbrowser/main.go | 20 +- plc4go/tools/plc4xbrowser/ui/actions.go | 23 +- plc4go/tools/plc4xbrowser/ui/commands.go | 18 +- plc4go/tools/plc4xbrowser/ui/common.go | 12 + plc4go/tools/plc4xbrowser/ui/dispatcher.go | 59 ++ plc4go/tools/plc4xbrowser/ui/log_buffer.go | 65 +++ plc4go/tools/plc4xbrowser/ui/output.go | 39 ++ plc4go/tools/plc4xbrowser/ui/program.go | 862 +++++++++++++++++++++++++++++ 10 files changed, 1134 insertions(+), 12 deletions(-) diff --git a/plc4go/go.mod b/plc4go/go.mod index 7346a47..69853e4 100644 --- a/plc4go/go.mod +++ b/plc4go/go.mod @@ -23,6 +23,9 @@ go 1.25 require ( github.com/apache/plc4x/plc4go v0.0.0-20251124092144-6738c00ca1a7 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/fatih/color v1.18.0 github.com/gdamore/tcell/v2 v2.12.0 github.com/gopacket/gopacket v1.5.0 @@ -39,11 +42,18 @@ require ( require ( github.com/IBM/netaddr v1.5.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bitfield/gotestdox v0.2.2 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/chigopher/pathlib v0.19.1 // indirect github.com/cstockton/go-conv v1.0.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dnephin/pflag v1.0.7 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -60,9 +70,14 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // 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/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -74,6 +89,7 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/vektra/mockery/v2 v2.53.2 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/mod v0.30.0 // indirect diff --git a/plc4go/go.sum b/plc4go/go.sum index 65bf061..bab20c5 100644 --- a/plc4go/go.sum +++ b/plc4go/go.sum @@ -6,8 +6,26 @@ github.com/antchfx/xpath v0.0.0-20170515025933-1f3266e77307 h1:C735MoY/X+UOx6SEC github.com/antchfx/xpath v0.0.0-20170515025933-1f3266e77307/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= github.com/apache/plc4x/plc4go v0.0.0-20251124092144-6738c00ca1a7 h1:kDoe4hT0iZcB/2+mbHPnLdPCEkmeqPUCGxaxMFw+5CA= github.com/apache/plc4x/plc4go v0.0.0-20251124092144-6738c00ca1a7/go.mod h1:yDBaYPcJ6jyhjEAMVN0TryBpyKAmpxYF/3MQ9IXhWmY= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +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/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +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/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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/chigopher/pathlib v0.19.1 h1:RoLlUJc0CqBGwq239cilyhxPNLXTK+HXoASGyGznx5A= @@ -21,6 +39,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= +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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -75,6 +95,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= @@ -83,6 +105,12 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -94,6 +122,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +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/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= @@ -130,6 +159,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/vektra/mockery/v2 v2.53.2 h1:4G/4fl9x722Yb8hLqH1YU3XZNRJFwl5KUMvpkmAyuC8= github.com/vektra/mockery/v2 v2.53.2/go.mod h1:UJT+mgXhCcOCHXTnM5cJHCZL+d76BYB+EbY1sFztEB8= +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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -155,6 +186,7 @@ golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/plc4go/tools/plc4xbrowser/main.go b/plc4go/tools/plc4xbrowser/main.go index 7a1cd1b..d79c011 100644 --- a/plc4go/tools/plc4xbrowser/main.go +++ b/plc4go/tools/plc4xbrowser/main.go @@ -20,16 +20,28 @@ package main import ( + "flag" "github.com/apache/plc4x-extras/plc4go/tools/plc4xbrowser/ui" ) func main() { + flag.Parse() ui.LoadConfig() - application := ui.SetupApplication() - ui.InitSubsystem() - if err := application.Run(); err != nil { + if ui.IsLegacyUI() { + application := ui.SetupApplication() + ui.InitSubsystem() + defer ui.Shutdown() + if err := application.Run(); err != nil { + panic(err) + } + return + } + + program := ui.SetupProgram() + ui.InitSubsystem() + defer ui.Shutdown() + if _, err := program.Run(); err != nil { panic(err) } - ui.Shutdown() } diff --git a/plc4go/tools/plc4xbrowser/ui/actions.go b/plc4go/tools/plc4xbrowser/ui/actions.go index 1355b46..1bb3443 100644 --- a/plc4go/tools/plc4xbrowser/ui/actions.go +++ b/plc4go/tools/plc4xbrowser/ui/actions.go @@ -48,7 +48,11 @@ func InitSubsystem() { //With().Caller().Logger(). Output(zerolog.NewConsoleWriter( func(w *zerolog.ConsoleWriter) { - w.Out = tview.ANSIWriter(consoleOutput) + out := consoleOutput + if IsLegacyUI() { + out = tview.ANSIWriter(out) + } + w.Out = out }, func(w *zerolog.ConsoleWriter) { w.FormatFieldValue = func(i interface{}) string { @@ -76,7 +80,9 @@ func InitSubsystem() { // We offset the commands executed with the last commands commandsExecuted = len(config.History.Last10Commands) - outputCommandHistory() + if IsLegacyUI() { + outputCommandHistory() + } for _, driver := range config.AutoRegisterDrivers { log.Info().Str("driver", driver).Msg("Auto register driver") @@ -89,9 +95,16 @@ func InitSubsystem() { } func outputCommandHistory() { - _, _ = fmt.Fprintln(commandOutput, "[#0000ff]Last 10 commands[white]") - for i, command := range config.History.Last10Commands { - _, _ = fmt.Fprintf(commandOutput, " [#00ff00]%d[white]: [\"%d\"]%s[\"\"]\n", i, i, tview.Escape(command)) + if IsLegacyUI() { + _, _ = fmt.Fprintln(commandOutput, "[#0000ff]Last 10 commands[white]") + for i, command := range config.History.Last10Commands { + _, _ = fmt.Fprintf(commandOutput, " [#00ff00]%d[white]: [\"%d\"]%s[\"\"]\n", i, i, tview.Escape(command)) + } + } else { + _, _ = fmt.Fprintln(commandOutput, "Last 10 commands") + for i, command := range config.History.Last10Commands { + _, _ = fmt.Fprintf(commandOutput, " %d: %s\n", i, command) + } } } diff --git a/plc4go/tools/plc4xbrowser/ui/commands.go b/plc4go/tools/plc4xbrowser/ui/commands.go index 5637dc8..db02688 100644 --- a/plc4go/tools/plc4xbrowser/ui/commands.go +++ b/plc4go/tools/plc4xbrowser/ui/commands.go @@ -530,7 +530,11 @@ var rootCommand = Command{ Name: "on", Description: "debug on", action: func(ctx context.Context, _ Command, _ string) error { - plc4xBrowserLog = zerolog.New(zerolog.ConsoleWriter{Out: tview.ANSIWriter(consoleOutput)}) + out := consoleOutput + if IsLegacyUI() { + out = tview.ANSIWriter(out) + } + plc4xBrowserLog = zerolog.New(zerolog.ConsoleWriter{Out: out}) return nil }, }, @@ -640,14 +644,22 @@ func init() { Name: "help", Description: "prints out this help", action: func(_ context.Context, _ Command, _ string) error { - _, _ = fmt.Fprintf(commandOutput, "[#0000ff]Available commands[white]\n") + if IsLegacyUI() { + _, _ = fmt.Fprintf(commandOutput, "[#0000ff]Available commands[white]\n") + } else { + _, _ = fmt.Fprintln(commandOutput, "Available commands") + } rootCommand.visit(0, func(currentIndent int, command Command) { indentString := strings.Repeat(" ", currentIndent) description := command.Description if description == "" { description = command.Name + "s" } - _, _ = fmt.Fprintf(commandOutput, "%s [#00ff00]%s[white]: %s\n", indentString, command.Name, description) + if IsLegacyUI() { + _, _ = fmt.Fprintf(commandOutput, "%s [#00ff00]%s[white]: %s\n", indentString, command.Name, description) + } else { + _, _ = fmt.Fprintf(commandOutput, "%s%s: %s\n", indentString, command.Name, description) + } }) return nil }, diff --git a/plc4go/tools/plc4xbrowser/ui/common.go b/plc4go/tools/plc4xbrowser/ui/common.go index 935219c..6d97e68 100644 --- a/plc4go/tools/plc4xbrowser/ui/common.go +++ b/plc4go/tools/plc4xbrowser/ui/common.go @@ -20,6 +20,7 @@ package ui import ( + "flag" "io" "strings" "sync" @@ -30,10 +31,21 @@ import ( "github.com/rs/zerolog" ) +var legacyUI = flag.Bool("legacy-ui", true, "Use the legacy tview-based UI") + +func IsLegacyUI() bool { + if legacyUI == nil { + return false + } + return *legacyUI +} + const protocols = "ads,bacnetip,c-bus,opcua,s7" var protocolList = strings.Split(protocols, ",") +var dispatcher = newDispatcher() + var plc4xBrowserLog = zerolog.Nop() var driverManager plc4go.PlcDriverManager diff --git a/plc4go/tools/plc4xbrowser/ui/dispatcher.go b/plc4go/tools/plc4xbrowser/ui/dispatcher.go new file mode 100644 index 0000000..adf4f06 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/ui/dispatcher.go @@ -0,0 +1,59 @@ +package ui + +import ( + "sync" + + tea "github.com/charmbracelet/bubbletea" +) + +// messageDispatcher buffers UI messages until the Bubble Tea program is ready +// and forwards them once it is. This keeps the rest of the code decoupled from +// the program lifecycle and preserves behaviour when subsystems write to the UI +// before the program starts running. +type messageDispatcher struct { + mu sync.Mutex + program *tea.Program + buffer []tea.Msg + queue chan tea.Msg +} + +func newDispatcher() *messageDispatcher { + d := &messageDispatcher{ + queue: make(chan tea.Msg, 128), + } + go d.run() + return d +} + +func (d *messageDispatcher) setProgram(program *tea.Program) { + d.mu.Lock() + d.program = program + buffered := append([]tea.Msg(nil), d.buffer...) + d.buffer = nil + d.mu.Unlock() + + go func() { + for _, msg := range buffered { + d.queue <- msg + } + }() +} + +func (d *messageDispatcher) send(msg tea.Msg) { + d.queue <- msg +} + +func (d *messageDispatcher) run() { + for msg := range d.queue { + d.mu.Lock() + program := d.program + if program == nil { + d.buffer = append(d.buffer, msg) + d.mu.Unlock() + continue + } + d.mu.Unlock() + + program.Send(msg) + } +} diff --git a/plc4go/tools/plc4xbrowser/ui/log_buffer.go b/plc4go/tools/plc4xbrowser/ui/log_buffer.go new file mode 100644 index 0000000..72c0f48 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/ui/log_buffer.go @@ -0,0 +1,65 @@ +package ui + +// logBuffer keeps a rolling window of lines while handling fragmented writes. +// It is used for the command, console and message outputs. +type logBuffer struct { + lines []string + max int +} + +func newLogBuffer(max int) logBuffer { + return logBuffer{lines: []string{""}, max: max} +} + +func (b *logBuffer) append(text string) { + if len(b.lines) == 0 { + b.lines = []string{""} + } + remaining := text + for len(remaining) > 0 { + newline := -1 + for i := 0; i < len(remaining); i++ { + if remaining[i] == '\n' { + newline = i + break + } + } + if newline == -1 { + b.lines[len(b.lines)-1] += remaining + break + } + b.lines[len(b.lines)-1] += remaining[:newline] + b.lines = append(b.lines, "") + remaining = remaining[newline+1:] + } + b.trim() +} + +func (b *logBuffer) clear() { + b.lines = []string{""} +} + +func (b *logBuffer) trim() { + if b.max <= 0 { + return + } + if len(b.lines) <= b.max { + return + } + offset := len(b.lines) - b.max + b.lines = append([]string(nil), b.lines[offset:]...) + if len(b.lines) == 0 { + b.lines = []string{""} + } +} + +func (b logBuffer) String() string { + result := "" + for i, line := range b.lines { + if i > 0 { + result += "\n" + } + result += line + } + return result +} diff --git a/plc4go/tools/plc4xbrowser/ui/output.go b/plc4go/tools/plc4xbrowser/ui/output.go new file mode 100644 index 0000000..9663827 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/ui/output.go @@ -0,0 +1,39 @@ +package ui + +import "io" + +type outputTarget int + +const ( + targetCommand outputTarget = iota + targetConsole + targetMessage +) + +type appendOutputMsg struct { + target outputTarget + text string +} + +type clearOutputMsg struct { + target outputTarget +} + +type programWriter struct { + target outputTarget +} + +func (w *programWriter) Write(p []byte) (int, error) { + dispatcher.send(appendOutputMsg{target: w.target, text: string(p)}) + return len(p), nil +} + +func newWriter(target outputTarget) io.Writer { + return &programWriter{target: target} +} + +func makeClearFunc(target outputTarget) func() { + return func() { + dispatcher.send(clearOutputMsg{target: target}) + } +} diff --git a/plc4go/tools/plc4xbrowser/ui/program.go b/plc4go/tools/plc4xbrowser/ui/program.go new file mode 100644 index 0000000..8e456fa --- /dev/null +++ b/plc4go/tools/plc4xbrowser/ui/program.go @@ -0,0 +1,862 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ui + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + plc4go "github.com/apache/plc4x/plc4go/pkg/api" + apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type driverEntry struct { + Code string + Description string +} + +type messageEntry struct { + Number int + Timestamp time.Time +} + +type connectionsUpdatedMsg struct { + Connections []string +} + +type driversUpdatedMsg struct { + Drivers []driverEntry +} + +type messageArrivedMsg struct { + Entry messageEntry +} + +type commandExecResultMsg struct { + Command string + Err error +} + +var commandHistoryShortcut = regexp.MustCompile("^[0-9]$") + +var ( + textColor = lipgloss.Color("15") + accentColor = lipgloss.Color("12") + highlightColor = lipgloss.Color("10") + placeholderColor = lipgloss.Color("242") + + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(textColor) + subtitleStyle = lipgloss.NewStyle().Foreground(textColor).Align(lipgloss.Center) + sectionTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(accentColor) + sectionBodyStyle = lipgloss.NewStyle().MarginLeft(2) + suggestionStyle = lipgloss.NewStyle().MarginLeft(2).Foreground(highlightColor) + columnTitleStyle = lipgloss.NewStyle().Bold(true).Align(lipgloss.Center).Foreground(textColor) + panelStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(textColor).Padding(0, 1) + placeholderStyle = lipgloss.NewStyle().MarginLeft(2).Foreground(placeholderColor).Italic(true) + historyIndexStyle = lipgloss.NewStyle().Foreground(highlightColor).Bold(true).MarginLeft(2).Width(2).Align(lipgloss.Right) + historyGapStyle = lipgloss.NewStyle().Foreground(textColor) + historyTextStyle = lipgloss.NewStyle().Foreground(textColor) +) + +type model struct { + width int + height int + + commandLog logBuffer + consoleLog logBuffer + messageLog logBuffer + + connections []string + drivers []driverEntry + messages []messageEntry + + commandInput textinput.Model + history []string + historyIndex int + + suggestions []string + suggestionIndex int + + commandViewport viewport.Model + payloadViewport viewport.Model + consoleViewport viewport.Model +} + +func newModel() *model { + input := textinput.New() + input.Prompt = "$ " + input.Placeholder = "" + input.CharLimit = 0 + input.Focus() + + history := append([]string(nil), config.History.Last10Commands...) + + vp := viewport.New(0, 0) + vp.Style = lipgloss.NewStyle() + + payload := viewport.New(0, 0) + payload.Style = lipgloss.NewStyle() + + console := viewport.New(0, 0) + console.Style = lipgloss.NewStyle() + + mdl := &model{ + commandLog: newLogBuffer(config.MaxOutputLines), + consoleLog: newLogBuffer(config.MaxConsoleLines), + messageLog: newLogBuffer(config.MaxOutputLines), + connections: snapshotConnections(), + drivers: snapshotDrivers(), + commandInput: input, + history: history, + historyIndex: len(history), + suggestions: nil, + suggestionIndex: -1, + commandViewport: vp, + payloadViewport: payload, + consoleViewport: console, + } + + mdl.updateCommandViewportContent() + mdl.updatePayloadViewportContent() + mdl.updateConsoleViewportContent() + + return mdl +} + +func SetupProgram() *tea.Program { + mdl := newModel() + program := tea.NewProgram(mdl, tea.WithAltScreen(), tea.WithMouseCellMotion()) + dispatcher.setProgram(program) + + commandOutput = newWriter(targetCommand) + commandOutputClear = makeClearFunc(targetCommand) + consoleOutput = newWriter(targetConsole) + consoleOutputClear = makeClearFunc(targetConsole) + messageOutput = newWriter(targetMessage) + messageOutputClear = makeClearFunc(targetMessage) + + driverAdded = func(driver plc4go.PlcDriver) { + dispatcher.send(driversUpdatedMsg{Drivers: snapshotDrivers()}) + } + connectionsChanged = func() { + dispatcher.send(connectionsUpdatedMsg{Connections: snapshotConnections()}) + } + messageReceived = func(messageNumber int, receiveTime time.Time, message apiModel.PlcMessage) { + dispatcher.send(messageArrivedMsg{Entry: messageEntry{Number: messageNumber, Timestamp: receiveTime}}) + fmt.Fprintf(messageOutput, "Message #%d (%s)\n%v\n", messageNumber, receiveTime.Format("15:04:05.999999"), message) + } + + // Ensure the current state is reflected before the program starts. + dispatcher.send(connectionsUpdatedMsg{Connections: snapshotConnections()}) + dispatcher.send(driversUpdatedMsg{Drivers: snapshotDrivers()}) + + return program +} + +func (m *model) Init() tea.Cmd { + return nil +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.updateLayout() + case tea.KeyMsg: + return m.handleKey(msg) + case tea.MouseMsg: + var cmds []tea.Cmd + var cmd tea.Cmd + m.commandViewport, cmd = m.commandViewport.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + m.payloadViewport, cmd = m.payloadViewport.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + m.consoleViewport, cmd = m.consoleViewport.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + case appendOutputMsg: + m.handleAppendOutput(msg) + case clearOutputMsg: + m.handleClearOutput(msg) + case connectionsUpdatedMsg: + m.connections = append([]string(nil), msg.Connections...) + case driversUpdatedMsg: + m.drivers = append([]driverEntry(nil), msg.Drivers...) + case messageArrivedMsg: + m.messages = append(m.messages, msg.Entry) + if len(m.messages) > 200 { + m.messages = append([]messageEntry(nil), m.messages[len(m.messages)-200:]...) + } + m.updatePayloadViewportContent() + case commandExecResultMsg: + if msg.Err != nil { + fmt.Fprintf(commandOutput, "%s %v\n", time.Now().Format("15:04"), msg.Err) + return m, nil + } + m.commandInput.SetValue("") + m.commandInput.CursorEnd() + m.history = append(m.history, msg.Command) + if len(m.history) > 100 { + m.history = append([]string(nil), m.history[len(m.history)-100:]...) + } + m.historyIndex = len(m.history) + m.updateLayout() + } + return m, nil +} + +func (m *model) View() string { + width := m.width + if width <= 0 { + width = 120 + } + header := titleStyle.Align(lipgloss.Center).Width(width).PaddingBottom(1).Render("PLC4X Browser") + footer := subtitleStyle.Width(width).PaddingTop(1).Render("https://github.com/apache/plc4x") + availableHeight := m.height - lipgloss.Height(header) - lipgloss.Height(footer) + if availableHeight < 8 { + availableHeight = m.height / 2 + if availableHeight < 8 { + availableHeight = 8 + } + } + + const gapWidth = 0 + leftWidth, centerWidth, rightWidth := computeColumnWidths(width, gapWidth) + + leftPanel := renderPanel("", []panelSection{ + {title: "Connections", body: renderList(m.connections)}, + {title: "Registered drivers", body: renderDrivers(m.drivers)}, + }, max(leftWidth-2, 0), availableHeight) + + centerPanel := m.renderOutputPanel(max(centerWidth-2, 0), availableHeight) + + rightPanel := m.renderCommandPanel(max(rightWidth-2, 0), availableHeight) + + gap := lipgloss.NewStyle().Width(gapWidth).Render("") + columns := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, gap, centerPanel, gap, rightPanel) + columns = lipgloss.Place(width, availableHeight, lipgloss.Center, lipgloss.Top, columns) + content := lipgloss.JoinVertical(lipgloss.Left, header, columns, footer) + if m.height > 0 { + content = lipgloss.Place(width, m.height, lipgloss.Center, lipgloss.Top, content) + } + return content +} + +func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC, tea.KeyCtrlD: + return m, tea.Quit + case tea.KeyEnter: + return m.submitCommand() + case tea.KeyTab: + return m.completeCommand(false) + case tea.KeyShiftTab: + return m.completeCommand(true) + case tea.KeyPgUp: + m.commandViewport.PageUp() + return m, nil + case tea.KeyPgDown: + m.commandViewport.PageDown() + return m, nil + case tea.KeyHome: + m.commandViewport.GotoTop() + return m, nil + case tea.KeyEnd: + m.commandViewport.GotoBottom() + return m, nil + case tea.KeyUp: + if msg.Alt { + m.commandViewport.ScrollUp(1) + return m, nil + } + m.usePreviousHistory() + return m, nil + case tea.KeyDown: + if msg.Alt { + m.commandViewport.ScrollDown(1) + return m, nil + } + m.useNextHistory() + return m, nil + default: + oldValue := m.commandInput.Value() + var cmd tea.Cmd + m.commandInput, cmd = m.commandInput.Update(msg) + if m.commandInput.Value() != oldValue { + m.suggestions = nil + m.suggestionIndex = -1 + m.updateLayout() + } + return m, cmd + } +} + +func (m *model) submitCommand() (tea.Model, tea.Cmd) { + commandText := strings.TrimSpace(m.commandInput.Value()) + if commandText == "" { + return m, nil + } + if commandText == "quit" { + return m, tea.Quit + } + commandsExecuted++ + resolved, err := resolveHistoryShortcut(commandText) + if err != nil { + fmt.Fprintf(commandOutput, "%s %v\n", time.Now().Format("15:04"), err) + return m, nil + } + fmt.Fprintf(commandOutput, "%s %s\n", time.Now().Format("15:04"), resolved) + m.suggestions = nil + m.suggestionIndex = -1 + m.updateLayout() + return m, executeCommand(resolved) +} + +func (m *model) completeCommand(reverse bool) (tea.Model, tea.Cmd) { + current := m.commandInput.Value() + if len(m.suggestions) == 0 { + m.suggestions = rootCommand.Completions(current) + m.suggestionIndex = -1 + if len(m.suggestions) == 0 { + return m, nil + } + } + if reverse { + m.suggestionIndex-- + if m.suggestionIndex < 0 { + m.suggestionIndex = len(m.suggestions) - 1 + } + } else { + m.suggestionIndex = (m.suggestionIndex + 1) % len(m.suggestions) + } + completion := m.suggestions[m.suggestionIndex] + m.commandInput.SetValue(completion) + m.commandInput.CursorEnd() + m.updateLayout() + return m, nil +} + +func (m *model) usePreviousHistory() { + if len(m.history) == 0 { + return + } + if m.historyIndex == 0 { + m.commandInput.SetValue(m.history[0]) + m.commandInput.CursorEnd() + return + } + m.historyIndex-- + m.commandInput.SetValue(m.history[m.historyIndex]) + m.commandInput.CursorEnd() +} + +func (m *model) useNextHistory() { + if len(m.history) == 0 { + return + } + if m.historyIndex >= len(m.history)-1 { + m.historyIndex = len(m.history) + m.commandInput.SetValue("") + m.commandInput.CursorEnd() + return + } + m.historyIndex++ + m.commandInput.SetValue(m.history[m.historyIndex]) + m.commandInput.CursorEnd() +} + +func (m *model) handleAppendOutput(msg appendOutputMsg) { + switch msg.target { + case targetCommand: + m.commandLog.append(msg.text) + m.updateCommandViewportContent() + case targetConsole: + m.consoleLog.append(msg.text) + m.updateConsoleViewportContent() + case targetMessage: + m.messageLog.append(msg.text) + m.updatePayloadViewportContent() + } +} + +func (m *model) handleClearOutput(msg clearOutputMsg) { + switch msg.target { + case targetCommand: + m.commandLog.clear() + m.updateCommandViewportContent() + case targetConsole: + m.consoleLog.clear() + m.updateConsoleViewportContent() + case targetMessage: + m.messageLog.clear() + m.updatePayloadViewportContent() + } +} + +func (m *model) renderCommandPanel(width, height int) string { + panel := panelStyle + if width > 0 { + panel = panel.Width(width) + } + if height > 0 { + panel = panel.Height(height) + } + + innerWidth := width + if width > 0 { + frameWidth, _ := panelStyle.GetFrameSize() + innerWidth = max(width-frameWidth, 0) + } + + parts := []string{ + columnTitleStyle.Width(innerWidth).Render("Commands"), + m.commandViewport.View(), + "", + sectionTitleStyle.Render("Command Input"), + sectionBodyStyle.Render(m.commandInput.View()), + } + if len(m.suggestions) > 0 { + suggestions := suggestionStyle.Render(strings.Join(m.suggestions, " ")) + parts = append(parts, "", sectionTitleStyle.Render("Suggestions"), sectionBodyStyle.Render(suggestions)) + } + + body := lipgloss.JoinVertical(lipgloss.Left, parts...) + return panel.Render(body) +} + +func (m *model) renderOutputPanel(width, height int) string { + panel := panelStyle + if width > 0 { + panel = panel.Width(width) + } + if height > 0 { + panel = panel.Height(height) + } + + innerWidth := width + if width > 0 { + frameWidth, _ := panelStyle.GetFrameSize() + innerWidth = max(width-frameWidth, 0) + } + + parts := []string{ + columnTitleStyle.Width(innerWidth).Render("Output"), + m.payloadViewport.View(), + "", + sectionTitleStyle.Render("Console"), + m.consoleViewport.View(), + } + + body := lipgloss.JoinVertical(lipgloss.Left, parts...) + return panel.Render(body) +} + +func (m *model) updateLayout() { + if m.width == 0 && m.height == 0 { + return + } + + width := m.width + if width <= 0 { + width = 120 + } + header := titleStyle.Align(lipgloss.Center).Width(width).Render("PLC4X Browser") + footer := subtitleStyle.Width(width).Render("https://github.com/apache/plc4x") + availableHeight := m.height - lipgloss.Height(header) - lipgloss.Height(footer) + if availableHeight < 8 { + availableHeight = m.height / 2 + if availableHeight < 8 { + availableHeight = 8 + } + } + + const gapWidth = 0 + _, centerWidth, rightWidth := computeColumnWidths(width, gapWidth) + panelFrameWidth, panelFrameHeight := panelStyle.GetFrameSize() + bodyFrameWidth, _ := sectionBodyStyle.GetFrameSize() + + commandPanelWidth := max(rightWidth-2, 0) + commandInnerWidth := commandPanelWidth + if commandPanelWidth > 0 { + commandInnerWidth = max(commandPanelWidth-panelFrameWidth, 0) + } + commandInnerHeight := availableHeight + if availableHeight > 0 { + commandInnerHeight = max(availableHeight-panelFrameHeight, 0) + } + + suggestionsBlock := "" + if len(m.suggestions) > 0 { + suggestionsBlock = suggestionStyle.Render(strings.Join(m.suggestions, " ")) + } + + cmdHeader := columnTitleStyle.Width(commandInnerWidth).Render("Commands") + inputTitle := sectionTitleStyle.Render("Command Input") + inputBody := sectionBodyStyle.Render(m.commandInput.View()) + + commandStaticHeight := lipgloss.Height(cmdHeader) + lipgloss.Height(inputTitle) + lipgloss.Height(inputBody) + 1 + if suggestionsBlock != "" { + suggestionsTitle := sectionTitleStyle.Render("Suggestions") + suggestionsBody := sectionBodyStyle.Render(suggestionsBlock) + commandStaticHeight += 1 + lipgloss.Height(suggestionsTitle) + lipgloss.Height(suggestionsBody) + } + + commandViewportHeight := commandInnerHeight - commandStaticHeight + if commandViewportHeight < 0 { + commandViewportHeight = 0 + } + + commandViewportWidth := commandInnerWidth - bodyFrameWidth + if commandViewportWidth < 0 { + commandViewportWidth = 0 + } + + m.commandViewport.Width = commandViewportWidth + m.commandViewport.Height = commandViewportHeight + m.updateCommandViewportContent() + + outputPanelWidth := max(centerWidth-2, 0) + outputInnerWidth := outputPanelWidth + if outputPanelWidth > 0 { + outputInnerWidth = max(outputPanelWidth-panelFrameWidth, 0) + } + outputInnerHeight := availableHeight + if availableHeight > 0 { + outputInnerHeight = max(availableHeight-panelFrameHeight, 0) + } + + outputHeader := columnTitleStyle.Width(outputInnerWidth).Render("Output") + consoleTitle := sectionTitleStyle.Render("Console") + fixedHeight := lipgloss.Height(outputHeader) + 1 + lipgloss.Height(consoleTitle) + + const ( + minPayloadHeight = 6 + minConsoleHeight = 4 + preferredConsoleHeight = 10 + ) + + availableForViewports := outputInnerHeight - fixedHeight + if availableForViewports < 0 { + availableForViewports = 0 + } + + var payloadViewportHeight, consoleViewportHeight int + if availableForViewports == 0 { + payloadViewportHeight, consoleViewportHeight = 0, 0 + } else { + payloadViewportHeight = minPayloadHeight + consoleViewportHeight = minConsoleHeight + if payloadViewportHeight+consoleViewportHeight > availableForViewports { + payloadViewportHeight = min(availableForViewports, minPayloadHeight) + if payloadViewportHeight < 0 { + payloadViewportHeight = 0 + } + consoleViewportHeight = availableForViewports - payloadViewportHeight + if consoleViewportHeight < 0 { + consoleViewportHeight = 0 + } + } else { + remaining := availableForViewports - (payloadViewportHeight + consoleViewportHeight) + extraConsoleCapacity := preferredConsoleHeight - minConsoleHeight + if extraConsoleCapacity > 0 { + extra := min(extraConsoleCapacity, remaining) + consoleViewportHeight += extra + remaining -= extra + } + payloadViewportHeight += remaining + } + } + + payloadViewportWidth := outputInnerWidth - bodyFrameWidth + if payloadViewportWidth < 0 { + payloadViewportWidth = 0 + } + consoleViewportWidth := outputInnerWidth - bodyFrameWidth + if consoleViewportWidth < 0 { + consoleViewportWidth = 0 + } + + m.payloadViewport.Width = payloadViewportWidth + m.payloadViewport.Height = payloadViewportHeight + m.consoleViewport.Width = consoleViewportWidth + m.consoleViewport.Height = consoleViewportHeight + + m.updatePayloadViewportContent() + m.updateConsoleViewportContent() +} + +func (m *model) updateCommandViewportContent() { + logBody := strings.TrimSuffix(m.commandLog.String(), "\n") + logSection := lipgloss.JoinVertical(lipgloss.Left, + sectionTitleStyle.Render("Command Log"), + renderBodyBlock(logBody), + ) + + historySection := lipgloss.JoinVertical(lipgloss.Left, + sectionTitleStyle.Render("Last 10 commands"), + renderCommandHistoryView(m.history), + ) + + content := lipgloss.JoinVertical(lipgloss.Left, logSection, "", historySection) + atBottom := m.commandViewport.AtBottom() + m.commandViewport.SetContent(content) + if atBottom { + m.commandViewport.GotoBottom() + } +} + +func (m *model) updatePayloadViewportContent() { + segments := make([]string, 0, 2) + messageBody := strings.TrimSuffix(m.messageLog.String(), "\n") + if strings.TrimSpace(messageBody) != "" { + segment := lipgloss.JoinVertical(lipgloss.Left, + sectionTitleStyle.Render("Message Log"), + renderBodyBlock(messageBody), + ) + segments = append(segments, segment) + } + if len(m.messages) > 0 { + recent := lipgloss.JoinVertical(lipgloss.Left, + sectionTitleStyle.Render("Recent Messages"), + renderBodyBlock(renderMessages(m.messages)), + ) + segments = append(segments, recent) + } + if len(segments) == 0 { + segments = append(segments, placeholderStyle.Render("(none)")) + } + content := strings.Join(segments, "\n\n") + atBottom := m.payloadViewport.AtBottom() + m.payloadViewport.SetContent(content) + if atBottom { + m.payloadViewport.GotoBottom() + } +} + +func (m *model) updateConsoleViewportContent() { + consoleBody := strings.TrimSuffix(m.consoleLog.String(), "\n") + content := renderBodyBlock(consoleBody) + atBottom := m.consoleViewport.AtBottom() + m.consoleViewport.SetContent(content) + if atBottom { + m.consoleViewport.GotoBottom() + } +} + +func resolveHistoryShortcut(commandText string) (string, error) { + if !commandHistoryShortcut.MatchString(commandText) { + return commandText, nil + } + index, _ := strconv.Atoi(commandHistoryShortcut.FindString(commandText)) + if index >= len(config.History.Last10Commands) { + return "", fmt.Errorf("no such element %d in command history", index) + } + return config.History.Last10Commands[index], nil +} + +func executeCommand(command string) tea.Cmd { + return func() tea.Msg { + err := Execute(command) + return commandExecResultMsg{Command: command, Err: err} + } +} + +func renderList(entries []string) string { + if len(entries) == 0 { + return "(none)" + } + return strings.Join(entries, "\n") +} + +func renderDrivers(entries []driverEntry) string { + if len(entries) == 0 { + return "(none)" + } + rows := make([]string, 0, len(entries)) + for _, entry := range entries { + rows = append(rows, fmt.Sprintf("%s — %s", entry.Code, entry.Description)) + } + return strings.Join(rows, "\n") +} + +func renderMessages(entries []messageEntry) string { + if len(entries) == 0 { + return "(none)" + } + const maxLines = 10 + start := 0 + if len(entries) > maxLines { + start = len(entries) - maxLines + } + rows := make([]string, 0, len(entries)-start) + for _, entry := range entries[start:] { + rows = append(rows, fmt.Sprintf("#%d @ %s", entry.Number, entry.Timestamp.Format("15:04:05"))) + } + return strings.Join(rows, "\n") +} + +func renderCommandHistoryLines(history []string) []string { + start := len(history) - 10 + if start < 0 { + start = 0 + } + rows := make([]string, 0, len(history)-start) + for idx := start; idx < len(history); idx++ { + index := historyIndexStyle.Render(fmt.Sprintf("%d", idx)) + divider := historyGapStyle.Render(": ") + entry := historyTextStyle.Render(history[idx]) + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, index, divider, entry)) + } + return rows +} + +func renderCommandHistoryView(history []string) string { + if len(history) == 0 { + return placeholderStyle.Render("(none)") + } + rows := renderCommandHistoryLines(history) + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func renderBodyBlock(text string) string { + if strings.TrimSpace(text) == "" { + return placeholderStyle.Render("(none)") + } + lines := strings.Split(text, "\n") + styled := make([]string, len(lines)) + for i, line := range lines { + styled[i] = sectionBodyStyle.Render(line) + } + return lipgloss.JoinVertical(lipgloss.Left, styled...) +} + +type panelSection struct { + title string + body string +} + +func renderPanel(title string, sections []panelSection, width, height int) string { + var parts []string + if strings.TrimSpace(title) != "" { + parts = append(parts, columnTitleStyle.Width(width).Render(title)) + } + for i, section := range sections { + if strings.TrimSpace(section.title) != "" { + parts = append(parts, sectionTitleStyle.Render(section.title)) + } + body := section.body + if strings.TrimSpace(body) == "" { + body = placeholderStyle.Render("(none)") + } else if !containsANSI(body) { + body = sectionBodyStyle.Render(body) + } + parts = append(parts, body) + if i != len(sections)-1 { + parts = append(parts, "") + } + } + if len(sections) == 0 { + parts = append(parts, placeholderStyle.Render("(none)")) + } + inner := strings.Join(parts, "\n") + style := panelStyle + if width > 0 { + style = style.Width(width) + } + if height > 0 { + style = style.Height(height) + } + return style.Render(inner) +} + +func containsANSI(body string) bool { + return strings.Contains(body, "\x1b[") +} + +func computeColumnWidths(total, gap int) (int, int, int) { + if total <= 0 { + total = 120 + } + gapsWidth := gap * 2 + usable := total - gapsWidth + if usable < 60 { + usable = 60 + } + leftMin, centerMin, rightMin := 24, 32, 24 + left := max(leftMin, usable/4) + right := max(rightMin, usable/4) + center := usable - left - right + if center < centerMin { + deficit := centerMin - center + if spare := left - leftMin; spare > 0 { + shift := min(deficit, spare) + left -= shift + deficit -= shift + } + if deficit > 0 { + if spare := right - rightMin; spare > 0 { + shift := min(deficit, spare) + right -= shift + deficit -= shift + } + } + center = usable - left - right + } + return left, center, right +} + +func snapshotConnections() []string { + result := make([]string, 0, len(connections)) + for connection := range connections { + result = append(result, connection) + } + sort.Strings(result) + return result +} + +func snapshotDrivers() []driverEntry { + result := make([]driverEntry, 0, len(registeredDrivers)) + for _, driver := range registeredDrivers { + result = append(result, driverEntry{ + Code: driver.GetProtocolCode(), + Description: driver.String(), + }) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Code < result[j].Code + }) + return result +}
