From 4bdb4a0fb3caa0efb68b010c32c6206def92860d Mon Sep 17 00:00:00 2001 From: IllusionMan1212 Date: Sat, 13 Nov 2021 06:38:12 +0200 Subject: [PATCH] refactor: cleanup initial impl and modularize everything --- client.go => client/client.go | 8 +- parser.go => client/parser.go | 2 +- client/structs.go | 29 ++++ constants.go | 27 ---- main.go | 3 +- state.go | 296 ---------------------------------- ui/app/app.go | 110 +++++++++++++ ui/login/login.go | 175 ++++++++++++++++++++ ui/login/styles.go | 74 +++++++++ ui/styles.go | 47 ++++++ 10 files changed, 440 insertions(+), 331 deletions(-) rename client.go => client/client.go (91%) rename parser.go => client/parser.go (99%) create mode 100644 client/structs.go delete mode 100644 state.go create mode 100644 ui/app/app.go create mode 100644 ui/login/login.go create mode 100644 ui/login/styles.go create mode 100644 ui/styles.go diff --git a/client.go b/client/client.go similarity index 91% rename from client.go rename to client/client.go index b05467e..411d566 100644 --- a/client.go +++ b/client/client.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License along // with this program; if not, see https://www.gnu.org/licenses. -package main +package client import ( "crypto/tls" @@ -57,16 +57,12 @@ func NewClient(host string, port string) *Client { } func (c Client) handleCommand(message IRCMessage) { - fmt.Printf("Tags: %s\n", message.Tags) - fmt.Printf("Source: %s\n", message.Source) - fmt.Printf("Command: %s\n", message.Command) - fmt.Printf("Params: %s\n", message.Parameters) - switch message.Command { case "PING": c.conn.Write([]byte("PONG\r\n")) break } + // TODO: handle all other commands } func (c Client) Run() { diff --git a/parser.go b/client/parser.go similarity index 99% rename from parser.go rename to client/parser.go index d3879ba..5683672 100644 --- a/parser.go +++ b/client/parser.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License along // with this program; if not, see https://www.gnu.org/licenses. -package main +package client import ( "strings" diff --git a/client/structs.go b/client/structs.go new file mode 100644 index 0000000..c93fcc0 --- /dev/null +++ b/client/structs.go @@ -0,0 +1,29 @@ +// gorc project +// Copyright (C) 2021 IllusionMan1212 and contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see https://www.gnu.org/licenses. + +package client + +type IRCMessage struct { + Tags []Tag // starts with @ | Optional + Source string // starts with : | Optional + Command string // can either be a string or a numeric value | Required + Parameters []string // Optional (Dependant on command) +} + +type Tag struct { + key string + value string +} diff --git a/constants.go b/constants.go index 7ba0acc..29cf6c4 100644 --- a/constants.go +++ b/constants.go @@ -16,20 +16,6 @@ package main -const welcomeMsg = ` - - /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$$ - /$$__ $$ /$$__ $$ /$$__ $$ /$$_____/ -| $$ \ $$| $$ \ $$| $$ \__/| $$ -| $$ | $$| $$ | $$| $$ | $$ -| $$$$$$$| $$$$$$/| $$ | $$$$$$$ - \____ $$ \______/ |__/ \_______/ - /$$ \ $$ -| $$$$$$/ - \______/ - -` - /* --- IRC Ports --- */ const ( INSECURE_PORT = 6667 @@ -90,19 +76,6 @@ const ( PONG = "PONG" // Respond to a server PING ) -/* --- IRC Message --- */ -type IRCMessage struct { - Tags []Tag // starts with @ | Optional - Source string // starts with : | Optional - Command string // can either be a string or a numeric value | Required - Parameters []string // Optional (Dependant on command) -} - -type Tag struct { - key string - value string -} - /* --- Channel Types --- */ // These are the channel prefixes for different types of channels const ( diff --git a/main.go b/main.go index 123b538..bfe26fe 100644 --- a/main.go +++ b/main.go @@ -20,10 +20,11 @@ import ( "log" tea "github.com/charmbracelet/bubbletea" + "github.com/illusionman1212/gorc/ui/app" ) func main() { - if err := tea.NewProgram(initialState(), tea.WithAltScreen()).Start(); err != nil { + if err := tea.NewProgram(app.InitialState(), tea.WithAltScreen()).Start(); err != nil { log.Fatal(err) } diff --git a/state.go b/state.go deleted file mode 100644 index ab130fe..0000000 --- a/state.go +++ /dev/null @@ -1,296 +0,0 @@ -// gorc project -// Copyright (C) 2021 IllusionMan1212 and contributors -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, see https://www.gnu.org/licenses. - -package main - -import ( - "fmt" - "log" - "os" - "strings" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "golang.org/x/term" -) - -var ( - border = lipgloss.Border{ - Top: "─", - Bottom: "─", - Left: "│", - Right: "│", - TopLeft: "╭", - TopRight: "╮", - BottomLeft: "╰", - BottomRight: "╯", - } - - mainStyle = lipgloss.NewStyle(). - Width(172). - Height(40) - - dialogStyle = lipgloss.NewStyle(). - Border(border, true). - Align(lipgloss.Center). - Width(50). - Padding(3, 6). - BorderForeground(lipgloss.Color("105")) - - welcomeMsgStyle = lipgloss.NewStyle(). - Padding(0, 5). - MarginBottom(5) - - cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("105")) - focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("105")) - noStyle = lipgloss.NewStyle() - - focusedButton = lipgloss.NewStyle(). - Background(lipgloss.Color("105")). - Foreground(lipgloss.Color("255")). - MarginTop(1). - Padding(0, 2). - Align(lipgloss.Center). - Render("Connect") - blurredButton = lipgloss.NewStyle(). - Background(lipgloss.Color("#809070")). - Foreground(lipgloss.Color("#EEEEEE")). - MarginTop(1). - Padding(0, 2). - Align(lipgloss.Center). - Render("Connect") - - focusedCheckbox = lipgloss.NewStyle(). - Foreground(lipgloss.Color("105")). - MarginTop(1). - Render("[ ] Enable TLS") - - blurredCheckbox = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#EEEEEE")). - MarginTop(1). - Render("[ ] Enable TLS") - - focusedCheckedCheckbox = lipgloss.NewStyle(). - Foreground(lipgloss.Color("105")). - MarginTop(1). - Render("[x] Enable TLS") - - blurredCheckedCheckbox = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#EEEEEE")). - MarginTop(1). - Render("[x] Enable TLS") -) - -type loginState struct { - focusIndex int - inputs []textinput.Model - - tls bool -} - -// TODO: have a global state that holds a separate client state and separate tui state -type state struct { - host string - port string - - nick string - password string - - windowWidth int - windowHeight int - - login loginState -} - -func initialLoginState() loginState { - ls := loginState{ - inputs: make([]textinput.Model, 5), - } - - var t textinput.Model - for i := range ls.inputs { - t = textinput.NewModel() - t.CursorStyle = cursorStyle - - switch i { - case 0: - t.Placeholder = "Host" - t.Focus() - t.CharLimit = 40 - t.TextStyle = focusedStyle - case 1: - t.Placeholder = "Port" - t.CharLimit = 5 - case 2: - t.Placeholder = "Channel" - t.CharLimit = 32 - case 3: - t.Placeholder = "Nickname" - t.CharLimit = 32 - case 4: - t.Placeholder = "Password" - t.CharLimit = 32 - t.EchoMode = textinput.EchoPassword - t.EchoCharacter = '•' - } - - ls.inputs[i] = t - } - - return ls -} - -func initialState() state { - width, height, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { - log.Fatal(err) - } - - s := state{ - login: initialLoginState(), - windowWidth: width, - windowHeight: height, - } - - return s -} - -func (s state) Init() tea.Cmd { - return textinput.Blink -} - -func (s state) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c": - return s, tea.Quit - case "tab", "shift+tab", "enter", "up", "down", "space": - str := msg.String() - - // run the newClient cmd when pressing "enter" while focused on the connect button - if str == "enter" && s.login.focusIndex == len(s.login.inputs)+1 { - return s, tea.Quit // TODO: change this to create a newClient and connect using a tea.Cmd - } - - // toggle tls state when pressing "enter" while focused on the checkbox - // TODO: figure out why space isn't working here. all other keys works just fine - if (str == "enter" || str == "space") && s.login.focusIndex == len(s.login.inputs) { - s.login.tls = !s.login.tls - return s, nil - } - - if str == "up" || str == "shift+tab" { - s.login.focusIndex-- - } else { - s.login.focusIndex++ - } - - if s.login.focusIndex > len(s.login.inputs)+1 { - s.login.focusIndex = 0 - } else if s.login.focusIndex < 0 { - s.login.focusIndex = len(s.login.inputs) + 1 - } - - cmds := make([]tea.Cmd, len(s.login.inputs)) - for i := 0; i <= len(s.login.inputs)-1; i++ { - if i == s.login.focusIndex { - cmds[i] = s.login.inputs[i].Focus() - s.login.inputs[i].PromptStyle = focusedStyle - s.login.inputs[i].TextStyle = focusedStyle - continue - } - - s.login.inputs[i].Blur() - s.login.inputs[i].PromptStyle = noStyle - s.login.inputs[i].TextStyle = noStyle - } - - return s, tea.Batch(cmds...) - } - case tea.WindowSizeMsg: - // NOTE: this kinda works. still need to fix it tho - width, height, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { - log.Print(err) - return s, tea.Quit - } - - s.windowWidth = width - s.windowHeight = height - - mainStyle = mainStyle. - Width(width). - Height(height) - - return s, nil - } - - cmd := s.updateInputs(msg) - - return s, cmd -} - -func (s *state) updateInputs(msg tea.Msg) tea.Cmd { - var cmds = make([]tea.Cmd, len(s.login.inputs)) - - for i := range s.login.inputs { - s.login.inputs[i], cmds[i] = s.login.inputs[i].Update(msg) - } - - return tea.Batch(cmds...) -} - -func (s state) View() string { - var sb strings.Builder - - for i := range s.login.inputs { - sb.WriteString(s.login.inputs[i].View()) - if i < len(s.login.inputs)-1 { - sb.WriteRune('\n') - } - } - - checkbox := &blurredCheckbox - // if the checkbox is focused - if s.login.focusIndex == len(s.login.inputs) { - checkbox = &focusedCheckbox - // if tls is enabled - if s.login.tls { - checkbox = &focusedCheckedCheckbox - } - } - - // if the checkbox is not focused and tls is enabled - if s.login.focusIndex != len(s.login.inputs) && s.login.tls { - checkbox = &blurredCheckedCheckbox - } - - fmt.Fprintf(&sb, "\n%s\n", *checkbox) - - button := &blurredButton - if s.login.focusIndex == len(s.login.inputs)+1 { - button = &focusedButton - } - fmt.Fprintf(&sb, "\n%s\n", *button) - - ui := lipgloss.JoinVertical(0, welcomeMsgStyle.Render(welcomeMsg), dialogStyle.Render(sb.String())) - - final := lipgloss.Place(s.windowWidth, s.windowHeight, lipgloss.Center, lipgloss.Top, ui) - - return mainStyle.Render(final) -} diff --git a/ui/app/app.go b/ui/app/app.go new file mode 100644 index 0000000..0d436c5 --- /dev/null +++ b/ui/app/app.go @@ -0,0 +1,110 @@ +// gorc project +// Copyright (C) 2021 IllusionMan1212 and contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see https://www.gnu.org/licenses. + +package app + +import ( + "log" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/illusionman1212/gorc/ui" + "github.com/illusionman1212/gorc/ui/login" + "golang.org/x/term" +) + +type Screen int + +const ( + Login = iota + MainScreen +) + +// TODO: have a global State that holds a separate client State and separate tui State +type State struct { + host string + port string + + nick string + password string + + windowWidth int + windowHeight int + currentScreen Screen + + login login.State +} + +func InitialState() State { + width, height, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + log.Fatal(err) + } + + s := State{ + login: login.NewLogin(), + windowWidth: width, + windowHeight: height, + currentScreen: Login, + } + + return s +} + +func (s State) Init() tea.Cmd { + return nil +} + +func (s State) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + // TODO: clean up and gracefully quit by leaving the IRC channel/server and then quit bubble. + return s, tea.Quit + } + + case tea.WindowSizeMsg: + s.windowWidth = msg.Width + s.windowHeight = msg.Height + + ui.MainStyle = ui.MainStyle. + Width(msg.Width). + Height(msg.Height) + + return s, nil + } + + // switch between which screen is currently active and update its state + switch s.currentScreen { + case Login: + loginState, loginCmd := s.login.Update(msg) + s.login = loginState + return s, loginCmd + } + + return s, nil +} + +func (s State) View() string { + // switch between which screen is currently active and render it. + switch s.currentScreen { + case Login: + return s.login.View() + } + + return "Error: this screen shouldn't ever show" +} diff --git a/ui/login/login.go b/ui/login/login.go new file mode 100644 index 0000000..53c36c6 --- /dev/null +++ b/ui/login/login.go @@ -0,0 +1,175 @@ +// gorc project +// Copyright (C) 2021 IllusionMan1212 and contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see https://www.gnu.org/licenses. + +package login + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/illusionman1212/gorc/ui" +) + +type State struct { + FocusIndex int + Inputs []textinput.Model + TLS bool +} + +func NewLogin() State { + ls := State{ + Inputs: make([]textinput.Model, 5), + } + + var t textinput.Model + for i := range ls.Inputs { + t = textinput.NewModel() + t.CursorStyle = ui.CursorStyle + + switch i { + case 0: + t.Placeholder = "Host" + t.Focus() + t.CharLimit = 40 + t.TextStyle = ui.FocusedStyle + case 1: + t.Placeholder = "Port" + t.CharLimit = 5 + case 2: + t.Placeholder = "Channel" + t.CharLimit = 32 + case 3: + t.Placeholder = "Nickname" + t.CharLimit = 32 + case 4: + t.Placeholder = "Password" + t.CharLimit = 32 + t.EchoMode = textinput.EchoPassword + t.EchoCharacter = '•' + } + + ls.Inputs[i] = t + } + + return ls +} + +func (s State) Update(msg tea.Msg) (State, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab", "shift+tab", "enter", "up", "down", "space": + str := msg.String() + + // run the newClient cmd when pressing "enter" while focused on the connect button + if str == "enter" && s.FocusIndex == len(s.Inputs)+1 { + return s, tea.Quit // TODO: change this to create a newClient and connect using a tea.Cmd + } + + // toggle tls state when pressing "enter" while focused on the checkbox + // TODO: figure out why space isn't working here. all other keys works just fine + if (str == "enter" || str == "space") && s.FocusIndex == len(s.Inputs) { + s.TLS = !s.TLS + return s, nil + } + + if str == "up" || str == "shift+tab" { + s.FocusIndex-- + } else { + s.FocusIndex++ + } + + if s.FocusIndex > len(s.Inputs)+1 { + s.FocusIndex = 0 + } else if s.FocusIndex < 0 { + s.FocusIndex = len(s.Inputs) + 1 + } + + cmds := make([]tea.Cmd, len(s.Inputs)) + for i := 0; i <= len(s.Inputs)-1; i++ { + if i == s.FocusIndex { + cmds[i] = s.Inputs[i].Focus() + s.Inputs[i].PromptStyle = ui.FocusedStyle + s.Inputs[i].TextStyle = ui.FocusedStyle + continue + } + + s.Inputs[i].Blur() + s.Inputs[i].PromptStyle = ui.NoStyle + s.Inputs[i].TextStyle = ui.NoStyle + } + + return s, tea.Batch(cmds...) + } + } + + cmd := s.updateInputs(msg) + + return s, cmd +} + +func (s *State) updateInputs(msg tea.Msg) tea.Cmd { + var cmds = make([]tea.Cmd, len(s.Inputs)) + + for i := range s.Inputs { + s.Inputs[i], cmds[i] = s.Inputs[i].Update(msg) + } + + return tea.Batch(cmds...) +} + +func (s State) View() string { + var sb strings.Builder + + for i := range s.Inputs { + sb.WriteString(s.Inputs[i].View()) + if i < len(s.Inputs)-1 { + sb.WriteRune('\n') + } + } + + checkbox := &blurredCheckbox + // if the checkbox is focused + if s.FocusIndex == len(s.Inputs) { + checkbox = &focusedCheckbox + // if tls is enabled + if s.TLS { + checkbox = &focusedCheckedCheckbox + } + } + + // if the checkbox is not focused and tls is enabled + if s.FocusIndex != len(s.Inputs) && s.TLS { + checkbox = &blurredCheckedCheckbox + } + + fmt.Fprintf(&sb, "\n%s\n", *checkbox) + + button := &blurredButton + if s.FocusIndex == len(s.Inputs)+1 { + button = &focusedButton + } + fmt.Fprintf(&sb, "\n%s\n", *button) + + screen := lipgloss.JoinVertical(0, welcomeMsgStyle.Render(WelcomeMsg), ui.DialogStyle.Render(sb.String())) + + final := lipgloss.Place(ui.MainStyle.GetWidth(), ui.MainStyle.GetHeight(), lipgloss.Center, lipgloss.Top, screen) + + return ui.MainStyle.Render(final) +} diff --git a/ui/login/styles.go b/ui/login/styles.go new file mode 100644 index 0000000..11d0d70 --- /dev/null +++ b/ui/login/styles.go @@ -0,0 +1,74 @@ +// gorc project +// Copyright (C) 2021 IllusionMan1212 and contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see https://www.gnu.org/licenses. + +package login + +import "github.com/charmbracelet/lipgloss" + +const WelcomeMsg = ` + + /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$$ + /$$__ $$ /$$__ $$ /$$__ $$ /$$_____/ +| $$ \ $$| $$ \ $$| $$ \__/| $$ +| $$ | $$| $$ | $$| $$ | $$ +| $$$$$$$| $$$$$$/| $$ | $$$$$$$ + \____ $$ \______/ |__/ \_______/ + /$$ \ $$ +| $$$$$$/ + \______/ + +` + +var ( + welcomeMsgStyle = lipgloss.NewStyle(). + Padding(0, 5). + MarginBottom(5) + + focusedButton = lipgloss.NewStyle(). + Background(lipgloss.Color("105")). + Foreground(lipgloss.Color("255")). + MarginTop(1). + Padding(0, 2). + Align(lipgloss.Center). + Render("Connect") + blurredButton = lipgloss.NewStyle(). + Background(lipgloss.Color("#809070")). + Foreground(lipgloss.Color("#EEEEEE")). + MarginTop(1). + Padding(0, 2). + Align(lipgloss.Center). + Render("Connect") + + focusedCheckbox = lipgloss.NewStyle(). + Foreground(lipgloss.Color("105")). + MarginTop(1). + Render("[ ] Enable TLS") + + blurredCheckbox = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#EEEEEE")). + MarginTop(1). + Render("[ ] Enable TLS") + + focusedCheckedCheckbox = lipgloss.NewStyle(). + Foreground(lipgloss.Color("105")). + MarginTop(1). + Render("[x] Enable TLS") + + blurredCheckedCheckbox = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#EEEEEE")). + MarginTop(1). + Render("[x] Enable TLS") +) diff --git a/ui/styles.go b/ui/styles.go new file mode 100644 index 0000000..4767489 --- /dev/null +++ b/ui/styles.go @@ -0,0 +1,47 @@ +// gorc project +// Copyright (C) 2021 IllusionMan1212 and contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see https://www.gnu.org/licenses. + +package ui + +import "github.com/charmbracelet/lipgloss" + +var ( + Border = lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "╰", + BottomRight: "╯", + } + + MainStyle = lipgloss.NewStyle(). + Width(172). + Height(40) + + DialogStyle = lipgloss.NewStyle(). + Border(Border, true). + Align(lipgloss.Center). + Width(50). + Padding(3, 6). + BorderForeground(lipgloss.Color("105")) + + CursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("105")) + FocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("105")) + NoStyle = lipgloss.NewStyle() +)