Преглед на файлове

build(deps): upgrade tview to latest

ayn2op преди 2 месеца
родител
ревизия
63fef0b13e

+ 2 - 2
cmd/root.go

@@ -5,9 +5,9 @@ import (
 	"fmt"
 	"log/slog"
 
-	"github.com/ayn2op/discordo/internal/app"
 	"github.com/ayn2op/discordo/internal/config"
 	"github.com/ayn2op/discordo/internal/logger"
+	"github.com/ayn2op/discordo/internal/ui/root"
 	"github.com/diamondburned/arikawa/v3/utils/ws"
 )
 
@@ -45,5 +45,5 @@ func Run() error {
 		return fmt.Errorf("failed to load config: %w", err)
 	}
 
-	return app.New(cfg).Run()
+	return root.NewView(cfg).Run()
 }

+ 1 - 1
go.mod

@@ -8,7 +8,7 @@ require (
 	github.com/BurntSushi/toml v1.6.0
 	github.com/alecthomas/chroma/v2 v2.23.1
 	github.com/andybalholm/brotli v1.2.0
-	github.com/ayn2op/tview v0.0.0-20260223211434-6859308d597f
+	github.com/ayn2op/tview v0.0.0-20260225005318-47ca92c515c0
 	github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb
 	github.com/diamondburned/arikawa/v3 v3.6.1-0.20260221051847-b81b70d1a5cb
 	github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d

+ 2 - 2
go.sum

@@ -14,8 +14,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
 github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
 github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
-github.com/ayn2op/tview v0.0.0-20260223211434-6859308d597f h1:ZxDODg1E/RPoTWlXhnhZOKITe1ebBM0Cpy+GiGaa+xk=
-github.com/ayn2op/tview v0.0.0-20260223211434-6859308d597f/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0=
+github.com/ayn2op/tview v0.0.0-20260225005318-47ca92c515c0 h1:aWW3YTB4CKSrHlzg542uT7gIo812+VvNYIOogu5GAAs=
+github.com/ayn2op/tview v0.0.0-20260225005318-47ca92c515c0/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

+ 0 - 117
internal/app/app.go

@@ -1,117 +0,0 @@
-package app
-
-import (
-	"fmt"
-	"log/slog"
-	"os"
-
-	"github.com/ayn2op/discordo/internal/clipboard"
-	"github.com/ayn2op/discordo/internal/config"
-	"github.com/ayn2op/discordo/internal/consts"
-	"github.com/ayn2op/discordo/internal/keyring"
-	"github.com/ayn2op/discordo/internal/ui/chat"
-	"github.com/ayn2op/discordo/internal/ui/login"
-	"github.com/ayn2op/tview"
-	"github.com/ayn2op/tview/keybind"
-	"github.com/gdamore/tcell/v3"
-)
-
-type App struct {
-	inner    *tview.Application
-	chatView *chat.View
-	cfg      *config.Config
-}
-
-func New(cfg *config.Config) *App {
-	tview.Styles = tview.Theme{}
-	app := &App{
-		inner: tview.NewApplication(),
-		cfg:   cfg,
-	}
-
-	if err := clipboard.Init(); err != nil {
-		slog.Error("failed to init clipboard", "err", err)
-	}
-
-	app.inner.SetInputCapture(app.onInputCapture)
-	return app
-}
-
-func (a *App) Run() error {
-	token := os.Getenv("DISCORDO_TOKEN")
-	if token == "" {
-		t, err := keyring.GetToken()
-		if err != nil {
-			slog.Info("failed to retrieve token from keyring", "err", err)
-		}
-		token = t
-	}
-
-	screen, err := tcell.NewScreen()
-	if err != nil {
-		return fmt.Errorf("failed to create screen: %w", err)
-	}
-
-	if err := screen.Init(); err != nil {
-		return fmt.Errorf("failed to init screen: %w", err)
-	}
-
-	if a.cfg.Mouse {
-		screen.EnableMouse()
-	}
-
-	screen.SetTitle(consts.Name)
-	screen.EnablePaste()
-	screen.EnableFocus()
-	a.inner.SetScreen(screen)
-
-	if token == "" {
-		loginForm := login.NewForm(a.inner, a.cfg, func(token string) {
-			if err := a.showChatView(token); err != nil {
-				slog.Error("failed to show chat view", "err", err)
-			}
-		})
-		a.inner.SetRoot(loginForm)
-	} else {
-		if err := a.showChatView(token); err != nil {
-			return err
-		}
-	}
-
-	return a.inner.Run()
-}
-
-func (a *App) showChatView(token string) error {
-	a.chatView = chat.NewView(a.inner, a.cfg, a.quit)
-	if err := a.chatView.OpenState(token); err != nil {
-		return err
-	}
-	a.inner.SetRoot(a.chatView)
-	return nil
-}
-
-func (a *App) quit() {
-	if a.chatView != nil {
-		if err := a.chatView.CloseState(); err != nil {
-			slog.Error("failed to close the session", "err", err)
-		}
-	}
-
-	a.inner.Stop()
-}
-
-func (a *App) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
-	switch {
-	case keybind.Matches(event, a.cfg.Keybinds.Suspend.Keybind):
-		a.suspend()
-		return nil
-	case keybind.Matches(event, a.cfg.Keybinds.Quit.Keybind):
-		a.quit()
-		return nil
-	case keybind.Matches(event, keybind.NewKeybind(keybind.WithKeys("ctrl+c"))):
-		// https://github.com/ayn2op/tview/blob/a64fc48d7654432f71922c8b908280cdb525805c/application.go#L153
-		return tcell.NewEventKey(tcell.KeyCtrlC, "", tcell.ModNone)
-	}
-
-	return event
-}

