From 22d5e39899b26dca37556f926c482c24f10dc6dc Mon Sep 17 00:00:00 2001 From: IllusionMan1212 Date: Sat, 15 Jan 2022 21:56:06 +0200 Subject: [PATCH] feat: handle more commands and properly implement older ones * fix parser tests * write logs to file * gracefully terminate program * make viewport follow output * properly handle channel nicks and display them in the sidepanel * general refactors and fixes --- .gitignore | 3 + client/client.go | 53 +++++---- commands/commands.go | 14 +-- go.mod | 7 +- go.sum | 24 +++-- main.go | 20 +++- parser/parser_test.go | 210 ++++++++++++++++++++++++++++++------ ui/app/app.go | 10 +- ui/app/cmds.go | 5 - ui/mainscreen/cmds.go | 34 ++++-- ui/mainscreen/input.go | 13 +++ ui/mainscreen/mainscreen.go | 181 +++++++++++++++++++++---------- ui/mainscreen/sidepanel.go | 88 ++++++++++++--- ui/mainscreen/styles.go | 2 - ui/styles.go | 4 +- 15 files changed, 487 insertions(+), 181 deletions(-) diff --git a/.gitignore b/.gitignore index 6e64071..a41b4ce 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ gorc # vscode stuff .vscode + +# log file +gorc.log diff --git a/client/client.go b/client/client.go index a73b027..e155c5e 100644 --- a/client/client.go +++ b/client/client.go @@ -22,11 +22,13 @@ import ( "log" "net" "strings" + + tea "github.com/charmbracelet/bubbletea" ) type User struct { - // Nickname of user - Nick string + // User prefix in channel + Prefix string } type Channel struct { @@ -37,27 +39,28 @@ type Channel struct { History string // Users in this channel - Users []User + // The map key is the user's nickname + // and the user struct holds data about that user + // such as, prefixes in this channel and etc... + Users map[string]User } type Client struct { // tcp connection TcpConn net.Conn - // Channel to receive quit msg - Quit chan struct{} - Host string Port string Nickname string MOTD string // TODO: Channels map[string]Channel // TODO: ActiveChannel string + Tea *tea.Program } const CRLF = "\r\n" -func NewClient(host string, port string, tlsEnabled bool) Client { +func (s *Client) Initialize(host string, port string, tlsEnabled bool) { addr := fmt.Sprintf("%s:%s", host, port) if tlsEnabled { @@ -68,14 +71,12 @@ func NewClient(host string, port string, tlsEnabled bool) Client { log.Fatal(err) } - return Client{ - TcpConn: conn, - Host: host, - Port: port, - Quit: make(chan struct{}), - ActiveChannel: host, - Channels: make(map[string]Channel), - } + s.TcpConn = conn + s.Host = host + s.Port = port + s.ActiveChannel = host + s.Channels = make(map[string]Channel) + return } addrTCP, err := net.ResolveTCPAddr("tcp", addr) @@ -88,14 +89,11 @@ func NewClient(host string, port string, tlsEnabled bool) Client { log.Fatal(err) } - return Client{ - TcpConn: conn, - Host: host, - Port: port, - Quit: make(chan struct{}), - ActiveChannel: host, - Channels: make(map[string]Channel), - } + s.TcpConn = conn + s.Host = host + s.Port = port + s.ActiveChannel = host + s.Channels = make(map[string]Channel) } func (c *Client) Register(nick string, password string, channel string) { @@ -112,11 +110,12 @@ func (c *Client) Register(nick string, password string, channel string) { c.SendCommand("CAP", "END") // joining a channel when registering is optional if channel != "" { - c.SendCommand("JOIN", channel) c.ActiveChannel = channel - c.Channels[c.ActiveChannel] = Channel{} - } else { - c.Channels[c.ActiveChannel] = Channel{} + c.SendCommand("JOIN", channel) + } + + c.Channels[c.ActiveChannel] = Channel{ + Users: make(map[string]User), } } diff --git a/commands/commands.go b/commands/commands.go index 4d4243c..4c7d43f 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -106,13 +106,13 @@ const ( ) /* --- Channel membership prefixes --- */ -const ( - Founder = "+q" - Protected = "+a" - Operator = "@" - HalfOp = "%" - Voice = "+v" -) +var UserPrefixes = map[string]bool{ + "~": true, + "&": true, + "@": true, + "%": true, + "+": true, +} /* --- Numeric Replies --- */ const ( diff --git a/go.mod b/go.mod index 278f645..1de0d37 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,13 @@ go 1.16 replace github.com/charmbracelet/bubbles => /home/illusion/Desktop/repos/bubbles require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/bubbles v0.9.0 - github.com/charmbracelet/bubbletea v0.19.1 + github.com/charmbracelet/bubbletea v0.19.3 github.com/charmbracelet/lipgloss v0.4.0 + github.com/containerd/console v1.0.3 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect + golang.org/x/sys v0.0.0-20220111092808-5a964db01320 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect ) diff --git a/go.sum b/go.sum index d7f58cb..1148aa6 100644 --- a/go.sum +++ b/go.sum @@ -1,46 +1,48 @@ -github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/charmbracelet/bubbletea v0.14.1/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE= -github.com/charmbracelet/bubbletea v0.19.1 h1:VHuzkJbnTAkxhOfi9+Lb5PYfNM9+Oh+qhP8uDX5ReOU= -github.com/charmbracelet/bubbletea v0.19.1/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/charmbracelet/bubbletea v0.19.3 h1:OKeO/Y13rQQqt4snX+lePB0QrnW80UdrMNolnCcmoAw= +github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk= github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= -github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= -github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE= github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 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.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220111092808-5a964db01320 h1:0jf+tOCoZ3LyutmCOWpVni1chK4VfFLhRsDK7MhqGRY= +golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/main.go b/main.go index 2170014..8a7914e 100644 --- a/main.go +++ b/main.go @@ -24,10 +24,26 @@ import ( ) func main() { - if err := tea.NewProgram(app.InitialState(), + _app := app.InitialState() + + p := tea.NewProgram( + _app, tea.WithAltScreen(), tea.WithMouseCellMotion(), - ).Start(); err != nil { + ) + + _app.Client.Tea = p + + f, err := tea.LogToFile("gorc.log", "gorc") + defer f.Close() + + if err != nil { log.Fatal(err) } + + err = p.Start() + + if err != nil { + log.Println(err) + } } diff --git a/parser/parser_test.go b/parser/parser_test.go index 41281a9..a26f334 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -23,7 +23,11 @@ import "testing" func TestParser(t *testing.T) { t.Run("Test simple command", func(t *testing.T) { testMessage := "foo bar baz asdf" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Command != "foo" { t.Fatal("Parsing message with only command and params") @@ -44,7 +48,11 @@ func TestParser(t *testing.T) { t.Run("Test source", func(t *testing.T) { testMessage := ":coolguy foo bar baz asdf" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "coolguy" { t.Fatal("Parsing regular source") @@ -69,7 +77,11 @@ func TestParser(t *testing.T) { t.Run("Test trailing param", func(t *testing.T) { testMessage := "foo bar baz :asdf quux" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Parameters[0] != "bar" { t.Fatal("Parsing 1st param in message with trailing param") @@ -86,7 +98,11 @@ func TestParser(t *testing.T) { t.Run("Test empty trailing param", func(t *testing.T) { testMessage := "foo bar baz :" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Parameters[0] != "bar" { t.Fatal("Parsing 1st param in message with trailing param") @@ -103,7 +119,11 @@ func TestParser(t *testing.T) { t.Run("Test multiple colons at trailing param", func(t *testing.T) { testMessage := "foo bar baz ::asdf" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Parameters[0] != "bar" { t.Fatal("Parsing 1st param in message with trailing param") @@ -120,7 +140,11 @@ func TestParser(t *testing.T) { t.Run("Test source and trailing param", func(t *testing.T) { testMessage := ":coolguy foo bar baz :asdf quux" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "coolguy" { t.Fatal("Parsing source") @@ -145,7 +169,11 @@ func TestParser(t *testing.T) { t.Run("Test source and trailing param with spaces", func(t *testing.T) { testMessage := ":coolguy foo bar baz : asdf quux " - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "coolguy" { t.Fatal("Parsing source") @@ -170,7 +198,11 @@ func TestParser(t *testing.T) { t.Run("Test source and trailing param 2", func(t *testing.T) { testMessage := ":coolguy PRIVMSG bar :lol :) " - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "coolguy" { t.Fatal("Parsing source") @@ -191,7 +223,11 @@ func TestParser(t *testing.T) { t.Run("Test source and empty trailing param", func(t *testing.T) { testMessage := ":coolguy foo bar baz :" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "coolguy" { t.Fatal("Parsing source") @@ -216,7 +252,11 @@ func TestParser(t *testing.T) { t.Run("Test source and trailing param with spaces 2", func(t *testing.T) { testMessage := ":coolguy foo bar baz : " - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "coolguy" { t.Fatal("Parsing source") @@ -241,7 +281,11 @@ func TestParser(t *testing.T) { t.Run("Test tags", func(t *testing.T) { testMessage := "@id=234AB;rose :dan!d@localhost PRIVMSG #chan :Hey what's up!" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["id"] != "234AB" { t.Fatal("Parsing regular tag") @@ -270,7 +314,11 @@ func TestParser(t *testing.T) { t.Run("Test tags 2", func(t *testing.T) { testMessage := "@a=b;c=32;k;rt=q17 foo" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["a"] != "b" { t.Fatal("Parsing regular tag") @@ -296,7 +344,11 @@ func TestParser(t *testing.T) { // TODO: fails t.Run("Test escaped tags", func(t *testing.T) { testMessage := "@a=b\\\\and\\nk;c=72\\s45;d=gh\\:764 foo" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["a"] != "b\\and\nk" { t.Fatal("Parsing escaped tag") @@ -317,7 +369,11 @@ func TestParser(t *testing.T) { t.Run("Test tags with source", func(t *testing.T) { testMessage := "@c;h=;a=b :quux ab cd" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["c"] != "" { t.Log(ircMessage.Tags) @@ -348,7 +404,11 @@ func TestParser(t *testing.T) { // TODO: FAIL t.Run("Test single param with preceeding :", func(t *testing.T) { testMessage := ":src JOIN :#chan" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Parameters[0] != "#chan" { t.Fatal("Parsing a single param with a preceeding \":\"") @@ -357,7 +417,11 @@ func TestParser(t *testing.T) { t.Run("Test with no space as last param", func(t *testing.T) { testMessage := ":src AWAY" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "src" { t.Fatal("Parsing source") @@ -374,7 +438,11 @@ func TestParser(t *testing.T) { t.Run("Test with space as last param", func(t *testing.T) { testMessage := ":src AWAY " - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "src" { t.Fatal("Parsing source") @@ -392,7 +460,11 @@ func TestParser(t *testing.T) { t.Run("Test tab as space", func(t *testing.T) { testMessage := ":cool\tguy foo bar baz" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "cool\tguy" { t.Fatal("Parsing source") @@ -413,7 +485,11 @@ func TestParser(t *testing.T) { t.Run("Test weird control codes in source", func(t *testing.T) { testMessage := ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "coolguy!ag@net\x035w\x03ork.admin" { t.Fatal("Parsing source") @@ -434,7 +510,11 @@ func TestParser(t *testing.T) { t.Run("Test more weird control codes", func(t *testing.T) { testMessage := ":coolguy!~ag@n\x02et\x0305w\x0fork.admin PRIVMSG foo :bar baz" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "coolguy!~ag@n\x02et\x0305w\x0fork.admin" { t.Fatal("Parsing source") @@ -455,7 +535,11 @@ func TestParser(t *testing.T) { t.Run("Test full message", func(t *testing.T) { testMessage := "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :irc.example.com COMMAND param1 param2 :param3 param3" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["tag1"] != "value1" { t.Fatal("Parsing regular tag") @@ -496,7 +580,11 @@ func TestParser(t *testing.T) { t.Run("Test message without tags", func(t *testing.T) { testMessage := ":irc.example.com COMMAND param1 param2 :param3 param3" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "irc.example.com" { t.Fatal("Parsing source") @@ -521,7 +609,11 @@ func TestParser(t *testing.T) { t.Run("Test message without source", func(t *testing.T) { testMessage := "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= COMMAND param1 param2 :param3 param3" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["tag1"] != "value1" { t.Fatal("Parsing regular tag") @@ -563,7 +655,11 @@ func TestParser(t *testing.T) { // TODO: FAIL t.Run("Test yaml encoding with slashes", func(t *testing.T) { testMessage := "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["foo"] != "\\\\;\\s \r\n" { t.Fatal("Parsing escaped tag") @@ -576,7 +672,11 @@ func TestParser(t *testing.T) { t.Run("Test broken messages 1", func(t *testing.T) { testMessage := ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "gravel.mozilla.org" { t.Fatal("Parsing source") @@ -597,7 +697,11 @@ func TestParser(t *testing.T) { t.Run("Test broken messages 2", func(t *testing.T) { testMessage := ":gravel.mozilla.org MODE #tckk +n " - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "gravel.mozilla.org" { t.Fatal("Parsing source") @@ -618,7 +722,11 @@ func TestParser(t *testing.T) { t.Run("Test broken messages 3", func(t *testing.T) { testMessage := ":services.esper.net MODE #foo-bar +o foobar " - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "services.esper.net" { t.Fatal("Parsing source") @@ -644,7 +752,11 @@ func TestParser(t *testing.T) { // TODO: FAIL t.Run("Test tags. they should be parsed char-at-a-time", func(t *testing.T) { testMessage := "@tag1=value\\\\ntest COMMAND" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["tag1"] != "value\\ntest" { t.Fatal("Parsing escaped tag") @@ -662,7 +774,11 @@ func TestParser(t *testing.T) { // TODO: FAIl t.Run("Test tag escape for char that doesn't need it", func(t *testing.T) { testMessage := "@tag1=value\\1 COMMAND" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["tag1"] != "value1" { t.Fatal("Parsing escaped tag") @@ -676,7 +792,11 @@ func TestParser(t *testing.T) { // TODO: FAIL t.Run("Test slash at end of tag", func(t *testing.T) { testMessage := "@tag1=value1\\ COMMAND" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["tag1"] != "value1" { t.Fatal("Parsing escaped tag") @@ -689,7 +809,11 @@ func TestParser(t *testing.T) { t.Run("Test duplicate tags", func(t *testing.T) { testMessage := "@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["tag1"] != "5" { t.Fatal("Parsing duplicate tag") @@ -710,7 +834,11 @@ func TestParser(t *testing.T) { t.Run("Test vendored tags can have the same name as unvendored tag", func(t *testing.T) { testMessage := "@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Tags["tag1"] != "5" { t.Fatal("Parsing duplicate tag") @@ -735,7 +863,11 @@ func TestParser(t *testing.T) { t.Run("Test: some parsers handle /MODE in a special way", func(t *testing.T) { testMessage := ":SomeOp MODE #channel :+i" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "SomeOp" { t.Fatal("Parsing source") @@ -756,7 +888,11 @@ func TestParser(t *testing.T) { t.Run("Test: some parsers handle /MODE in a special way 2", func(t *testing.T) { testMessage := ":SomeOp MODE #channel +oo SomeUser :AnotherUser" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "SomeOp" { t.Fatal("Parsing source") @@ -785,7 +921,11 @@ func TestParser(t *testing.T) { t.Run("Test multiple spaces", func(t *testing.T) { testMessage := ":src JOIN #chan :thing lol haha" - ircMessage := ParseIRCMessage(testMessage) + ircMessage, value := ParseIRCMessage(testMessage) + + if !value { + t.Fatal("Invalid irc message") + } if ircMessage.Source != "src" { t.Fatal("Parsing source with multiple spaces in between") diff --git a/ui/app/app.go b/ui/app/app.go index a1a6bfe..12fc698 100644 --- a/ui/app/app.go +++ b/ui/app/app.go @@ -30,7 +30,7 @@ import ( type Screen int const ( - Login = iota + Login Screen = iota MainScreen ) @@ -52,15 +52,13 @@ func initialUiState(client *client.Client) UI { } } -func InitialState() State { +func InitialState() *State { client := &client.Client{} - s := State{ + return &State{ Client: client, UI: initialUiState(client), } - - return s } func (s State) Init() tea.Cmd { @@ -94,7 +92,7 @@ func (s State) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.UI.CurrentScreen = MainScreen // create new client with the provided host and port - (*s.Client) = client.NewClient(host, port, tlsEnabled) + s.Client.Initialize(host, port, tlsEnabled) s.Client.Register(nickname, password, channel) // 512 bytes as a base + 8192 additional bytes for tags diff --git a/ui/app/cmds.go b/ui/app/cmds.go index 67a31c0..6620329 100644 --- a/ui/app/cmds.go +++ b/ui/app/cmds.go @@ -19,13 +19,8 @@ package app import tea "github.com/charmbracelet/bubbletea" func (s *State) Quit() tea.Msg { - if s.Client.Quit != nil { - close(s.Client.Quit) - } - if s.Client.TcpConn != nil { s.Client.SendCommand("QUIT") - s.Client.TcpConn.Close() } return tea.Quit() diff --git a/ui/mainscreen/cmds.go b/ui/mainscreen/cmds.go index 4245862..a0b44fa 100644 --- a/ui/mainscreen/cmds.go +++ b/ui/mainscreen/cmds.go @@ -17,6 +17,7 @@ package mainscreen import ( + "io" "log" "strings" @@ -26,19 +27,18 @@ import ( func (s *State) ReadLoop() { for { - select { - case <-s.Client.Quit: + msg, err := s.ConnReader.ReadString('\n') + if err != nil { + if err != io.EOF { + log.Println(err) + } + s.Client.TcpConn.Close() return - default: - msg, err := s.ConnReader.ReadString('\n') - if err != nil { - log.Fatal(err) - } + } - msg = strings.Replace(msg, "\r\n", "", 1) - if ircMessage, valid := parser.ParseIRCMessage(msg); valid { - s.HandleCommand(ircMessage) - } + msg = strings.Replace(msg, "\r\n", "", 1) + if ircMessage, valid := parser.ParseIRCMessage(msg); valid { + s.HandleCommand(ircMessage) } } } @@ -52,3 +52,15 @@ func (s InputState) SendingPrivMsg(msg string) tea.Cmd { return SendPrivMsg{Msg: msg} } } + +type ReceivedIRCMsgMsg struct{} + +func ReceivedIRCMsg() tea.Msg { + return ReceivedIRCMsgMsg{} +} + +type SwitchChannelsMsg struct{} + +func SwitchChannels() tea.Msg { + return SwitchChannelsMsg{} +} diff --git a/ui/mainscreen/input.go b/ui/mainscreen/input.go index 9bd196f..193163b 100644 --- a/ui/mainscreen/input.go +++ b/ui/mainscreen/input.go @@ -62,7 +62,20 @@ func (s InputState) Update(msg tea.Msg) (InputState, tea.Cmd) { return s, cmd } +func (s *InputState) Focus() { + s.Input.Focus() + s.Style = s.Style.Copy().BorderForeground(lipgloss.Color("105")) +} + +func (s *InputState) Blur() { + s.Input.Blur() + s.Style = s.Style.Copy().BorderForeground(lipgloss.Color("#EEE")) +} + func (s *InputState) SetSize(width int) { + // set a max width for the input field so it scrolls horizontally instead of wrapping to a newline + // -4 for input prompt char, cursor, and some extra magical padding + s.Input.Width = width - s.Style.GetHorizontalFrameSize() - 4 s.Style = s.Style.Width(width - s.Style.GetHorizontalBorderSize()) } diff --git a/ui/mainscreen/mainscreen.go b/ui/mainscreen/mainscreen.go index 5aa335b..2d153e9 100644 --- a/ui/mainscreen/mainscreen.go +++ b/ui/mainscreen/mainscreen.go @@ -19,6 +19,7 @@ package mainscreen import ( "bufio" "fmt" + "math" "strings" "github.com/charmbracelet/bubbles/textinput" @@ -34,7 +35,8 @@ import ( type Window int const ( - Viewport = iota + Viewport Window = iota + SidePanel InputBox ) @@ -42,7 +44,6 @@ type State struct { ConnReader *bufio.Reader Client *client.Client Viewport *viewport.Model - Style lipgloss.Style FocusIndex Window InputBox InputState @@ -52,14 +53,14 @@ type State struct { func NewMainScreen(client *client.Client) State { newViewport := &viewport.Model{ HighPerformanceRendering: false, - Wrap: true, + Wrap: viewport.Wrap, + Style: MessagesStyle.Copy(), } return State{ ConnReader: nil, Client: client, Viewport: newViewport, - Style: MessagesStyle.Copy(), FocusIndex: InputBox, InputBox: NewInputBox(), SidePanel: NewSidePanel(client), @@ -71,6 +72,9 @@ func (s State) Update(msg tea.Msg) (State, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case ReceivedIRCMsgMsg: + s.Viewport.GotoBottom() + return s, nil case SendPrivMsg: // for sending slash commands // TODO: make this better @@ -83,21 +87,28 @@ func (s State) Update(msg tea.Msg) (State, tea.Cmd) { } if strings.ToUpper(command) == commands.JOIN { s.Client.ActiveChannel = params[0] - s.Client.Channels[params[0]] = client.Channel{} - s.Client.SendCommand(command, params...) - return s, nil + if _, ok := s.Client.Channels[params[0]]; !ok { + s.Client.Channels[params[0]] = client.Channel{ + Users: make(map[string]client.User), + } + } + s.Client.SendCommand(commands.JOIN, params...) + return s, SwitchChannels } s.Client.SendCommand(command, params...) } else { - // TODO: dont send command when activechannel == c.host - fullMsg := s.Client.Nickname + ": " + msg.Msg - if c, ok := s.Client.Channels[s.Client.ActiveChannel]; ok { - c.History += fullMsg + client.CRLF + if s.Client.ActiveChannel != s.Client.Host { + fullMsg := s.Client.Nickname + ": " + msg.Msg + if c, ok := s.Client.Channels[s.Client.ActiveChannel]; ok { + c.History += fullMsg + client.CRLF - s.Client.Channels[s.Client.ActiveChannel] = c + s.Client.Channels[s.Client.ActiveChannel] = c + } + // TODO: make sure to only append the message to the history if server sends back no errors + s.Client.SendCommand(commands.PRIVMSG, s.Client.ActiveChannel, msg.Msg) + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) + s.Viewport.GotoBottom() } - s.Client.SendCommand(commands.PRIVMSG, s.Client.ActiveChannel, msg.Msg) - s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) } return s, nil @@ -111,35 +122,32 @@ func (s State) Update(msg tea.Msg) (State, tea.Cmd) { s.FocusIndex-- } - if s.FocusIndex > 1 { + if s.FocusIndex > 2 { s.FocusIndex = 0 } else if s.FocusIndex < 0 { - s.FocusIndex = 1 + s.FocusIndex = 2 } switch s.FocusIndex { case Viewport: - s.Style = s.Style.Copy().BorderForeground(lipgloss.Color("105")) - s.InputBox.Style = s.InputBox.Style.Copy().BorderForeground(lipgloss.Color("#EEE")) - - s.InputBox.Input.Blur() + s.Focus() + s.InputBox.Blur() + s.SidePanel.Blur() case InputBox: - s.Style = s.Style.Copy().BorderForeground(lipgloss.Color("#EEE")) - s.InputBox.Style = s.InputBox.Style.Copy().BorderForeground(lipgloss.Color("105")) - - s.InputBox, cmd = s.InputBox.Update(msg) - cmds = append(cmds, cmd) cmds = append(cmds, textinput.Blink) - s.InputBox.Input.Focus() + s.Blur() + s.InputBox.Focus() + s.SidePanel.Blur() + case SidePanel: + s.Blur() + s.InputBox.Blur() + s.SidePanel.Focus() } return s, tea.Batch(cmds...) } } - // TODO: maybe we still need this, not sure - // s.Viewport.SetContent(s.Client.ActiveChannel.History) - switch s.FocusIndex { case Viewport: *s.Viewport, cmd = s.Viewport.Update(msg) @@ -149,22 +157,35 @@ func (s State) Update(msg tea.Msg) (State, tea.Cmd) { cmds = append(cmds, cmd) } + *s.SidePanel, cmd = s.SidePanel.Update(msg) + cmds = append(cmds, cmd) + return s, tea.Batch(cmds...) } +func (s *State) Focus() { + s.Viewport.Style = s.Viewport.Style.Copy().BorderForeground(lipgloss.Color("105")) +} +func (s *State) Blur() { + s.Viewport.Style = s.Viewport.Style.Copy().BorderForeground(lipgloss.Color("#EEE")) +} + func (s *State) SetSize(width, height int) { s.InputBox.SetSize(width) - s.SidePanel.SetSize(width, height, s.InputBox.Style.Copy().GetVerticalFrameSize()) + s.SidePanel.SetSize(width, height, s.InputBox.Style.GetVerticalFrameSize()) - // -1 is for some extra invisible margin or padding or whatever (idk what it is tbh) - newViewportWidth := (width * 8 / 10) - s.Style.GetHorizontalBorderSize() - 1 - newViewportHeight := height - s.InputBox.Style.GetVerticalFrameSize() - s.Style.GetVerticalBorderSize() - 1 + // We floor here because width is an int and some fractions are lost + // and also because we ceil the sidepanel's width + // -1 is for some extra invisible margin or padding between the main viewport and the inputbox + newWidth := int(math.Floor(float64(width)*8.0/10.0) - float64(s.Viewport.Style.GetHorizontalFrameSize())) + newHeight := height - s.InputBox.Style.GetVerticalFrameSize() - s.Viewport.Style.GetVerticalFrameSize() - 1 + + s.Viewport.Width = newWidth + s.Viewport.Height = newHeight + + s.Viewport.Style = s.Viewport.Style.Width(newWidth) + s.Viewport.Style = s.Viewport.Style.Height(newHeight) - // copying the existing style is important here otherwise we'll end up with artifacting - s.Viewport.Width = newViewportWidth - s.Style = s.Style.Width(newViewportWidth) - s.Viewport.Height = newViewportHeight - s.Style = s.Style.Height(newViewportHeight) // we need to re-set the content because words wrap differently on different sizes s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) } @@ -173,15 +194,16 @@ func (s *State) HandleCommand(msg parser.IRCMessage) { // TODO: handle different commands switch msg.Command { case commands.PING: - s.Client.SendCommand(commands.PONG, msg.Parameters[0]) + token := msg.Parameters[0] + s.Client.SendCommand(commands.PONG, token) case commands.PRIVMSG: nick := strings.SplitN(msg.Source, "!", 2)[0] channel := msg.Parameters[0] msgContent := msg.Parameters[1] - fullMsg := fmt.Sprintf("%s: %s", nick, msgContent) + privMsg := fmt.Sprintf("%s: %s", nick, msgContent) if c, ok := s.Client.Channels[channel]; ok { - c.History += fullMsg + client.CRLF + c.History += privMsg + client.CRLF s.Client.Channels[channel] = c } @@ -190,40 +212,81 @@ func (s *State) HandleCommand(msg parser.IRCMessage) { nick := strings.SplitN(msg.Source, "!", 2)[0] channel := msg.Parameters[0] - fullMsg := fmt.Sprintf("== %s has joined", nick) + joinMsg := fmt.Sprintf("== %s has joined", nick) if c, ok := s.Client.Channels[channel]; ok { - c.History += fullMsg + client.CRLF + c.History += joinMsg + client.CRLF + if _, exists := c.Users[nick]; !exists { + c.Users[nick] = client.User{} + } s.Client.Channels[channel] = c } + + if channel == s.Client.ActiveChannel { + s.SidePanel.UpdateNicks() + } + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) case commands.QUIT: nick := strings.SplitN(msg.Source, "!", 2)[0] reason := msg.Parameters[0] - fullMsg := fmt.Sprintf("== %s has quit (%s)", nick, reason) - if c, ok := s.Client.Channels[s.Client.ActiveChannel]; ok { - c.History += fullMsg + client.CRLF + quitMsg := fmt.Sprintf("== %s has quit (%s)", nick, reason) - s.Client.Channels[s.Client.ActiveChannel] = c + for chanName, channel := range s.Client.Channels { + channel.History += quitMsg + client.CRLF + delete(channel.Users, nick) + + s.Client.Channels[chanName] = channel } + + s.SidePanel.UpdateNicks() + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) + case commands.PART: + nick := strings.SplitN(msg.Source, "!", 2)[0] + channel := msg.Parameters[0] + reason := "" + if len(msg.Parameters) > 1 { + reason = msg.Parameters[1] + } + + partMsg := fmt.Sprintf("== %s has left %s (%s)", nick, channel, reason) + if c, ok := s.Client.Channels[channel]; ok { + c.History += partMsg + client.CRLF + delete(c.Users, nick) + + s.Client.Channels[channel] = c + } + + s.SidePanel.UpdateNicks() s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) case commands.RPL_NAMREPLY: - // TODO: put the users into their respective channels + // TODO: do i need these // client := msg.Parameters[0] // chanSymbol := msg.Parameters[1] - // channel := msg.Parameters[2] + channel := msg.Parameters[2] nicks := strings.Split(msg.Parameters[3], " ") - s.SidePanel.Nicks = append(s.SidePanel.Nicks, nicks...) - case commands.RPL_ENDOFNAMES: - fullMsg := fmt.Sprintf("%s %s %s %s", msg.Tags, msg.Source, msg.Command, strings.Join(msg.Parameters, " ")) - if c, ok := s.Client.Channels[s.Client.ActiveChannel]; ok { - c.History += fullMsg + client.CRLF + if c, ok := s.Client.Channels[channel]; ok { + for _, nick := range nicks { + prefix := "" + _nick := nick - s.Client.Channels[s.Client.ActiveChannel] = c + if commands.UserPrefixes[string(nick[0])] { + prefix = string(nick[0]) + _nick = nick[1:] + } + + c.Users[_nick] = client.User{ + Prefix: prefix, + } + } } - s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) + if channel == s.Client.ActiveChannel { + s.SidePanel.UpdateNicks() + } + case commands.RPL_ENDOFNAMES: + // TODO: what do i do here lol default: fullMsg := fmt.Sprintf("%s %s %s %s", msg.Tags, msg.Source, msg.Command, strings.Join(msg.Parameters, " ")) if c, ok := s.Client.Channels[s.Client.ActiveChannel]; ok { @@ -234,10 +297,14 @@ func (s *State) HandleCommand(msg parser.IRCMessage) { s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) } + + // send a receivedIRCmsg tea message so the ui can update + // we also use this tea message to scroll the viewport down + s.Client.Tea.Send(ReceivedIRCMsg()) } func (s State) View() string { - top := lipgloss.JoinHorizontal(lipgloss.Right, s.Style.Render(s.Viewport.View()), s.SidePanel.View()) + top := lipgloss.JoinHorizontal(lipgloss.Right, s.Viewport.View(), s.SidePanel.View()) screen := lipgloss.JoinVertical(0, top, s.InputBox.View()) return ui.MainStyle.Render(screen) diff --git a/ui/mainscreen/sidepanel.go b/ui/mainscreen/sidepanel.go index 0df0546..4b36b37 100644 --- a/ui/mainscreen/sidepanel.go +++ b/ui/mainscreen/sidepanel.go @@ -18,6 +18,7 @@ package mainscreen import ( "fmt" + "math" "strings" "github.com/charmbracelet/bubbles/viewport" @@ -27,34 +28,91 @@ import ( ) type SidePanelState struct { - // TODO: make a viewport and use that to display the names - // OR: we could use a list - Style lipgloss.Style - Nicks []string + Client *client.Client Viewport viewport.Model - Content string + Focused bool +} + +func (s *SidePanelState) getHeader() string { + separator := strings.Repeat("—", s.Viewport.Width) + "\n" + header := fmt.Sprintf("Users in this channel (%d)\n", len(s.Client.Channels[s.Client.ActiveChannel].Users)) + separator + + return header +} + +func (s *SidePanelState) getLatestNicks() []string { + nicks := make([]string, 0) + + for nick, user := range s.Client.Channels[s.Client.ActiveChannel].Users { + _nick := user.Prefix + nick + nicks = append(nicks, _nick) + } + + return nicks } func NewSidePanel(client *client.Client) *SidePanelState { return &SidePanelState{ - Style: SidePanelStyle.Copy(), + Client: client, + Viewport: viewport.Model{ + HighPerformanceRendering: false, + Wrap: viewport.Wrap, + Style: SidePanelStyle.Copy(), + }, } } func (s SidePanelState) Update(msg tea.Msg) (SidePanelState, tea.Cmd) { - return s, nil + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg.(type) { + case SwitchChannelsMsg: + s.UpdateNicks() + return s, nil + } + + if s.Focused { + s.Viewport, cmd = s.Viewport.Update(msg) + cmds = append(cmds, cmd) + } + + return s, tea.Batch(cmds...) +} + +func (s *SidePanelState) Focus() { + s.Focused = true + s.Viewport.Style = s.Viewport.Style.BorderForeground(lipgloss.Color("105")) +} + +func (s *SidePanelState) Blur() { + s.Focused = false + s.Viewport.Style = s.Viewport.Style.BorderForeground(lipgloss.Color("#EEE")) +} + +func (s *SidePanelState) UpdateNicks() { + header := s.getHeader() + nicks := s.getLatestNicks() + + s.Viewport.SetContent(header + strings.Join(nicks, "\n")) } func (s *SidePanelState) SetSize(width, height, inputboxHeight int) { - s.Style = s.Style.Copy().Width(width * 2 / 10) - // -1 is for some extra invisible margin or padding or whatever (idk what it is tbh) - s.Style = s.Style.Copy().Height(height - inputboxHeight - s.Style.GetVerticalBorderSize() - 1) + // We ceil here because width is an int and some fractions are lost + // -1 is for some extra invisible margin or padding between the sidepanel and the inputbox + newWidth := int(math.Ceil(math.Ceil((float64(width) * 2.0 / 10.0)) - float64(s.Viewport.Style.GetHorizontalFrameSize()))) + newHeight := height - inputboxHeight - s.Viewport.Style.GetVerticalFrameSize() - 1 + + s.Viewport.Style = s.Viewport.Style.Width(newWidth) + s.Viewport.Style = s.Viewport.Style.Height(newHeight) + + s.Viewport.Width = newWidth + s.Viewport.Height = newHeight + + // on resize recalculate the width and re-set the viewport contents + s.UpdateNicks() } func (s SidePanelState) View() string { - separator := strings.Repeat("-", s.Style.GetWidth()) + "\n" - header := fmt.Sprintf("Users in this channel (%d)\n", len(s.Nicks)) + separator - nicks := strings.Join(s.Nicks, "\n") - - return s.Style.Render(header + nicks) + return s.Viewport.View() } diff --git a/ui/mainscreen/styles.go b/ui/mainscreen/styles.go index 2322548..d108442 100644 --- a/ui/mainscreen/styles.go +++ b/ui/mainscreen/styles.go @@ -35,6 +35,4 @@ var ( Border(lipgloss.NormalBorder(), true). Width(ui.MainStyle.GetWidth() * 2 / 10). BorderForeground(lipgloss.Color("#EEE")) - - InputBoxHeight = 1 ) diff --git a/ui/styles.go b/ui/styles.go index 269bb38..b49f58e 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -20,8 +20,8 @@ import "github.com/charmbracelet/lipgloss" var ( MainStyle = lipgloss.NewStyle(). - Width(172). - Height(40) + Width(0). + Height(0) DialogStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder(), true).