New features and fixes: new shortcuts, UI/UX, better status resolution

Signed-off-by: Hadi <hadi@example.fr>
This commit is contained in:
Hadi
2026-08-20 15:52:24 +02:00
parent 031ff48696
commit bbab824a1d
14 changed files with 690 additions and 363 deletions
+2
View File
@@ -0,0 +1,2 @@
/go.work
/go.work.sum
+3 -3
View File
@@ -23,12 +23,12 @@ Colors and styles can be customized using [ilovetui](https://github.com/anotherh
## Features
- List all connected USB devices with their current status (allowed, blocked, rejected)
- List all connected USB devices with their current status
- Allow, block, or reject devices: temporarily or permanently
- Action popup with mouse support for quick device management
- Action popup for quick device management
- Filter devices by name with `/`
- Auto-refresh
- Keyboard shortcuts for all actions (`a`/`A`, `b`/`B`, `e`/`E`)
- Keyboard shortcuts for all actions (`a`/`A`, `b`/`B`, `e`/`E`, ...)
## Requirements
+3 -1
View File
@@ -6,16 +6,17 @@ require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
charm.land/lipgloss/v2 v2.0.3
github.com/anotherhadi/ilovetui v0.2.1
github.com/charmbracelet/x/ansi v0.11.7
)
require (
charm.land/glamour/v2 v2.0.0 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/anotherhadi/ilovetui v0.1.6 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -24,6 +25,7 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
+15 -2
View File
@@ -6,10 +6,16 @@ charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/anotherhadi/ilovetui v0.1.6 h1:NKg+T1DpV08Q4r+iowFrXF+0bTd6Y2f4OFpFwhsfsyY=
github.com/anotherhadi/ilovetui v0.1.6/go.mod h1:HVai6u5NGKSMOpmioYpwrN0lSxQjc7HtISUc5hTwvOw=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anotherhadi/ilovetui v0.2.1 h1:ndU52r0nzlT6LM53HNBvwVyvn3dRPKrbGVvzux9YXbk=
github.com/anotherhadi/ilovetui v0.2.1/go.mod h1:gAn69amLM3z0x8PV4obyf5bJntL/PTZusEuhu/xdFIY=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
@@ -18,6 +24,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
@@ -38,8 +46,12 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
@@ -71,6 +83,7 @@ golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+112 -17
View File
@@ -21,37 +21,115 @@ func ListDevices() ([]Device, error) {
if err != nil {
return nil, wrapExecError(err)
}
rules := listRules()
deviceRuleText := devicePolicyRuleText()
permanentTexts, havePermanentTexts := permanentRuleTexts()
implicitTarget, haveImplicitTarget := implicitPolicyTarget()
var devices []Device
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
d, err := parseLine(line)
if err == nil {
d.Permanent = rules[d.Hash] == d.Status
devices = append(devices, d)
if err != nil {
continue
}
d.RuleState = resolveRuleState(d, deviceRuleText, permanentTexts, havePermanentTexts, implicitTarget, haveImplicitTarget)
devices = append(devices, d)
}
return devices, nil
}
func listRules() map[string]Status {
out, err := exec.Command("usbguard", "list-rules").Output()
func resolveRuleState(d Device, deviceRuleText map[int]string, permanentTexts map[string]bool, havePermanentTexts bool, implicitTarget Status, haveImplicitTarget bool) RuleState {
text, matched := deviceRuleText[d.ID]
if matched {
if !havePermanentTexts {
return RulePermanent
}
if permanentTexts[text] {
return RulePermanent
}
return RuleTemporary
}
if !haveImplicitTarget || d.Status != implicitTarget {
return RuleTemporary
}
return RuleDefault
}
func implicitPolicyTarget() (Status, bool) {
out, err := exec.Command("usbguard", "get-parameter", "ImplicitPolicyTarget").Output()
if err != nil {
return "", false
}
return Status(strings.TrimSpace(string(out))), true
}
func DefaultPolicy() Status {
target, _ := implicitPolicyTarget()
return target
}
func devicePolicyRuleText() map[int]string {
out, err := exec.Command("usbguard", "list-rules", "-d").Output()
if err != nil {
return nil
}
rules := make(map[string]Status)
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
result := make(map[int]string)
var currentRuleText string
for _, line := range strings.Split(string(out), "\n") {
if line == "" {
continue
}
d, err := parseLine(line)
if err == nil && d.Hash != "" {
rules[d.Hash] = d.Status
if line[0] == ' ' || line[0] == '\t' {
trimmed := strings.TrimLeft(line, " \t")
colonIdx := strings.Index(trimmed, ":")
if colonIdx < 0 {
continue
}
devID, err := strconv.Atoi(strings.TrimSpace(trimmed[:colonIdx]))
if err != nil {
continue
}
return rules
result[devID] = currentRuleText
continue
}
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
continue
}
currentRuleText = normalizeRuleText(line[colonIdx+1:])
}
return result
}
func permanentRuleTexts() (map[string]bool, bool) {
path := ruleFilePath()
if path == "" {
return nil, false
}
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
texts := make(map[string]bool)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
texts[normalizeRuleText(line)] = true
}
return texts, true
}
func normalizeRuleText(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func AllowDevice(id int, permanent bool) error { return applyPolicy("allow-device", id, permanent) }
@@ -88,16 +166,32 @@ func wrapExecError(err error) error {
}
func IsRulesManaged() bool {
return strings.HasPrefix(ruleFilePath(), "/nix/store/")
}
func RulesWritable() (bool, bool) {
path := ruleFilePath()
if path == "" {
return false, false
}
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
return false, true
}
f.Close()
return true, true
}
func ruleFilePath() string {
out, err := exec.Command("systemctl", "cat", "usbguard").Output()
if err != nil {
return false
return ""
}
configPath := extractConfigPath(string(out))
if configPath == "" {
return false
return ""
}
ruleFile := parseRuleFilePath(configPath)
return strings.HasPrefix(ruleFile, "/nix/store/")
return parseRuleFilePath(configPath)
}
func extractConfigPath(s string) string {
@@ -126,7 +220,8 @@ func parseRuleFilePath(configPath string) string {
func classifyError(output string) error {
lower := strings.ToLower(output)
switch {
case strings.Contains(lower, "permission denied"), strings.Contains(lower, "not authorized"):
case strings.Contains(lower, "permission denied"), strings.Contains(lower, "not authorized"),
strings.Contains(lower, "operation not permitted"):
return ErrPermission
case strings.Contains(lower, "read-only"), strings.Contains(lower, "immutable"):
return ErrReadOnly
+9 -3
View File
@@ -15,21 +15,27 @@ const (
Rejected Status = "reject"
)
type RuleState string
const (
RuleDefault RuleState = "default"
RuleTemporary RuleState = "temporary"
RulePermanent RuleState = "permanent"
)
type Device struct {
ID int
Name string
Status Status
VidPid string
Hash string
Permanent bool
RuleState RuleState
}
func (d Device) Title() string { return d.Name }
func (d Device) Description() string { return fmt.Sprintf("id:%-3d %s", d.ID, d.VidPid) }
func (d Device) FilterValue() string { return d.Name + " " + d.VidPid }
// parseLine parses a line from "usbguard list-devices":
// 1: allow id 04b3:301b serial "" name "USB Hub" hash "..." via-port "usb1"
func parseLine(line string) (Device, error) {
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
+81
View File
@@ -0,0 +1,81 @@
package ui
import (
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
type actionModal struct {
list list.Model
dev guard.Device
rulesManaged bool
}
func newActionModal(dev guard.Device, rulesManaged bool) tea.Model {
return actionModal{
list: makeActionList(rulesManaged),
dev: dev,
rulesManaged: rulesManaged,
}
}
func actionModalStyle(status guard.Status) modal.Styles {
s := modal.DefaultStyles()
if clr, ok := statusColors[status]; ok {
s.Title = s.Title.Foreground(clr)
}
return s
}
func (a actionModal) Init() tea.Cmd { return nil }
func (a actionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, cancelKey):
return a, modal.Close()
case key.Matches(msg, listKeys.Open):
item := a.list.SelectedItem()
if item == nil {
return a, nil
}
it := item.(actionItem)
if it.nixos {
rule := guard.NixOSRule(a.dev, it.status)
return a, tea.Batch(modal.Close(), func() tea.Msg { return nixRuleMsg{rule: rule} })
}
return a, tea.Batch(modal.Close(), doAction(a.dev.ID, it.fn, it.permanent))
}
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
a.list.CursorUp()
case tea.MouseWheelDown:
a.list.CursorDown()
}
return a, nil
}
var cmd tea.Cmd
a.list, cmd = a.list.Update(msg)
return a, cmd
}
func (a actionModal) View() tea.View {
hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
parts := []string{a.list.View(), ""}
if a.rulesManaged {
parts = append(parts, hintStyle.Render("[NixOS: perm rules printed on exit]"))
}
parts = append(parts, hintStyle.Render("↑↓ navigate enter confirm esc cancel"))
return tea.NewView(strings.Join(parts, "\n"))
}
+21 -12
View File
@@ -7,10 +7,16 @@ import (
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
var ruleStateIndicators = map[guard.RuleState]string{
guard.RulePermanent: "● perm",
guard.RuleTemporary: "○ tmp",
guard.RuleDefault: "· default",
}
type deviceDelegate struct{}
func (d deviceDelegate) Height() int { return 2 }
@@ -31,34 +37,37 @@ func (d deviceDelegate) Render(w io.Writer, m list.Model, index int, item list.I
}
clr, ok := colorMap[dev.Status]
if !ok {
clr = ilovetui.S.Muted
clr = style.S.Muted
}
var nameStyle, descStyle lipgloss.Style
if selected {
nameStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(ilovetui.S.Primary).
BorderForeground(style.S.Primary).
Foreground(clr).
Bold(true).
PaddingLeft(1)
descStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(ilovetui.S.Primary).
Foreground(ilovetui.S.Muted).
BorderForeground(style.S.Primary).
Foreground(style.S.Muted).
PaddingLeft(1)
} else {
nameStyle = lipgloss.NewStyle().Foreground(clr).PaddingLeft(2)
descStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Muted).PaddingLeft(2)
descStyle = lipgloss.NewStyle().Foreground(style.S.Muted).PaddingLeft(2)
}
permIndicator := "○ tmp"
if dev.Permanent {
permIndicator = "● perm"
name := dev.Name
if icon := deviceIcon(dev.Name); icon != "" {
name = icon + " " + name
}
permIndicator := ruleStateIndicators[dev.RuleState]
width := m.Width()
fmt.Fprintf(w, "%s\n%s",
nameStyle.Render(dev.Name),
descStyle.Render(fmt.Sprintf("id:%-3d %s %s %s", dev.ID, dev.VidPid, string(dev.Status), permIndicator)),
clampToWidth(nameStyle.Render(name), width),
clampToWidth(descStyle.Render(fmt.Sprintf("id:%-3d %s %s %s", dev.ID, dev.VidPid, string(dev.Status), permIndicator)), width),
)
}
@@ -88,7 +97,7 @@ func (d actionDelegate) Render(w io.Writer, m list.Model, index int, item list.I
if index == m.Index() {
clr, ok := statusColorsSelected[a.status]
if !ok {
clr = ilovetui.S.Primary
clr = style.S.Primary
}
fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(clr).Render("> "+a.label))
} else {
+58
View File
@@ -0,0 +1,58 @@
package ui
import (
"strings"
"github.com/anotherhadi/ilovetui/style"
)
const (
iconUSB = " "
iconKeyboard = ""
iconPointer = ""
iconCamera = ""
iconStorage = ""
iconAudio = ""
iconBluetooth = ""
iconNetwork = ""
iconPrint = ""
iconHub = ""
iconPhone = ""
iconGamepad = ""
iconSecurity = ""
)
type deviceIconCategory struct {
icon string
keywords []string
}
var deviceIconCategories = []deviceIconCategory{
{iconKeyboard, []string{"keyboard"}},
{iconPointer, []string{"mouse", "touchpad", "trackpad"}},
{iconCamera, []string{"webcam", "camera"}},
{iconStorage, []string{"storage", "disk", "drive", "ssd", "data", "flash", "card reader"}},
{iconAudio, []string{"headset", "headphone", "audio", "speaker", "microphone"}},
{iconBluetooth, []string{"bluetooth"}},
{iconNetwork, []string{"wifi", "wi-fi", "wireless", "ethernet", "network adapter", "network card"}},
{iconPrint, []string{"printer", "scanner"}},
{iconHub, []string{"hub"}},
{iconPhone, []string{"phone", "android", "iphone"}},
{iconGamepad, []string{"gamepad", "joystick"}},
{iconSecurity, []string{"security key", "smartcard", "smart card", "yubikey", "fingerprint"}},
}
func deviceIcon(name string) string {
if !style.S.NerdFonts {
return ""
}
lower := strings.ToLower(name)
for _, c := range deviceIconCategories {
for _, kw := range c.keywords {
if strings.Contains(lower, kw) {
return c.icon + " "
}
}
}
return iconUSB
}
+30 -10
View File
@@ -8,38 +8,58 @@ type listKeyMap struct {
Refresh key.Binding
Quit key.Binding
Help key.Binding
// shown only in full help
Allow key.Binding
AllowPerm key.Binding
Block key.Binding
BlockPerm key.Binding
Reject key.Binding
RejectPerm key.Binding
AllowAll key.Binding
AllowAllPerm key.Binding
PrintAll key.Binding
Up key.Binding
Down key.Binding
GoToStart key.Binding
GoToEnd key.Binding
PrevPage key.Binding
NextPage key.Binding
}
func (k listKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Open, k.Filter, k.Refresh, k.Quit, k.Help}
}
func (k listKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Open, k.Filter, k.Refresh, k.Quit},
{k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm},
func (k listKeyMap) globalBindings() []key.Binding {
return []key.Binding{
k.Open, k.Filter, k.Refresh, k.Quit,
k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm,
k.AllowAll, k.AllowAllPerm, k.PrintAll,
k.Up, k.Down, k.GoToStart, k.GoToEnd, k.PrevPage, k.NextPage,
}
}
var listKeys = listKeyMap{
Open: key.NewBinding(key.WithKeys("enter", "tab", "l"), key.WithHelp("enter/l", "select action")),
Open: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter/tab", "select action")),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q/esc", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "more")),
Allow: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "allow")),
AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")),
Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")),
BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")),
Reject: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "reject")),
RejectPerm: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "reject (perm)")),
AllowAll: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "allow all")),
AllowAllPerm: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "allow all (perm)")),
PrintAll: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "print all rules")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
GoToStart: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")),
GoToEnd: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")),
PrevPage: key.NewBinding(key.WithKeys("left", "pgup", "h"), key.WithHelp("←/pgup/h", "prev page")),
NextPage: key.NewBinding(key.WithKeys("right", "pgdown", "l"), key.WithHelp("→/pgdn/l", "next page")),
}
var cancelKey = key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"), key.WithHelp("esc/q", "cancel"))
+267 -217
View File
@@ -5,81 +5,77 @@ import (
"strings"
"time"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
type state int
const (
stateList state = iota
statePopup
)
type (
tickMsg time.Time
devicesMsg []guard.Device
daemonStatusMsg string
defaultPolicyMsg guard.Status
actionMsg struct{ err error }
nixRuleMsg struct{ rule string }
)
type deviceSummary struct {
total, allowed, blocked, rejected int
}
type Model struct {
state state
list list.Model
actionList list.Model
help help.Model
help helpbar.Model
modals modal.Model
notif notification.Model
daemonStatus string
defaultPolicy guard.Status
deviceCounts deviceSummary
width int
height int
notice string
selectedDev *guard.Device
rulesManaged bool
rulesWritable bool
pendingRules []string
fatalShown bool
}
func (m Model) PendingRules() []string { return m.pendingRules }
func New() Model {
l := list.New(nil, deviceDelegate{}, 0, 0)
l := bubbles.NewList(nil, 0, 0)
l.SetDelegate(deviceDelegate{})
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(true)
l.SetShowStatusBar(false)
l.SetShowTitle(false)
l.DisableQuitKeybindings()
l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
l.Styles = list.DefaultStyles(true)
filterStyles := textinput.DefaultStyles(true)
filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(ilovetui.S.Primary)
filterStyles.Blurred.Prompt = filterStyles.Blurred.Prompt.Foreground(ilovetui.S.Primary)
l.Styles.Filter = filterStyles
h := ilovetui.NewHelp()
h := helpbar.New(
helpbar.WithToggle(listKeys.Help),
helpbar.WithGlobal(listKeys.globalBindings()...),
)
rulesManaged := guard.IsRulesManaged()
notice := ""
if rulesManaged {
notice = "Rules managed by NixOS config: permanent actions will print NixOS rules on exit."
listKeys.AllowPerm.SetEnabled(false)
listKeys.BlockPerm.SetEnabled(false)
listKeys.RejectPerm.SetEnabled(false)
}
rulesWritable, _ := guard.RulesWritable()
return Model{
state: stateList,
list: l,
actionList: makeActionList(rulesManaged),
help: h,
modals: modal.New(),
notif: notification.New(),
rulesManaged: rulesManaged,
notice: notice,
rulesWritable: rulesWritable,
}
}
@@ -104,20 +100,34 @@ func makeActionList(rulesManaged bool) list.Model {
actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected, false},
}
}
l := list.New(items, actionDelegate{}, 24, len(items))
l := bubbles.NewList(items, 24, len(items))
l.SetShowHelp(false)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowPagination(false)
l.DisableQuitKeybindings()
l.SetFilteringEnabled(false)
l.SetDelegate(actionDelegate{})
return l
}
func (m Model) Init() tea.Cmd {
return tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd())
return tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd(), m.modals.Init(), m.notif.Init())
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
next, cmd := m.updateMain(msg)
nm := next.(Model)
var modalCmd, notifCmd tea.Cmd
nm.modals, modalCmd = nm.modals.Update(msg)
nm.notif, notifCmd = nm.notif.Update(msg)
return nm, tea.Batch(cmd, modalCmd, notifCmd)
}
func (m Model) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@@ -125,17 +135,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.help.SetWidth(msg.Width)
m.list.SetSize(msg.Width, m.listHeight())
m.updateActionListSize()
return m, nil
case tickMsg:
return m, tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd())
return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd())
case devicesMsg:
items := make([]list.Item, len(msg))
summary := deviceSummary{total: len(msg)}
for i, d := range msg {
items[i] = d
switch d.Status {
case guard.Allowed:
summary.allowed++
case guard.Blocked:
summary.blocked++
case guard.Rejected:
summary.rejected++
}
}
m.deviceCounts = summary
cmd := m.list.SetItems(items)
return m, cmd
@@ -143,59 +162,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.daemonStatus = string(msg)
return m, nil
case defaultPolicyMsg:
m.defaultPolicy = guard.Status(msg)
return m, nil
case nixRuleMsg:
m.state = stateList
m.selectedDev = nil
m.pendingRules = append(m.pendingRules, msg.rule)
count := len(m.pendingRules)
if count == 1 {
m.notice = "1 NixOS rule queued (printed on exit)"
} else {
m.notice = fmt.Sprintf("%d NixOS rules queued (printed on exit)", count)
}
return m, nil
case actionMsg:
m.state = stateList
m.selectedDev = nil
if msg.err != nil {
switch msg.err {
case guard.ErrReadOnly:
m.notice = "Rules file is not writable: permanent changes are not supported."
case guard.ErrPermission:
m.notice = "Permission denied. Run with appropriate privileges."
default:
m.notice = msg.err.Error()
if msg.err == guard.ErrPermission {
if m.fatalShown {
return m, nil
}
} else {
m.notice = m.defaultNotice()
m.fatalShown = true
return m, modal.Show("Permission Error", newPermissionModal(),
modal.WithModalStyle(permissionModalStyle()))
}
return m, errorToast(msg.err)
}
return m, fetchDevices
case tea.KeyPressMsg:
if m.state == statePopup {
return m.updatePopup(msg)
if m.modals.Open() {
return m, nil
}
return m.updateList(msg)
case tea.MouseClickMsg:
if m.state == statePopup {
var cmd tea.Cmd
m.actionList, cmd = m.actionList.Update(msg)
return m, cmd
}
case tea.MouseWheelMsg:
return m.updateMouseWheel(msg)
if m.modals.Open() {
return m, nil
}
switch msg.Button {
case tea.MouseWheelUp:
m.list.CursorUp()
case tea.MouseWheelDown:
m.list.CursorDown()
}
return m, nil
}
if m.state == stateList {
if m.modals.Open() {
return m, nil
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
return m, nil
}
func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
@@ -203,29 +218,33 @@ func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
if !m.list.SettingFilter() {
id := m.selectedDevID()
dev, hasSelection := m.selectedDevice()
switch {
case key.Matches(msg, listKeys.Quit):
return m, tea.Quit
case key.Matches(msg, listKeys.Refresh):
m.notice = m.defaultNotice()
return m, tea.Batch(fetchDevices, fetchDaemonStatus)
return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy)
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
if hasSelection {
return m, modal.Show(dev.Name, newActionModal(dev, m.rulesManaged),
modal.WithModalStyle(actionModalStyle(dev.Status)))
}
case key.Matches(msg, listKeys.AllowAll):
return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, false)
case key.Matches(msg, listKeys.AllowAllPerm):
if m.rulesManaged {
return m, queueNixOSRules(m.visibleDevices(), guard.Allowed)
}
if id >= 0 {
if cmd := m.deviceActionCmd(msg, id); cmd != nil {
return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, true)
case key.Matches(msg, listKeys.PrintAll):
return m, queueCurrentStateRules(m.visibleDevices())
}
if hasSelection {
if cmd := m.deviceActionCmd(msg, dev); cmd != nil {
return m, cmd
}
}
@@ -235,27 +254,6 @@ func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, cmd
}
func (m Model) updatePopup(msg tea.KeyPressMsg) (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)
if a.nixos && m.selectedDev != nil {
rule := guard.NixOSRule(*m.selectedDev, a.status)
return m, func() tea.Msg { return nixRuleMsg{rule: rule} }
}
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() tea.View {
return tea.View{
Content: m.renderContent(),
@@ -267,115 +265,140 @@ func (m Model) View() tea.View {
func (m Model) renderContent() 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")
helpView := strings.TrimRight(m.help.View(), "\n")
bg := strings.Join([]string{header, listView, helpView}, "\n")
if m.state == statePopup && m.selectedDev != nil {
return placeOverlay(bg, m.renderActionSelect(), m.width, m.height)
}
bg = m.modals.Render(bg)
bg = m.notif.Render(bg)
return bg
}
func (m Model) renderHeader() string {
title := headerStyle.Render("USBGuard-tui")
lines := []string{
title,
m.renderServiceLine(),
m.renderPolicyLine(),
m.renderDevicesLine(),
m.renderRulesLine(),
}
if pending := m.renderPendingRulesLine(); pending != "" {
lines = append(lines, pending)
}
for i, l := range lines {
lines[i] = clampToWidth(l, m.width)
}
return strings.Join(lines, "\n")
}
func clampToWidth(s string, width int) string {
if width <= 0 {
return s
}
return ansi.Truncate(s, width, "…")
}
func (m Model) renderServiceLine() string {
label := infoLabelStyle.Render("Service")
switch m.daemonStatus {
case "active":
return title + mutedStyle.Render(" - ") + daemonActiveStyle.Render("active")
return label + daemonActiveStyle.Render("active")
case "":
return title
return label + mutedStyle.Render("checking...")
default:
return title + mutedStyle.Render(" - ") + daemonOtherStyle.Render(m.daemonStatus)
return label + daemonOtherStyle.Render(m.daemonStatus)
}
}
func (m Model) renderNotice() string {
if m.notice == "" {
func (m Model) renderPolicyLine() string {
label := infoLabelStyle.Render("Default policy")
if m.defaultPolicy == "" {
return label + mutedStyle.Render("unknown")
}
clr, ok := statusColors[m.defaultPolicy]
if !ok {
clr = style.S.Muted
}
return label + lipgloss.NewStyle().Foreground(clr).Render(string(m.defaultPolicy))
}
func (m Model) renderDevicesLine() string {
label := infoLabelStyle.Render("Devices")
if m.deviceCounts.total == 0 {
return label + mutedStyle.Render("0")
}
parts := []string{mutedStyle.Render(fmt.Sprintf("%d total", m.deviceCounts.total))}
if m.deviceCounts.allowed > 0 {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Allowed]).
Render(fmt.Sprintf("%d allow", m.deviceCounts.allowed)))
}
if m.deviceCounts.blocked > 0 {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Blocked]).
Render(fmt.Sprintf("%d block", m.deviceCounts.blocked)))
}
if m.deviceCounts.rejected > 0 {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Rejected]).
Render(fmt.Sprintf("%d reject", m.deviceCounts.rejected)))
}
return label + strings.Join(parts, mutedStyle.Render(" · "))
}
func (m Model) renderRulesLine() string {
label := infoLabelStyle.Render("Rules")
switch {
case m.rulesManaged:
return label + warnStyle.Render("read-only (NixOS managed)")
case !m.rulesWritable:
return label + warnStyle.Render("read-only")
default:
return label + daemonActiveStyle.Render("writable")
}
}
func (m Model) renderPendingRulesLine() string {
count := len(m.pendingRules)
if count == 0 {
return ""
}
return warnStyle.Render(m.notice)
}
func (m Model) renderActionSelect() string {
dev := m.selectedDev
color := statusColors[dev.Status]
innerW := m.actionListInnerWidth()
title := popupTitleStyle.Foreground(color).Width(innerW).Render(dev.Name)
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).Width(innerW).Render("↑↓ navigate enter confirm esc cancel")
parts := []string{title, m.actionList.View(), ""}
if m.rulesManaged {
nixosHint := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).Width(innerW).Render("[NixOS: perm rules printed on exit]")
parts = append(parts, nixosHint)
}
parts = append(parts, hint)
return popupStyle.Width(innerW).Render(strings.Join(parts, "\n"))
}
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)
}
func (m Model) defaultNotice() string {
if m.rulesManaged {
return "Rules managed by NixOS config: permanent actions will print NixOS rules on exit."
}
return ""
}
func (m Model) actionItemCount() int {
return 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() {
items := m.actionItemCount()
innerW := m.actionListInnerWidth()
// popup overhead: border(2) + padding_v(2) + title(1) + blank(1) + hint(1) = 7; +1 for NixOS footer
overhead := 7
if m.rulesManaged {
overhead = 8
}
available := m.height - overhead - 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)
noun := "rule"
if count > 1 {
noun = "rules"
}
label := infoLabelStyle.Render("Pending rules")
return label + warnStyle.Render(fmt.Sprintf("%d %s queued (printed on exit)", count, noun))
}
func (m Model) listHeight() int {
helpH := lipgloss.Height(strings.TrimRight(m.help.View(listKeys), "\n"))
return m.height - 1 - helpH - 1 // header - help - notice
headerH := lipgloss.Height(m.renderHeader())
helpH := m.help.Height()
return m.height - headerH - helpH
}
func (m Model) selectedDevID() int {
func (m Model) selectedDevice() (guard.Device, bool) {
if item := m.list.SelectedItem(); item != nil {
return item.(guard.Device).ID
return item.(guard.Device), true
}
return -1
return guard.Device{}, false
}
func (m Model) visibleDevices() []guard.Device {
items := m.list.VisibleItems()
devices := make([]guard.Device, len(items))
for i, item := range items {
devices[i] = item.(guard.Device)
}
return devices
}
func (m Model) visibleDeviceIDs() []int {
items := m.list.VisibleItems()
ids := make([]int, len(items))
for i, item := range items {
ids[i] = item.(guard.Device).ID
}
return ids
}
func tickCmd() tea.Cmd {
@@ -396,51 +419,78 @@ func fetchDaemonStatus() tea.Msg {
return daemonStatusMsg(guard.DaemonStatus())
}
func fetchDefaultPolicy() tea.Msg {
return defaultPolicyMsg(guard.DefaultPolicy())
}
func errorToast(err error) tea.Cmd {
msg := err.Error()
if err == guard.ErrReadOnly {
msg = "Rules file is not writable: permanent changes are not supported."
}
return notification.Show("Error", msg, notification.Error, notification.WithID("action-error"))
}
func doAction(id int, fn func(int, bool) error, permanent bool) tea.Cmd {
return func() tea.Msg {
return actionMsg{err: fn(id, permanent)}
}
}
func doBulkAction(ids []int, fn func(int, bool) error, permanent bool) tea.Cmd {
return func() tea.Msg {
for _, id := range ids {
if err := fn(id, permanent); err != nil {
return actionMsg{err: err}
}
}
return actionMsg{}
}
}
func queueNixOSRules(devices []guard.Device, status guard.Status) tea.Cmd {
cmds := make([]tea.Cmd, len(devices))
for i, d := range devices {
rule := guard.NixOSRule(d, status)
cmds[i] = func() tea.Msg { return nixRuleMsg{rule: rule} }
}
return tea.Batch(cmds...)
}
func queueCurrentStateRules(devices []guard.Device) tea.Cmd {
cmds := make([]tea.Cmd, len(devices))
for i, d := range devices {
rule := guard.NixOSRule(d, d.Status)
cmds[i] = func() tea.Msg { return nixRuleMsg{rule: rule} }
}
return tea.Batch(cmds...)
}
type actionBinding struct {
binding key.Binding
fn func(int, bool) error
perm bool
needsWritable bool
status guard.Status
}
var deviceActionBindings = []actionBinding{
{listKeys.Allow, guard.AllowDevice, false, false},
{listKeys.AllowPerm, guard.AllowDevice, true, true},
{listKeys.Block, guard.BlockDevice, false, false},
{listKeys.BlockPerm, guard.BlockDevice, true, true},
{listKeys.Reject, guard.RejectDevice, false, false},
{listKeys.RejectPerm, guard.RejectDevice, true, true},
{listKeys.Allow, guard.AllowDevice, false, guard.Allowed},
{listKeys.AllowPerm, guard.AllowDevice, true, guard.Allowed},
{listKeys.Block, guard.BlockDevice, false, guard.Blocked},
{listKeys.BlockPerm, guard.BlockDevice, true, guard.Blocked},
{listKeys.Reject, guard.RejectDevice, false, guard.Rejected},
{listKeys.RejectPerm, guard.RejectDevice, true, guard.Rejected},
}
func (m Model) deviceActionCmd(msg tea.KeyPressMsg, id int) tea.Cmd {
func (m Model) deviceActionCmd(msg tea.KeyPressMsg, dev guard.Device) tea.Cmd {
for _, b := range deviceActionBindings {
if (!b.needsWritable || !m.rulesManaged) && key.Matches(msg, b.binding) {
return doAction(id, b.fn, b.perm)
if !key.Matches(msg, b.binding) {
continue
}
if b.perm && m.rulesManaged {
return queueNixOSRules([]guard.Device{dev}, b.status)
}
return doAction(dev.ID, b.fn, b.perm)
}
return nil
}
func (m Model) updateMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
switch msg.Button {
case tea.MouseWheelUp:
if m.state == statePopup {
m.actionList.CursorUp()
} else {
m.list.CursorUp()
}
case tea.MouseWheelDown:
if m.state == statePopup {
m.actionList.CursorDown()
} else {
m.list.CursorDown()
}
}
return m, nil
}
-51
View File
@@ -1,51 +0,0 @@
package ui
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
var dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
// placeOverlay renders fg centered over bg, with bg stripped and rendered dim gray.
func placeOverlay(bg, fg string, width, height int) string {
fgLines := strings.Split(fg, "\n")
fgH := len(fgLines)
fgW := 0
for _, l := range fgLines {
if w := lipgloss.Width(l); w > fgW {
fgW = w
}
}
bgLines := strings.Split(bg, "\n")
x0 := (width - fgW) / 2
y0 := (height - fgH) / 2
result := make([]string, height)
for i := 0; i < height; i++ {
raw := ""
if i < len(bgLines) {
raw = ansi.Strip(bgLines[i])
}
if w := lipgloss.Width(raw); w < width {
raw += strings.Repeat(" ", width-w)
}
fgIdx := i - y0
if fgIdx < 0 || fgIdx >= fgH {
result[i] = dimStyle.Render(raw)
} else {
fgLine := fgLines[fgIdx]
fgLineW := lipgloss.Width(fgLine)
left := ansi.Truncate(raw, x0, "")
right := ansi.Cut(raw, x0+fgLineW, width)
result[i] = dimStyle.Render(left) + fgLine + dimStyle.Render(right)
}
}
return strings.Join(result, "\n")
}
+44
View File
@@ -0,0 +1,44 @@
package ui
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
)
type permissionModal struct{}
func newPermissionModal() tea.Model { return permissionModal{} }
func permissionModalStyle() modal.Styles {
s := modal.DefaultStyles()
s.Title = s.Title.Foreground(style.S.Error)
s.Border = s.Border.BorderForeground(style.S.Error)
return s
}
func (permissionModal) Init() tea.Cmd { return nil }
func (m permissionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tea.KeyPressMsg, tea.MouseClickMsg:
return m, tea.Quit
}
return m, nil
}
func (permissionModal) View() tea.View {
textStyle := lipgloss.NewStyle().Foreground(style.S.Text)
hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
parts := []string{
textStyle.Render("Can't reach the usbguard daemon"),
textStyle.Render("(permission denied)."),
textStyle.Render("Join the usbguard group, or use sudo."),
hintStyle.Render("press any key to quit"),
}
return tea.NewView(strings.Join(parts, "\n"))
}
+16 -18
View File
@@ -4,39 +4,37 @@ import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
var statusColors = map[guard.Status]color.Color{
guard.Allowed: ilovetui.S.Success,
guard.Blocked: ilovetui.S.Error,
guard.Rejected: ilovetui.S.Warning,
guard.Allowed: style.S.Success,
guard.Blocked: style.S.Error,
guard.Rejected: style.S.Warning,
}
var statusColorsSelected = map[guard.Status]color.Color{
guard.Allowed: ilovetui.S.Success,
guard.Blocked: ilovetui.S.Error,
guard.Rejected: ilovetui.S.Warning,
guard.Allowed: style.S.Success,
guard.Blocked: style.S.Error,
guard.Rejected: style.S.Warning,
}
var (
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ilovetui.S.Primary).
Foreground(style.S.Primary).
PaddingLeft(1)
daemonActiveStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Success)
daemonOtherStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Muted)
daemonActiveStyle = lipgloss.NewStyle().Foreground(style.S.Success)
daemonOtherStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
mutedStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Muted)
mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
popupStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ilovetui.S.Primary).
Padding(1, 3)
infoLabelStyle = lipgloss.NewStyle().
Foreground(style.S.Muted).
PaddingLeft(1).
Width(16)
popupTitleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1)
warnStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Warning)
warnStyle = lipgloss.NewStyle().Foreground(style.S.Warning)
)