+ 0 - 5
internal/app/suspend_default.go

@@ -1,5 +0,0 @@
-//go:build !unix
-
-package app
-
-func (a *App) suspend() {}

+ 101 - 0
internal/ui/chat/attachments_picker.go

@@ -0,0 +1,101 @@
+package chat
+
+import (
+	"github.com/ayn2op/discordo/internal/config"
+	"github.com/ayn2op/discordo/internal/ui"
+	"github.com/ayn2op/discordo/pkg/picker"
+	"github.com/ayn2op/tview"
+	"github.com/ayn2op/tview/help"
+	"github.com/ayn2op/tview/keybind"
+)
+
+type attachmentItem struct {
+	label string
+	open  func()
+}
+
+type attachmentsPicker struct {
+	*picker.Picker
+	cfg      *config.Config
+	chatView *View
+	items    []attachmentItem
+}
+
+var _ help.KeyMap = (*attachmentsPicker)(nil)
+
+func newAttachmentsPicker(cfg *config.Config, chatView *View) *attachmentsPicker {
+	ap := &attachmentsPicker{
+		Picker:   picker.New(),
+		cfg:      cfg,
+		chatView: chatView,
+	}
+	ap.Box = ui.ConfigureBox(tview.NewBox(), &cfg.Theme)
+	ap.
+		SetBlurFunc(nil).
+		SetFocusFunc(nil).
+		SetBorderSet(cfg.Theme.Border.ActiveSet.BorderSet).
+		SetBorderStyle(cfg.Theme.Border.ActiveStyle.Style).
+		SetTitleStyle(cfg.Theme.Title.ActiveStyle.Style).
+		SetFooterStyle(cfg.Theme.Footer.ActiveStyle.Style)
+
+	ap.SetTitle("Attachments")
+	ap.SetSelectedFunc(ap.onSelected)
+	ap.SetCancelFunc(ap.close)
+	ap.SetKeyMap(&picker.KeyMap{
+		Cancel: cfg.Keybinds.Picker.Cancel.Keybind,
+		Up:     cfg.Keybinds.Picker.Up.Keybind,
+		Down:   cfg.Keybinds.Picker.Down.Keybind,
+		Top:    cfg.Keybinds.Picker.Top.Keybind,
+		Bottom: cfg.Keybinds.Picker.Bottom.Keybind,
+		Select: cfg.Keybinds.Picker.Select.Keybind,
+	})
+	ap.SetScrollBarVisibility(cfg.Theme.ScrollBar.Visibility.ScrollBarVisibility)
+	ap.SetScrollBar(tview.NewScrollBar().
+		SetTrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style).
+		SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style).
+		SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet))
+	return ap
+}
+
+func (ap *attachmentsPicker) SetItems(items []attachmentItem) {
+	ap.items = items
+	ap.ClearItems()
+	for i, item := range items {
+		ap.AddItem(picker.Item{
+			Text:       item.label,
+			FilterText: item.label,
+			Reference:  i,
+		})
+	}
+	ap.Update()
+}
+
+func (ap *attachmentsPicker) onSelected(item picker.Item) {
+	index, ok := item.Reference.(int)
+	if !ok {
+		return
+	}
+	if index < 0 || index >= len(ap.items) {
+		return
+	}
+	ap.items[index].open()
+	ap.close()
+}
+
+func (ap *attachmentsPicker) close() {
+	ap.chatView.RemoveLayer(attachmentsListLayerName)
+	ap.chatView.app.SetFocus(ap.chatView.messagesList)
+}
+
+func (ap *attachmentsPicker) ShortHelp() []keybind.Keybind {
+	cfg := ap.cfg.Keybinds.Picker
+	return []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind}
+}
+
+func (ap *attachmentsPicker) FullHelp() [][]keybind.Keybind {
+	cfg := ap.cfg.Keybinds.Picker
+	return [][]keybind.Keybind{
+		{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Top.Keybind, cfg.Bottom.Keybind},
+		{cfg.Select.Keybind, cfg.Cancel.Keybind},
+	}
+}

