package ui import ( "strings" "time" "github.com/anotherhadi/usbguard-tui/internal/guard" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type state int const ( stateList state = iota statePopup ) type ( tickMsg time.Time devicesMsg []guard.Device daemonStatusMsg string actionMsg struct{ err error } ) type Model struct { state state list list.Model actionList list.Model help help.Model daemonStatus string width int height int notice string selectedDev *guard.Device } func New() Model { l := list.New(nil, deviceDelegate{}, 0, 0) l.SetShowHelp(false) l.SetFilteringEnabled(true) l.SetShowStatusBar(true) l.SetShowTitle(false) l.DisableQuitKeybindings() // free j/k for our shortcuts l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")) l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")) l.FilterInput.PromptStyle = lipgloss.NewStyle().Foreground(colorAccent) l.FilterInput.Cursor.Style = lipgloss.NewStyle().Foreground(colorAccent) return Model{ state: stateList, list: l, actionList: makeActionList(), help: help.New(), } } func makeActionList() list.Model { items := []list.Item{ actionItem{"allow", guard.AllowDevice, false, guard.Allowed}, actionItem{"allow (permanent)", guard.AllowDevice, true, guard.Allowed}, actionItem{"block", guard.BlockDevice, false, guard.Blocked}, actionItem{"block (permanent)", guard.BlockDevice, true, guard.Blocked}, actionItem{"reject", guard.RejectDevice, false, guard.Rejected}, actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected}, } l := list.New(items, actionDelegate{}, 24, 6) l.SetShowHelp(false) l.SetShowTitle(false) l.SetShowStatusBar(false) l.DisableQuitKeybindings() l.SetFilteringEnabled(false) return l } func (m Model) Init() tea.Cmd { return tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd()) } 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.help.Width = msg.Width m.list.SetSize(msg.Width, m.listHeight()) m.updateActionListSize() return m, nil case tickMsg: return m, tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd()) case devicesMsg: items := make([]list.Item, len(msg)) for i, d := range msg { items[i] = d } cmd := m.list.SetItems(items) return m, cmd case daemonStatusMsg: m.daemonStatus = string(msg) return m, nil case actionMsg: m.state = stateList m.selectedDev = nil if msg.err != nil { switch msg.err { case guard.ErrReadOnly: m.notice = "Read-only rules: applied temporarily. Add the rule to your config for persistence." case guard.ErrPermission: m.notice = "Permission denied. Run with appropriate privileges." default: m.notice = msg.err.Error() } } else { m.notice = "" } return m, fetchDevices case tea.KeyMsg: if m.state == statePopup { return m.updatePopup(msg) } return m.updateList(msg) } if m.state == stateList { var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } return m, nil } func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { return m, tea.Quit } if !m.list.SettingFilter() { id := m.selectedDevID() switch { case key.Matches(msg, listKeys.Quit): return m, tea.Quit case key.Matches(msg, listKeys.Refresh): m.notice = "" return m, tea.Batch(fetchDevices, fetchDaemonStatus) case key.Matches(msg, listKeys.Help): m.help.ShowAll = !m.help.ShowAll m.list.SetSize(m.width, m.listHeight()) return m, nil case key.Matches(msg, listKeys.Open): if item := m.list.SelectedItem(); item != nil { d := item.(guard.Device) m.selectedDev = &d m.updateActionListSize() m.actionList.Select(0) m.state = statePopup return m, nil } case id >= 0 && key.Matches(msg, listKeys.Allow): return m, doAction(id, guard.AllowDevice, false) case id >= 0 && key.Matches(msg, listKeys.AllowPerm): return m, doAction(id, guard.AllowDevice, true) case id >= 0 && key.Matches(msg, listKeys.Block): return m, doAction(id, guard.BlockDevice, false) case id >= 0 && key.Matches(msg, listKeys.BlockPerm): return m, doAction(id, guard.BlockDevice, true) case id >= 0 && key.Matches(msg, listKeys.Reject): return m, doAction(id, guard.RejectDevice, false) case id >= 0 && key.Matches(msg, listKeys.RejectPerm): return m, doAction(id, guard.RejectDevice, true) } } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } func (m Model) updatePopup(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, cancelKey): m.state = stateList m.selectedDev = nil return m, nil case key.Matches(msg, listKeys.Open): if item := m.actionList.SelectedItem(); item != nil { a := item.(actionItem) return m, doAction(m.selectedDev.ID, a.fn, a.permanent) } } var cmd tea.Cmd m.actionList, cmd = m.actionList.Update(msg) return m, cmd } func (m Model) View() string { header := m.renderHeader() notice := m.renderNotice() listView := strings.TrimRight(m.list.View(), "\n") helpView := strings.TrimRight(m.help.View(listKeys), "\n") bg := strings.Join([]string{header, listView, notice, helpView}, "\n") if m.state == statePopup && m.selectedDev != nil { return placeOverlay(bg, m.renderActionSelect(), m.width, m.height) } return bg } func (m Model) renderHeader() string { title := headerStyle.Render("USBGuard-tui") switch m.daemonStatus { case "active": return title + mutedStyle.Render(" - ") + daemonActiveStyle.Render("active") case "": return title default: return title + mutedStyle.Render(" - ") + daemonOtherStyle.Render(m.daemonStatus) } } func (m Model) renderNotice() string { if m.notice == "" { return "" } return warnStyle.Render(m.notice) } func (m Model) renderActionSelect() string { dev := m.selectedDev color := statusColors[dev.Status] innerW := m.actionListInnerWidth() title := popupTitleStyle.Copy().Foreground(color).Width(innerW).Render(dev.Name) hint := lipgloss.NewStyle().Foreground(colorMuted).Width(innerW).Render("↑↓ navigate enter confirm esc cancel") content := strings.Join([]string{title, m.actionList.View(), "", hint}, "\n") return popupStyle.Width(innerW).Render(content) } func (m Model) popupOuterWidth() int { w := m.width - 6 if w > 60 { w = 60 } if w < 32 { w = 32 } return w } func (m Model) actionListInnerWidth() int { return m.popupOuterWidth() - 8 // border(2) + padding_h(6) } // updateActionListSize sizes the action list and toggles pagination based on available space. // When there is enough room for all items: pagination is hidden and height is set exactly, // avoiding the phantom line that bubbles/list reserves when showPagination=true. // When space is limited: pagination is shown naturally by bubbles/list. func (m *Model) updateActionListSize() { const items = 6 innerW := m.actionListInnerWidth() // popup overhead: border(2) + padding_v(2) + title(1) + blank(1) + hint(1) = 7 available := m.height - 7 - 2 // 2 lines margin if available >= items { m.actionList.SetShowPagination(false) m.actionList.SetSize(innerW, items) } else { m.actionList.SetShowPagination(true) h := available if h < 2 { h = 2 } m.actionList.SetSize(innerW, h) } } func (m Model) listHeight() int { helpH := lipgloss.Height(strings.TrimRight(m.help.View(listKeys), "\n")) return m.height - 1 - helpH - 1 // header - help - notice } func (m Model) selectedDevID() int { if item := m.list.SelectedItem(); item != nil { return item.(guard.Device).ID } return -1 } func tickCmd() tea.Cmd { return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } func fetchDevices() tea.Msg { devices, err := guard.ListDevices() if err != nil { return actionMsg{err: err} } return devicesMsg(devices) } func fetchDaemonStatus() tea.Msg { return daemonStatusMsg(guard.DaemonStatus()) } func doAction(id int, fn func(int, bool) error, permanent bool) tea.Cmd { return func() tea.Msg { return actionMsg{err: fn(id, permanent)} } }