+ 10 - 3
internal/ui/chat/guilds_tree.go

@@ -55,8 +55,7 @@ func newGuildsTree(cfg *config.Config, chatView *View) *guildsTree {
 		SetGraphics(cfg.Theme.GuildsTree.Graphics).
 		SetGraphicsColor(tcell.GetColor(cfg.Theme.GuildsTree.GraphicsColor)).
 		SetSelectedFunc(gt.onSelected).
-		SetTitle("Guilds").
-		SetInputCapture(gt.onInputCapture)
+		SetTitle("Guilds")
 
 	return gt
 }
@@ -375,7 +374,7 @@ func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) {
 		})
 }
 
-func (gt *guildsTree) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+func (gt *guildsTree) handleInput(event *tcell.EventKey) *tcell.EventKey {
 	switch {
 	case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind):
 		gt.collapseParentNode(gt.GetCurrentNode())
@@ -404,6 +403,14 @@ func (gt *guildsTree) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	return nil
 }
 
+func (gt *guildsTree) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+	event = gt.handleInput(event)
+	if event == nil {
+		return
+	}
+	gt.TreeView.InputHandler(event, setFocus)
+}
+
 func (gt *guildsTree) yankID() {
 	node := gt.GetCurrentNode()
 	if node == nil {

+ 13 - 7
internal/ui/chat/message_input.go

@@ -66,7 +66,6 @@ func newMessageInput(cfg *config.Config, chatView *View) *messageInput {
 		mentionsList:    newMentionsList(cfg),
 	}
 	mi.Box = ui.ConfigureBox(mi.Box, &cfg.Theme)
-	mi.SetInputCapture(mi.onInputCapture)
 	mi.
 		SetPlaceholder(tview.NewLine(tview.NewSegment("Select a channel to start chatting", tcell.StyleDefault.Dim(true)))).
 		SetClipboard(
@@ -93,7 +92,7 @@ func (mi *messageInput) stopTypingTimer() {
 	}
 }
 
-func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+func (mi *messageInput) handleInput(event *tcell.EventKey) *tcell.EventKey {
 	switch {
 	case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Paste.Keybind):
 		mi.paste()
@@ -144,19 +143,18 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 
 	if mi.cfg.AutocompleteLimit > 0 {
 		if mi.chatView.GetVisible(mentionsListLayerName) {
-			handler := mi.mentionsList.InputHandler()
 			switch {
 			case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Up.Keybind):
-				handler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil)
+				mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil)
 				return nil
 			case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Down.Keybind):
-				handler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone), nil)
+				mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone), nil)
 				return nil
 			case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Top.Keybind):
-				handler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone), nil)
+				mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone), nil)
 				return nil
 			case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Bottom.Keybind):
-				handler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil)
+				mi.mentionsList.InputHandler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil)
 				return nil
 			}
 		}
@@ -167,6 +165,14 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	return event
 }
 
+func (mi *messageInput) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+	event = mi.handleInput(event)
+	if event == nil {
+		return
+	}
+	mi.TextArea.InputHandler(event, setFocus)
+}
+
 func (mi *messageInput) paste() {
 	if data := clipboard.Read(clipboard.FmtImage); data != nil {
 		name := "clipboard.png"

+ 24 - 98
internal/ui/chat/messages_list.go

@@ -52,6 +52,8 @@ type messagesList struct {
 	// itemByID caches unselected message TextViews.
 	itemByID map[discord.MessageID]*tview.TextView
 
+	attachmentsPicker *attachmentsPicker
+
 	fetchingMembers struct {
 		mu    sync.Mutex
 		value bool
@@ -83,6 +85,7 @@ func newMessagesList(cfg *config.Config, chatView *View) *messagesList {
 		renderer: markdown.NewRenderer(cfg),
 		itemByID: make(map[discord.MessageID]*tview.TextView),
 	}
+	ml.attachmentsPicker = newAttachmentsPicker(cfg, chatView)
 
 	ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme)
 	ml.SetTitle("Messages")
@@ -94,7 +97,6 @@ func newMessagesList(cfg *config.Config, chatView *View) *messagesList {
 		SetTrackStyle(cfg.Theme.ScrollBar.TrackStyle.Style).
 		SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style).
 		SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet))
-	ml.SetInputCapture(ml.onInputCapture)
 	return ml
 }
 
@@ -526,7 +528,7 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) {
 	return &ml.messages[cursor], nil
 }
 
-func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+func (ml *messagesList) handleInput(event *tcell.EventKey) *tcell.EventKey {
 	switch {
 	case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollUp.Keybind):
 		ml.ScrollUp()
@@ -592,6 +594,14 @@ func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	return event
 }
 
+func (ml *messagesList) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+	event = ml.handleInput(event)
+	if event == nil {
+		return
+	}
+	ml.List.InputHandler(event, setFocus)
+}
+
 func (ml *messagesList) selectUp() {
 	messages := ml.messages
 	if len(messages) == 0 {
@@ -788,19 +798,8 @@ func extractURLs(content string) []string {
 }
 
 func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord.Attachment) {
-	type attachmentAction struct {
-		label    string
-		shortcut rune
-		open     func()
-	}
-
-	closeList := func() {
-		ml.chatView.RemoveLayer(attachmentsListLayerName)
-		ml.chatView.app.SetFocus(ml)
-	}
-
-	var actions []attachmentAction
-	for i, a := range attachments {
+	var items []attachmentItem
+	for _, a := range attachments {
 		attachment := a
 		action := func() {
 			if strings.HasPrefix(attachment.ContentType, "image/") {
@@ -809,103 +808,30 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
 				go ml.openURL(attachment.URL)
 			}
 		}
-		actions = append(actions, attachmentAction{
-			label:    attachment.Filename,
-			shortcut: rune('a' + i),
-			open:     action,
+		items = append(items, attachmentItem{
+			label: attachment.Filename,
+			open:  action,
 		})
 	}
-	for i, u := range urls {
+	for _, u := range urls {
 		url := u
-		actions = append(actions, attachmentAction{
-			label:    url,
-			shortcut: rune('1' + i),
-			open:     func() { go ml.openURL(url) },
+		items = append(items, attachmentItem{
+			label: url,
+			open:  func() { go ml.openURL(url) },
 		})
 	}
-
-	normalItems := make([]*tview.TextView, len(actions))
-	selectedItems := make([]*tview.TextView, len(actions))
-	for i, action := range actions {
-		normalItems[i] = tview.NewTextView().
-			SetScrollable(false).
-			SetWrap(false).
-			SetWordWrap(false).
-			SetLines([]tview.Line{{{Text: action.label, Style: tcell.StyleDefault}}})
-		selectedItems[i] = tview.NewTextView().
-			SetScrollable(false).
-			SetWrap(false).
-			SetWordWrap(false).
-			SetLines([]tview.Line{{{Text: action.label, Style: tcell.StyleDefault.Reverse(true)}}})
-	}
-
-	list := tview.NewList().
-		SetSnapToItems(true).
-		SetBuilder(func(index int, cursor int) tview.ListItem {
-			if index < 0 || index >= len(actions) {
-				return nil
-			}
-			if index == cursor {
-				return selectedItems[index]
-			}
-			return normalItems[index]
-		})
-	list.Box = ui.ConfigureBox(list.Box, &ml.cfg.Theme)
-	if len(actions) > 0 {
-		list.SetCursor(0)
-	}
-	list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-		switch {
-		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind):
-			return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
-		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind):
-			return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone)
-		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectTop.Keybind):
-			return tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)
-		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectBottom.Keybind):
-			return tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)
-		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):
-			closeList()
-			return nil
-		}
-
-		if event.Key() == tcell.KeyEnter || event.Key() == tcell.KeyRune && event.Str() == " " {
-			index := list.Cursor()
-			if index >= 0 && index < len(actions) {
-				actions[index].open()
-				closeList()
-			}
-			return nil
-		}
-
-		if event.Key() == tcell.KeyRune {
-			key := event.Str()
-			if key == "" {
-				return event
-			}
-			ch := []rune(key)[0]
-			for index, action := range actions {
-				if action.shortcut == ch {
-					list.SetCursor(index)
-					actions[index].open()
-					closeList()
-					return nil
-				}
-			}
-		}
-
-		return event
-	})
+	ml.attachmentsPicker.SetItems(items)
 
 	ml.chatView.
 		AddLayer(
-			ui.Centered(list, 0, 0),
+			ui.Centered(ml.attachmentsPicker, 0, 0),
 			layers.WithName(attachmentsListLayerName),
 			layers.WithResize(true),
 			layers.WithVisible(true),
 			layers.WithOverlay(),
 		).
 		SendToFront(attachmentsListLayerName)
+	ml.chatView.app.SetFocus(ml.attachmentsPicker)
 }
 
 func (ml *messagesList) openAttachment(attachment discord.Attachment) {

+ 9 - 2
internal/ui/chat/view.go

@@ -91,7 +91,6 @@ func NewView(app *tview.Application, cfg *config.Config, onLogout func()) *View
 	v.help.SetBorderPadding(0, 0, cfg.Help.Padding[0], cfg.Help.Padding[1])
 
 	v.SetBackgroundLayerStyle(v.cfg.Theme.Dialog.BackgroundStyle.Style)
-	v.SetInputCapture(v.onInputCapture)
 	v.buildLayout()
 	return v
 }
@@ -222,7 +221,7 @@ func (v *View) focusNext() {
 	}
 }
 
-func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+func (v *View) handleInput(event *tcell.EventKey) *tcell.EventKey {
 	switch {
 	case keybind.Matches(event, v.cfg.Keybinds.ToggleHelp.Keybind):
 		v.help.SetShowAll(!v.help.ShowAll())
@@ -267,6 +266,14 @@ func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	return event
 }
 
+func (v *View) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+	event = v.handleInput(event)
+	if event == nil {
+		return
+	}
+	v.Layers.InputHandler(event, setFocus)
+}
+
 func (v *View) updateHelpHeight() {
 	height := 1
 	if v.help.ShowAll() {

+ 12 - 11
internal/ui/login/qr.go

@@ -58,21 +58,22 @@ func newQRLogin(app *tview.Application, cfg *config.Config, done func(token stri
 		SetChangedFunc(func() {
 			q.app.QueueUpdateDraw(func() {})
 		}).
-		SetTitle("Login with QR").
-		SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
-			if ev.Key() == tcell.KeyEsc {
-				q.stop()
-				if q.done != nil {
-					q.done("", nil)
-				}
-				return nil
-			}
-			return ev
-		})
+		SetTitle("Login with QR")
 
 	return q
 }
 
+func (q *qrLogin) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+	if event.Key() == tcell.KeyEsc {
+		q.stop()
+		if q.done != nil {
+			q.done("", nil)
+		}
+		return
+	}
+	q.TextView.InputHandler(event, setFocus)
+}
+
 func (q *qrLogin) start() {
 	ctx, cancel := context.WithCancel(context.Background())
 	q.cancel = cancel

+ 5 - 0
internal/ui/root/suspend_default.go

@@ -0,0 +1,5 @@
+//go:build !unix
+
+package root
+
+func (v *View) suspend() {}

+ 3 - 3
internal/app/suspend_unix.go → internal/ui/root/suspend_unix.go

@@ -1,6 +1,6 @@
 //go:build unix
 
-package app
+package root
 
 import (
 	"os"
@@ -8,8 +8,8 @@ import (
 	"syscall"
 )
 
-func (a *App) suspend() {
-	a.inner.Suspend(func() {
+func (v *View) suspend() {
+	v.app.Suspend(func() {
 		c := make(chan os.Signal, 1)
 		signal.Notify(c, syscall.SIGCONT)
 		defer signal.Stop(c)

+ 209 - 0
internal/ui/root/view.go

@@ -0,0 +1,209 @@
+package root
+
+import (
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/ayn2op/discordo/internal/clipboard"
+	"github.com/ayn2op/discordo/internal/config"
+	"github.com/ayn2op/discordo/internal/consts"
+	"github.com/ayn2op/discordo/internal/keyring"
+	"github.com/ayn2op/discordo/internal/ui/chat"
+	"github.com/ayn2op/discordo/internal/ui/login"
+	"github.com/ayn2op/tview"
+	"github.com/ayn2op/tview/keybind"
+	"github.com/gdamore/tcell/v3"
+)
+
+type View struct {
+	*tview.Box
+	app   *tview.Application
+	inner tview.Primitive
+	chat  *chat.View
+
+	cfg *config.Config
+}
+
+func NewView(cfg *config.Config) *View {
+	tview.Styles = tview.Theme{}
+	v := &View{
+		Box: tview.NewBox(),
+		app: tview.NewApplication(),
+		cfg: cfg,
+	}
+
+	if err := clipboard.Init(); err != nil {
+		slog.Error("failed to init clipboard", "err", err)
+	}
+
+	return v
+}
+
+func (v *View) Run() error {
+	token := os.Getenv("DISCORDO_TOKEN")
+	if token == "" {
+		t, err := keyring.GetToken()
+		if err != nil {
+			slog.Info("failed to retrieve token from keyring", "err", err)
+		}
+		token = t
+	}
+
+	screen, err := tcell.NewScreen()
+	if err != nil {
+		return fmt.Errorf("failed to create screen: %w", err)
+	}
+
+	if err := screen.Init(); err != nil {
+		return fmt.Errorf("failed to init screen: %w", err)
+	}
+
+	if v.cfg.Mouse {
+		screen.EnableMouse()
+	}
+
+	screen.SetTitle(consts.Name)
+	screen.EnablePaste()
+	screen.EnableFocus()
+	v.app.SetScreen(screen)
+	v.app.SetRoot(v)
+
+	if token == "" {
+		v.showLoginView()
+	} else {
+		if err := v.showChatView(token); err != nil {
+			return err
+		}
+	}
+
+	v.app.SetFocus(v)
+	err = v.app.Run()
+	v.closeChatViewState()
+	return err
+}
+
+func (v *View) showLoginView() {
+	loginForm := login.NewForm(v.app, v.cfg, func(token string) {
+		if err := v.showChatView(token); err != nil {
+			slog.Error("failed to show chat view", "err", err)
+		}
+	})
+	v.inner = loginForm
+	v.MarkDirty()
+	v.app.SetFocus(v)
+}
+
+func (v *View) showChatView(token string) error {
+	v.chat = chat.NewView(v.app, v.cfg, v.showLoginView)
+	if err := v.chat.OpenState(token); err != nil {
+		return err
+	}
+	v.inner = v.chat
+	v.MarkDirty()
+	v.app.SetFocus(v)
+	return nil
+}
+
+func (v *View) quit() {
+	v.closeChatViewState()
+	v.app.Stop()
+}
+
+func (v *View) closeChatViewState() {
+	if v.chat != nil {
+		if err := v.chat.CloseState(); err != nil {
+			slog.Error("failed to close the session", "err", err)
+		}
+		v.chat = nil
+	}
+}
+
+func (v *View) Draw(screen tcell.Screen) {
+	if v.inner == nil {
+		return
+	}
+	x, y, width, height := v.GetRect()
+	v.inner.SetRect(x, y, width, height)
+	v.inner.Draw(screen)
+}
+
+func (v *View) InputHandler(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+	switch {
+	case keybind.Matches(event, v.cfg.Keybinds.Suspend.Keybind):
+		v.suspend()
+		return
+	case keybind.Matches(event, v.cfg.Keybinds.Quit.Keybind):
+		v.quit()
+		return
+	}
+
+	if v.inner != nil {
+		v.inner.InputHandler(event, setFocus)
+	}
+}
+
+func (v *View) Focus(delegate func(p tview.Primitive)) {
+	if v.inner != nil {
+		delegate(v.inner)
+		return
+	}
+	v.Box.Focus(delegate)
+}
+
+func (v *View) HasFocus() bool {
+	if v.inner != nil && v.inner.HasFocus() {
+		return true
+	}
+	return v.Box.HasFocus()
+}
+
+func (v *View) Blur() {
+	if v.inner != nil {
+		v.inner.Blur()
+	}
+	v.Box.Blur()
+}
+
+func (v *View) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) {
+	return v.WrapMouseHandler(func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) {
+		if v.inner == nil {
+			return false, nil
+		}
+		handler := v.inner.MouseHandler()
+		if handler == nil {
+			return false, nil
+		}
+		return handler(action, event, setFocus)
+	})
+}
+
+func (v *View) PasteHandler() func(text string, setFocus func(p tview.Primitive)) {
+	return v.WrapPasteHandler(func(text string, setFocus func(p tview.Primitive)) {
+		if v.inner == nil {
+			return
+		}
+		handler := v.inner.PasteHandler()
+		if handler == nil {
+			return
+		}
+		handler(text, setFocus)
+	})
+}
+
+func (v *View) IsDirty() bool {
+	if v.Box.IsDirty() {
+		return true
+	}
+	if v.inner == nil {
+		return false
+	}
+	return v.inner.IsDirty()
+}
+
+func (v *View) MarkClean() {
+	v.Box.MarkClean()
+	if v.inner != nil {
+		v.inner.MarkClean()
+	}
+}

+ 15 - 9
pkg/picker/picker.go

@@ -43,8 +43,7 @@ func New() *Picker {
 		SetLabel("> ").
 		SetBorders(tview.BordersBottom).
 		SetBorderSet(borderSet).
-		SetBorderStyle(tcell.StyleDefault.Dim(true)).
-		SetInputCapture(p.onInputCapture)
+		SetBorderStyle(tcell.StyleDefault.Dim(true))
 
 	p.
 		SetDirection(tview.FlexRow).
@@ -149,24 +148,23 @@ func (p *Picker) onInputChanged(text string) {
 	p.setFilteredItems(fuzzied)
 }
 
-func (p *Picker) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+func (p *Picker) handleInput(event *tcell.EventKey) *tcell.EventKey {
 	if p.keyMap == nil {
-		return nil
+		return event
 	}
 
-	handler := p.list.InputHandler()
 	switch {
 	case keybind.Matches(event, p.keyMap.Up):
-		handler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil)
+		p.list.InputHandler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil)
 		return nil
 	case keybind.Matches(event, p.keyMap.Down):
-		handler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone), nil)
+		p.list.InputHandler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone), nil)
 		return nil
 	case keybind.Matches(event, p.keyMap.Top):
-		handler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone), nil)
+		p.list.InputHandler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone), nil)
 		return nil
 	case keybind.Matches(event, p.keyMap.Bottom):
-		handler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil)
+		p.list.InputHandler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil)
 	case keybind.Matches(event, p.keyMap.Select):
 		p.onListSelected(p.list.Cursor())
 		return nil
@@ -180,3 +178,11 @@ func (p *Picker) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 
 	return event
 }
+
+func (p *Picker) InputHandler(event *tcell.EventKey, setFocus func(p2 tview.Primitive)) {
+	event = p.handleInput(event)
+	if event == nil {
+		return
+	}
+	p.Flex.InputHandler(event, setFocus)
+}