├── .gitignore ├── demo └── demo.gif ├── cmd ├── doc.go ├── printer.go └── cli.go ├── ui ├── config │ ├── doc.go │ ├── config.go │ └── values.go ├── theme │ ├── doc.go │ ├── color.go │ └── config.go ├── app │ ├── views │ │ ├── doc.go │ │ ├── viewoperation.go │ │ ├── viewpages.go │ │ ├── audioprofilesview.go │ │ ├── networkview.go │ │ ├── statusbarview.go │ │ ├── viewauthorizer.go │ │ ├── views.go │ │ ├── adapterview.go │ │ ├── helpview.go │ │ ├── deviceview.go │ │ ├── menubarview.go │ │ ├── modalsview.go │ │ ├── playerview.go │ │ ├── progressview.go │ │ ├── filepickerview.go │ │ └── viewactions.go │ ├── suspend_windows.go │ ├── doc.go │ ├── suspend.go │ └── app.go └── keybindings │ └── doc.go ├── main.go ├── .github └── workflows │ └── goreleaser.yaml ├── LICENSE ├── .goreleaser.yml ├── revive.toml ├── README.md ├── go.mod └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluetuith-org/bluetuith/HEAD/demo/demo.gif -------------------------------------------------------------------------------- /cmd/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package cmd provides a terminal interface to launch the application. 3 | */ 4 | package cmd 5 | -------------------------------------------------------------------------------- /ui/config/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package config provides a configuration store and parser. 3 | */ 4 | package config 5 | -------------------------------------------------------------------------------- /ui/theme/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package theme provides basic customizable theming support. 3 | */ 4 | package theme 5 | -------------------------------------------------------------------------------- /ui/app/views/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package views provides individual composable views for the application. 3 | */ 4 | package views 5 | -------------------------------------------------------------------------------- /ui/keybindings/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package keybindings provides a keybinding configuration and parser. 3 | */ 4 | package keybindings 5 | -------------------------------------------------------------------------------- /ui/app/suspend_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package app 4 | 5 | import "github.com/gdamore/tcell/v2" 6 | 7 | func suspendApp(_ tcell.Screen) { 8 | } 9 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/darkhz/bluetuith/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Run(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ui/app/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package app provides the base view and renderer for the application. 3 | It provides an interface to control the main application, as well as 4 | organize different views. 5 | */ 6 | package app 7 | -------------------------------------------------------------------------------- /ui/app/suspend.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package app 4 | 5 | import ( 6 | "syscall" 7 | 8 | "github.com/gdamore/tcell/v2" 9 | ) 10 | 11 | func suspendApp(t tcell.Screen) { 12 | if err := t.Suspend(); err != nil { 13 | return 14 | } 15 | if err := syscall.Kill(syscall.Getpid(), syscall.SIGSTOP); err != nil { 16 | return 17 | } 18 | if err := t.Resume(); err != nil { 19 | return 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cmd/printer.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | ) 6 | 7 | // printWarn prints a warning to the screen. 8 | func printWarn(message string) { 9 | message = "[-] " + message 10 | 11 | color.New(color.FgYellow, color.Bold).Println(message) 12 | } 13 | 14 | // printError prints an error to the screen. 15 | func printError(err error) { 16 | message := "[!] " + err.Error() 17 | 18 | color.New(color.FgRed, color.Bold).Println(message) 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: goreleaser 3 | 4 | on: 5 | pull_request: 6 | push: 7 | tags: 8 | - "*" 9 | 10 | permissions: 11 | contents: write 12 | packages: write 13 | 14 | jobs: 15 | goreleaser: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: 1.25.0 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v6 28 | with: 29 | distribution: goreleaser 30 | version: "latest" 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 darkhz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /ui/app/views/viewoperation.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "sync" 4 | 5 | // viewOperation holds an operation manager instance. 6 | type viewOperation struct { 7 | cancel func() 8 | lock sync.Mutex 9 | 10 | root *Views 11 | } 12 | 13 | // newViewOperation returns a new operations manager. 14 | func newViewOperation(root *Views) *viewOperation { 15 | return &viewOperation{root: root} 16 | } 17 | 18 | // startOperation sets up the cancellation handler, 19 | // and starts the operation. 20 | func (v *viewOperation) startOperation(dofunc, cancel func()) { 21 | v.lock.Lock() 22 | defer v.lock.Unlock() 23 | 24 | if v.cancel != nil { 25 | v.root.status.InfoMessage("Operation still in progress", false) 26 | return 27 | } 28 | 29 | v.cancel = cancel 30 | 31 | go func() { 32 | dofunc() 33 | v.cancelOperation(false) 34 | }() 35 | } 36 | 37 | // cancelOperation cancels the currently running operation. 38 | func (v *viewOperation) cancelOperation(cancelfunc bool) { 39 | var cancel func() 40 | 41 | v.lock.Lock() 42 | defer v.lock.Unlock() 43 | 44 | if v.cancel == nil { 45 | return 46 | } 47 | 48 | cancel = v.cancel 49 | v.cancel = nil 50 | 51 | if cancelfunc { 52 | go cancel() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: bluetuith 2 | version: 2 3 | 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | - GO111MODULE=on 8 | - GOPROXY=https://proxy.golang.org 9 | 10 | flags: 11 | - -trimpath 12 | 13 | ldflags: 14 | - -s -w -X github.com/darkhz/bluetuith/cmd.Version={{.Version}}@{{.ShortCommit}} 15 | 16 | goos: 17 | - linux 18 | - windows 19 | - darwin 20 | 21 | goarch: 22 | - arm 23 | - 386 24 | - arm64 25 | - amd64 26 | 27 | goarm: 28 | - 5 29 | - 6 30 | - 7 31 | 32 | ignore: 33 | - goos: windows 34 | goarch: arm 35 | - goos: darwin 36 | goarch: arm 37 | - goos: darwin 38 | goarch: 386 39 | 40 | archives: 41 | - id: foo 42 | name_template: >- 43 | {{ .ProjectName }}_{{ .Version }}_ 44 | {{- title .Os }}_ 45 | {{- if eq .Arch "amd64" }}x86_64 46 | {{- else if eq .Arch "386" }}i386 47 | {{- else if eq .Arch "arm" }}{{ .Arch }}v{{ .Arm }} 48 | {{- else }}{{ .Arch }}{{ end }} 49 | 50 | files: 51 | - LICENSE 52 | 53 | checksum: 54 | name_template: 'checksums.txt' 55 | 56 | changelog: 57 | disable: true 58 | -------------------------------------------------------------------------------- /revive.toml: -------------------------------------------------------------------------------- 1 | ignoreGeneratedHeader = false 2 | severity = "warning" 3 | confidence = 0.8 4 | errorCode = 0 5 | warningCode = 0 6 | 7 | [rule.blank-imports] 8 | [rule.context-as-argument] 9 | [rule.context-keys-type] 10 | [rule.dot-imports] 11 | [rule.error-return] 12 | [rule.error-strings] 13 | [rule.error-naming] 14 | [rule.exported] 15 | [rule.increment-decrement] 16 | [rule.var-naming] 17 | [rule.var-declaration] 18 | [rule.range] 19 | [rule.receiver-naming] 20 | [rule.time-naming] 21 | [rule.unexported-return] 22 | [rule.indent-error-flow] 23 | [rule.errorf] 24 | [rule.empty-block] 25 | [rule.superfluous-else] 26 | [rule.unused-parameter] 27 | [rule.unreachable-code] 28 | [rule.redefines-builtin-id] 29 | [rule.confusing-naming] 30 | [rule.confusing-results] 31 | [rule.defer] 32 | [rule.datarace] 33 | [rule.deep-exit] 34 | [rule.early-return] 35 | [rule.enforce-map-style] 36 | [rule.enforce-slice-style] 37 | [rule.identical-branches] 38 | [rule.if-return] 39 | [rule.unnecessary-stmt] 40 | [rule.unconditional-recursion] 41 | [rule.unchecked-type-assertion] 42 | [rule.optimize-operands-order] 43 | [rule.import-shadowing] 44 | [rule.import-alias-naming] 45 | [rule.atomic] 46 | 47 | [rule.package-comments] 48 | Disabled = true 49 | -------------------------------------------------------------------------------- /ui/app/views/viewpages.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "github.com/darkhz/bluetuith/ui/keybindings" 5 | "github.com/darkhz/tview" 6 | "go.uber.org/atomic" 7 | ) 8 | 9 | // viewPages holds a pages manager for multiple views. 10 | type viewPages struct { 11 | page atomic.String 12 | pageContext atomic.String 13 | 14 | *tview.Pages 15 | } 16 | 17 | // newViewPages returns a new viewPages. 18 | func newViewPages() *viewPages { 19 | p := &viewPages{ 20 | Pages: tview.NewPages(), 21 | } 22 | 23 | p.page.Store(devicePage.String()) 24 | p.pageContext.Store(string(keybindings.ContextApp)) 25 | 26 | return p 27 | } 28 | 29 | // currentPage returns the currently focused page. 30 | func (v *viewPages) currentPage(set ...string) string { 31 | var pg string 32 | 33 | if set != nil { 34 | pg = set[0] 35 | v.page.Store(pg) 36 | } else { 37 | pg = v.page.Load() 38 | } 39 | 40 | return pg 41 | } 42 | 43 | // currentContext gets or sets the current keybinding/page context of the currently focused page. 44 | func (v *viewPages) currentContext(set ...keybindings.Context) keybindings.Context { 45 | var c keybindings.Context 46 | 47 | if set != nil { 48 | c = set[0] 49 | v.pageContext.Store(string(c)) 50 | } else { 51 | c = keybindings.Context(v.pageContext.Load()) 52 | } 53 | 54 | return c 55 | } 56 | -------------------------------------------------------------------------------- /ui/theme/color.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gdamore/tcell/v2" 7 | ) 8 | 9 | // ColorWrap wraps the text content with the modifier element's color. 10 | func ColorWrap(elementName Context, elementContent string, attributes ...string) string { 11 | attr := "::b" 12 | if attributes != nil { 13 | attr = attributes[0] 14 | } 15 | 16 | return fmt.Sprintf("[%s%s]%s[-:-:-]", ThemeConfig[elementName], attr, elementContent) 17 | } 18 | 19 | // ColorName returns the name of the provided color. 20 | func ColorName(color tcell.Color) string { 21 | for n, h := range tcell.ColorNames { 22 | if color == h { 23 | return n 24 | } 25 | } 26 | 27 | return "" 28 | } 29 | 30 | // BackgroundColor checks whether the given color is a light 31 | // or dark color, and returns the appropriate color that is 32 | // visible on top of the given color. 33 | func BackgroundColor(themeContext Context) tcell.Color { 34 | if isLightColor(GetColor(themeContext)) { 35 | return tcell.ColorBlack 36 | } 37 | 38 | return tcell.ColorWhite 39 | } 40 | 41 | // GetColor returns the color of the modifier element. 42 | func GetColor(themeContext Context) tcell.Color { 43 | color := ThemeConfig[themeContext] 44 | if color == "black" { 45 | return tcell.Color16 46 | } 47 | 48 | return tcell.GetColor(color) 49 | } 50 | 51 | // isLightColor checks if the given color is a light color. 52 | // Adapted from: 53 | // https://github.com/bgrins/TinyColor/blob/master/tinycolor.js#L68 54 | func isLightColor(color tcell.Color) bool { 55 | r, g, b := color.RGB() 56 | brightness := (r*299 + g*587 + b*114) / 1000 57 | 58 | return brightness > 130 59 | } 60 | 61 | // isValidElementColor returns whether the modifier-value pair is valid. 62 | func isValidElementColor(color string) bool { 63 | if color == "transparent" || 64 | tcell.GetColor(color) != tcell.ColorDefault { 65 | return true 66 | } 67 | 68 | return false 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/darkhz/bluetuith)](https://goreportcard.com/report/github.com/darkhz/bluetuith) [![Packaging status](https://repology.org/badge/tiny-repos/bluetuith.svg)](https://repology.org/project/bluetuith/versions) 2 | 3 | ![demo](demo/demo.gif) 4 | 5 | # bluetuith 6 | bluetuith is a TUI-based bluetooth connection manager, which can interact with bluetooth adapters and devices. 7 | It aims to be a replacement to most bluetooth managers, like blueman. 8 | 9 | This project is currently in the alpha stage. 10 | 11 | ## Funding 12 | 13 | This project is funded through [NGI Zero Core](https://nlnet.nl/core), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/bluetuith). 14 | 15 | [NLnet foundation logo](https://nlnet.nl) 16 | [NGI Zero Logo](https://nlnet.nl/core) 17 | 18 | ## Features 19 | ### UI 20 | - Adapter selector and status indicators 21 | - Mouse support 22 | - File transfer progress view 23 | - Authentication view for pairing and file transfers 24 | - Device interaction menus and options 25 | - Media player 26 | 27 | ### Platform-specific 28 | The available features per-platform are [here](https://github.com/bluetuith-org/bluetooth-classic?tab=readme-ov-file#feature-matrix). 29 | Please view the documentation for a list of operating requirements per platform. 30 | 31 | ## Documentation 32 | The documentation is now hosted [here](https://bluetuith-org.github.io/bluetuith/). 33 | 34 | The wiki is out-of-date. 35 | 36 | 37 | [![Packaging status](https://repology.org/badge/vertical-allrepos/bluetuith.svg)](https://repology.org/project/bluetuith/versions) 38 | 39 | 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/darkhz/bluetuith 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/bluetuith-org/bluetooth-classic v0.0.5 7 | github.com/darkhz/tview v0.0.0-20240308094543-6078a888ff79 8 | github.com/fatih/color v1.18.0 9 | github.com/gdamore/tcell/v2 v2.13.1 10 | github.com/google/uuid v1.6.0 11 | github.com/knadh/koanf/parsers/hjson v1.0.0 12 | github.com/knadh/koanf/providers/cliflagv2 v1.0.1 13 | github.com/knadh/koanf/providers/file v1.2.0 14 | github.com/knadh/koanf/v2 v2.3.0 15 | github.com/mattn/go-runewidth v0.0.19 16 | github.com/puzpuzpuz/xsync/v3 v3.5.1 17 | github.com/schollz/progressbar/v3 v3.18.0 18 | github.com/urfave/cli/v2 v2.27.7 19 | go.uber.org/atomic v1.11.0 20 | golang.org/x/text v0.31.0 21 | ) 22 | 23 | require ( 24 | github.com/Southclaws/fault v0.8.2 // indirect 25 | github.com/Wifx/gonetworkmanager v0.5.0 // indirect 26 | github.com/clipperhouse/stringish v0.1.1 // indirect 27 | github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 28 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 29 | github.com/cskr/pubsub/v2 v2.0.2 // indirect 30 | github.com/fsnotify/fsnotify v1.9.0 // indirect 31 | github.com/gdamore/encoding v1.0.1 // indirect 32 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 33 | github.com/godbus/dbus/v5 v5.2.0 // indirect 34 | github.com/hjson/hjson-go/v4 v4.5.0 // indirect 35 | github.com/knadh/koanf/maps v0.1.2 // indirect 36 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 37 | github.com/mafik/pulseaudio v0.0.0-20240327130323-384e01075e6e // indirect 38 | github.com/mattn/go-colorable v0.1.14 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 41 | github.com/mitchellh/copystructure v1.2.0 // indirect 42 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 43 | github.com/rivo/uniseg v0.4.7 // indirect 44 | github.com/rs/xid v1.6.0 // indirect 45 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 46 | github.com/ugorji/go/codec v1.3.1 // indirect 47 | github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect 48 | golang.org/x/sys v0.38.0 // indirect 49 | golang.org/x/term v0.37.0 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /ui/theme/config.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Context describes the type of context to apply the color into. 8 | type Context string 9 | 10 | // The different context types for themes. 11 | const ( 12 | ThemeText Context = "Text" 13 | ThemeBorder Context = "Border" 14 | ThemeBackground Context = "Background" 15 | ThemeStatusInfo Context = "StatusInfo" 16 | ThemeStatusError Context = "StatusError" 17 | ThemeAdapter Context = "Adapter" 18 | ThemeAdapterPowered Context = "AdapterPowered" 19 | ThemeAdapterNotPowered Context = "AdapterNotPowered" 20 | ThemeAdapterDiscoverable Context = "AdapterDiscoverable" 21 | ThemeAdapterScanning Context = "AdapterScanning" 22 | ThemeAdapterPairable Context = "AdapterPairable" 23 | ThemeDevice Context = "Device" 24 | ThemeDeviceType Context = "DeviceType" 25 | ThemeDeviceAlias Context = "DeviceAlias" 26 | ThemeDeviceConnected Context = "DeviceConnected" 27 | ThemeDeviceDiscovered Context = "DeviceDiscovered" 28 | ThemeDeviceProperty Context = "DeviceProperty" 29 | ThemeDevicePropertyConnected Context = "DevicePropertyConnected" 30 | ThemeDevicePropertyDiscovered Context = "DevicePropertyDiscovered" 31 | ThemeMenu Context = "Menu" 32 | ThemeMenuBar Context = "MenuBar" 33 | ThemeMenuItem Context = "MenuItem" 34 | ThemeProgressBar Context = "ProgressBar" 35 | ThemeProgressText Context = "ProgressText" 36 | ) 37 | 38 | // ThemeConfig stores a list of color for the modifier elements. 39 | var ThemeConfig = map[Context]string{ 40 | ThemeText: "white", 41 | ThemeBorder: "white", 42 | ThemeBackground: "default", 43 | ThemeStatusInfo: "white", 44 | ThemeStatusError: "red", 45 | 46 | ThemeAdapter: "white", 47 | ThemeAdapterPowered: "green", 48 | ThemeAdapterNotPowered: "red", 49 | ThemeAdapterDiscoverable: "aqua", 50 | ThemeAdapterScanning: "yellow", 51 | ThemeAdapterPairable: "mediumorchid", 52 | 53 | ThemeDevice: "white", 54 | ThemeDeviceType: "white", 55 | ThemeDeviceAlias: "white", 56 | ThemeDeviceConnected: "white", 57 | ThemeDeviceDiscovered: "white", 58 | ThemeDeviceProperty: "grey", 59 | ThemeDevicePropertyConnected: "green", 60 | ThemeDevicePropertyDiscovered: "orange", 61 | 62 | ThemeMenu: "white", 63 | ThemeMenuBar: "default", 64 | ThemeMenuItem: "white", 65 | 66 | ThemeProgressBar: "white", 67 | ThemeProgressText: "white", 68 | } 69 | 70 | // ParseThemeConfig parses the theme configuration. 71 | func ParseThemeConfig(themeConfig map[string]string) error { 72 | for context, color := range themeConfig { 73 | if !isValidElementColor(color) { 74 | return fmt.Errorf("theme configuration is incorrect for %s (%s)", context, color) 75 | } 76 | 77 | switch color { 78 | case "black": 79 | color = "#000000" 80 | 81 | case "transparent": 82 | color = "default" 83 | } 84 | 85 | ThemeConfig[Context(context)] = color 86 | } 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /ui/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/bluetuith-org/bluetooth-classic/api/appfeatures" 7 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 8 | "github.com/darkhz/tview" 9 | "github.com/gdamore/tcell/v2" 10 | 11 | "github.com/darkhz/bluetuith/ui/app/views" 12 | "github.com/darkhz/bluetuith/ui/config" 13 | ) 14 | 15 | // Application holds an application with its views. 16 | type Application struct { 17 | view *views.Views 18 | } 19 | 20 | // NewApplication returns a new application. 21 | func NewApplication() *Application { 22 | return &Application{ 23 | view: views.NewViews(), 24 | } 25 | } 26 | 27 | // Start starts the application. 28 | func (a *Application) Start(session bluetooth.Session, featureSet *appfeatures.FeatureSet, cfg *config.Config) error { 29 | binder := &appBinder{ 30 | session: session, 31 | draws: make(chan struct{}, 1), 32 | featureSet: featureSet, 33 | Application: tview.NewApplication(), 34 | } 35 | 36 | appview, err := a.view.Initialize(binder, cfg) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | binder.SetInputCapture(appview.InputCapture) 42 | binder.SetMouseCapture(appview.MouseFunc) 43 | binder.SetBeforeDrawFunc(appview.BeforeDrawFunc) 44 | 45 | go binder.monitorQueuedDraws() 46 | 47 | return binder.SetRoot(appview.Layout, true).SetFocus(appview.InitialFocus).EnableMouse(true).Run() 48 | } 49 | 50 | // Authorizer returns the session's authorizer. 51 | func (a *Application) Authorizer() bluetooth.SessionAuthorizer { 52 | return a.view.Authorizer() 53 | } 54 | 55 | // appBinder holds the bluetooth session and the application. 56 | type appBinder struct { 57 | session bluetooth.Session 58 | featureSet *appfeatures.FeatureSet 59 | draws chan struct{} 60 | shouldSuspend bool 61 | 62 | *tview.Application 63 | } 64 | 65 | // Session returns the current session. 66 | func (a *appBinder) Session() bluetooth.Session { 67 | return a.session 68 | } 69 | 70 | // Features returns the current features of the session. 71 | func (a *appBinder) Features() *appfeatures.FeatureSet { 72 | return a.featureSet 73 | } 74 | 75 | // InstantDraw instantly draws to the screen. 76 | func (a *appBinder) InstantDraw(drawFunc func()) { 77 | a.QueueUpdateDraw(drawFunc) 78 | } 79 | 80 | // QueueDraw only queues the drawing. 81 | func (a *appBinder) QueueDraw(drawFunc func()) { 82 | a.QueueUpdate(drawFunc) 83 | 84 | select { 85 | case a.draws <- struct{}{}: 86 | default: 87 | } 88 | } 89 | 90 | // Refresh refreshes the screen. 91 | func (a *appBinder) Refresh() { 92 | a.Draw() 93 | } 94 | 95 | // GetFocused gets the currently focused primitive. 96 | func (a *appBinder) GetFocused() tview.Primitive { 97 | return a.GetFocus() 98 | } 99 | 100 | // FocusPrimitive sets the focus on the provided primitive. 101 | func (a *appBinder) FocusPrimitive(primitive tview.Primitive) { 102 | a.SetFocus(primitive) 103 | } 104 | 105 | // StartSuspend starts the application's suspend. 106 | // [appbinder.Suspend] is called within the application's drawing handler 107 | // once this function is called. 108 | func (a *appBinder) StartSuspend() { 109 | a.shouldSuspend = true 110 | } 111 | 112 | // Suspend suspends the application. 113 | func (a *appBinder) Suspend(t tcell.Screen) { 114 | if !a.shouldSuspend { 115 | return 116 | } 117 | 118 | a.shouldSuspend = false 119 | 120 | suspendApp(t) 121 | } 122 | 123 | // Close stops the application. 124 | func (a *appBinder) Close() { 125 | a.Stop() 126 | } 127 | 128 | // monitorQueuedDraws monitors for any queued primitive draws and refrehses the screen. 129 | func (a *appBinder) monitorQueuedDraws() { 130 | t := time.NewTicker(1 * time.Second) 131 | defer t.Stop() 132 | 133 | var queued bool 134 | 135 | for { 136 | select { 137 | case <-t.C: 138 | if queued { 139 | go a.Refresh() 140 | queued = false 141 | t.Reset(1 * time.Second) 142 | } 143 | 144 | case <-a.draws: 145 | queued = true 146 | t.Reset(50 * time.Millisecond) 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /ui/app/views/audioprofilesview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "errors" 5 | "sort" 6 | 7 | "go.uber.org/atomic" 8 | 9 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 10 | "github.com/darkhz/bluetuith/ui/theme" 11 | "github.com/darkhz/tview" 12 | "github.com/gdamore/tcell/v2" 13 | ) 14 | 15 | // audioProfilesView holds the audio profiles viewer. 16 | type audioProfilesView struct { 17 | isSupported atomic.Bool 18 | 19 | *Views 20 | } 21 | 22 | // audioDevice describes a single audio device. 23 | type audioDevice struct { 24 | address bluetooth.MacAddress 25 | profile bluetooth.AudioProfile 26 | } 27 | 28 | // Initialize initializes the audio profiles viewer. 29 | func (a *audioProfilesView) Initialize() error { 30 | a.isSupported.Store(true) 31 | 32 | return nil 33 | } 34 | 35 | // SetRootView sets the root view for the audio profiles viewer. 36 | func (a *audioProfilesView) SetRootView(v *Views) { 37 | a.Views = v 38 | } 39 | 40 | // audioProfiles shows a popup to select the audio profile. 41 | func (a *audioProfilesView) audioProfiles() { 42 | if !a.isSupported.Load() { 43 | a.status.ErrorMessage(errors.New("this operation is not supported")) 44 | return 45 | } 46 | 47 | device := a.device.getSelection(false) 48 | if device.Address.IsNil() { 49 | return 50 | } 51 | 52 | profiles, err := a.app.Session().MediaPlayer(device.Address).AudioProfiles() 53 | if err != nil { 54 | a.status.ErrorMessage(err) 55 | return 56 | } 57 | sort.Slice(profiles, func(i, _ int) bool { 58 | return profiles[i].Name == "off" 59 | }) 60 | 61 | a.menu.drawContextMenu( 62 | menuDeviceName.String(), 63 | func(profileMenu *tview.Table) { 64 | row, _ := profileMenu.GetSelection() 65 | 66 | a.setProfile(profileMenu, row, 0) 67 | 68 | }, nil, 69 | func(profileMenu *tview.Table) (int, int) { 70 | var width, index int 71 | 72 | profileMenu.SetSelectorWrap(true) 73 | 74 | for row, profile := range profiles { 75 | if profile.Active { 76 | index = row 77 | } 78 | 79 | if len(profile.Description) > width { 80 | width = len(profile.Description) 81 | } 82 | 83 | profileMenu.SetCellSimple(row, 0, "") 84 | 85 | profileMenu.SetCell(row, 1, tview.NewTableCell(profile.Description). 86 | SetExpansion(1). 87 | SetReference(audioDevice{device.Address, profile}). 88 | SetAlign(tview.AlignLeft). 89 | SetOnClickedFunc(a.setProfile). 90 | SetTextColor(theme.GetColor(theme.ThemeText)). 91 | SetSelectedStyle(tcell.Style{}. 92 | Foreground(theme.GetColor(theme.ThemeText)). 93 | Background(theme.BackgroundColor(theme.ThemeText)), 94 | ), 95 | ) 96 | 97 | } 98 | 99 | a.markActiveProfile(profileMenu, index) 100 | 101 | return width - 16, index 102 | }, 103 | ) 104 | } 105 | 106 | // setProfile sets the selected audio profile. 107 | func (a *audioProfilesView) setProfile(profileMenu *tview.Table, row, _ int) { 108 | cell := profileMenu.GetCell(row, 1) 109 | if cell == nil { 110 | return 111 | } 112 | 113 | device, ok := cell.GetReference().(audioDevice) 114 | if !ok { 115 | return 116 | } 117 | 118 | if err := a.app.Session().MediaPlayer(device.address).SetAudioProfile(device.profile); err != nil { 119 | a.status.ErrorMessage(err) 120 | return 121 | } 122 | 123 | a.markActiveProfile(profileMenu, row) 124 | } 125 | 126 | // markActiveProfile marks the active profile in the profiles list. 127 | func (a *audioProfilesView) markActiveProfile(profileMenu *tview.Table, index ...int) { 128 | for i := range profileMenu.GetRowCount() { 129 | var activeIndicator string 130 | 131 | if i == index[0] { 132 | activeIndicator = string('\u2022') 133 | } else { 134 | activeIndicator = "" 135 | } 136 | 137 | profileMenu.SetCell(i, 0, tview.NewTableCell(activeIndicator). 138 | SetSelectable(false). 139 | SetTextColor(theme.GetColor(theme.ThemeText)). 140 | SetSelectedStyle(tcell.Style{}. 141 | Foreground(theme.GetColor(theme.ThemeText)). 142 | Background(theme.BackgroundColor(theme.ThemeText)), 143 | ), 144 | ) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /ui/app/views/networkview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "go.uber.org/atomic" 9 | 10 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 11 | "github.com/darkhz/bluetuith/ui/theme" 12 | "github.com/darkhz/tview" 13 | "github.com/gdamore/tcell/v2" 14 | ) 15 | 16 | // networkView holds the network selector view. 17 | type networkView struct { 18 | isSupported atomic.Bool 19 | 20 | *Views 21 | } 22 | 23 | // Initialize initializes the network selector view. 24 | func (n *networkView) Initialize() error { 25 | n.isSupported.Store(true) 26 | 27 | return nil 28 | } 29 | 30 | // SetRootView sets the root view for the network selector view. 31 | func (n *networkView) SetRootView(v *Views) { 32 | n.Views = v 33 | } 34 | 35 | // networkSelect shows a popup to select the network type. 36 | func (n *networkView) networkSelect() { 37 | if !n.isSupported.Load() { 38 | n.status.ErrorMessage(errors.New("this operation is not supported")) 39 | return 40 | } 41 | 42 | type nwTypeDesc struct { 43 | connType bluetooth.NetworkType 44 | desc string 45 | } 46 | 47 | var connTypes []nwTypeDesc 48 | 49 | device := n.device.getSelection(false) 50 | if device.Address.IsNil() { 51 | return 52 | } 53 | 54 | if device.HaveService(bluetooth.PanuServiceClass) { 55 | connTypes = append(connTypes, nwTypeDesc{ 56 | bluetooth.NetworkPanu, 57 | "Personal Area Network", 58 | }) 59 | } 60 | if device.HaveService(bluetooth.DialupNetServiceClass) { 61 | connTypes = append(connTypes, nwTypeDesc{ 62 | bluetooth.NetworkDun, 63 | "Dialup Network", 64 | }) 65 | } 66 | 67 | if connTypes == nil { 68 | n.status.InfoMessage("No network options exist for "+device.Name, false) 69 | return 70 | } 71 | 72 | n.menu.drawContextMenu( 73 | menuDeviceName.String(), 74 | func(networkMenu *tview.Table) { 75 | row, _ := networkMenu.GetSelection() 76 | 77 | cell := networkMenu.GetCell(row, 0) 78 | if cell == nil { 79 | return 80 | } 81 | 82 | connType, ok := cell.GetReference().(bluetooth.NetworkType) 83 | if !ok { 84 | return 85 | } 86 | 87 | go n.networkConnect(device, connType) 88 | 89 | }, nil, 90 | func(networkMenu *tview.Table) (int, int) { 91 | var width int 92 | 93 | for row, nw := range connTypes { 94 | ctype := nw.connType 95 | description := nw.desc 96 | 97 | if len(description) > width { 98 | width = len(description) 99 | } 100 | 101 | networkMenu.SetCell(row, 0, tview.NewTableCell(description). 102 | SetExpansion(1). 103 | SetReference(ctype). 104 | SetAlign(tview.AlignLeft). 105 | SetTextColor(theme.GetColor(theme.ThemeText)). 106 | SetSelectedStyle(tcell.Style{}. 107 | Foreground(theme.GetColor(theme.ThemeText)). 108 | Background(theme.BackgroundColor(theme.ThemeText)), 109 | ), 110 | ) 111 | networkMenu.SetCell(row, 1, tview.NewTableCell("("+strings.ToUpper(ctype.String())+")"). 112 | SetAlign(tview.AlignRight). 113 | SetTextColor(theme.GetColor(theme.ThemeText)). 114 | SetSelectedStyle(tcell.Style{}. 115 | Foreground(theme.GetColor(theme.ThemeText)). 116 | Background(theme.BackgroundColor(theme.ThemeText)), 117 | ), 118 | ) 119 | } 120 | 121 | return width, 0 122 | }, 123 | ) 124 | } 125 | 126 | // networkConnect connects to the network with the selected network type. 127 | func (n *networkView) networkConnect(device bluetooth.DeviceData, connType bluetooth.NetworkType) { 128 | info := fmt.Sprintf("%s (%s)", 129 | device.Name, strings.ToUpper(connType.String()), 130 | ) 131 | 132 | n.op.startOperation( 133 | func() { 134 | n.status.InfoMessage("Connecting to "+info, true) 135 | err := n.app.Session().Network(device.Address).Connect(device.Name, connType) 136 | if err != nil { 137 | n.status.ErrorMessage(err) 138 | return 139 | } 140 | n.status.InfoMessage("Connected to "+info, false) 141 | }, 142 | func() { 143 | err := n.app.Session().Network(device.Address).Disconnect() 144 | if err != nil { 145 | n.status.ErrorMessage(err) 146 | return 147 | } 148 | n.status.InfoMessage("Cancelled connection to "+info, false) 149 | }, 150 | ) 151 | } 152 | -------------------------------------------------------------------------------- /ui/app/views/statusbarview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/darkhz/bluetuith/ui/keybindings" 9 | "github.com/darkhz/bluetuith/ui/theme" 10 | "github.com/darkhz/tview" 11 | "github.com/gdamore/tcell/v2" 12 | ) 13 | 14 | const ( 15 | statusInputPage viewName = "input" 16 | statusMessagesPage viewName = "messages" 17 | ) 18 | 19 | type statusBarView struct { 20 | // MessageBox is an area to display messages. 21 | MessageBox *tview.TextView 22 | 23 | // Help is an area to display help keybindings. 24 | Help *tview.TextView 25 | 26 | // InputField is an area to interact with messages. 27 | InputField *tview.InputField 28 | 29 | sctx context.Context 30 | scancel context.CancelFunc 31 | msgchan chan message 32 | 33 | *Views 34 | 35 | *tview.Pages 36 | } 37 | 38 | type message struct { 39 | text string 40 | persist bool 41 | } 42 | 43 | func (s *statusBarView) Initialize() error { 44 | s.Pages = tview.NewPages() 45 | s.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 46 | 47 | s.InputField = tview.NewInputField() 48 | s.InputField.SetLabelColor(theme.GetColor(theme.ThemeText)) 49 | s.InputField.SetFieldTextColor(theme.GetColor(theme.ThemeText)) 50 | s.InputField.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 51 | s.InputField.SetFieldBackgroundColor(theme.GetColor(theme.ThemeBackground)) 52 | 53 | s.MessageBox = tview.NewTextView() 54 | s.MessageBox.SetDynamicColors(true) 55 | s.MessageBox.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 56 | 57 | s.Help = tview.NewTextView() 58 | s.Help.SetDynamicColors(true) 59 | s.Help.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 60 | 61 | s.AddPage(statusInputPage.String(), s.InputField, true, true) 62 | s.AddPage(statusMessagesPage.String(), s.MessageBox, true, true) 63 | s.SwitchToPage(statusMessagesPage.String()) 64 | 65 | s.msgchan = make(chan message, 10) 66 | s.sctx, s.scancel = context.WithCancel(context.Background()) 67 | 68 | go s.startStatus() 69 | 70 | flex := tview.NewFlex(). 71 | SetDirection(tview.FlexRow). 72 | AddItem(s.Pages, 1, 0, false) 73 | 74 | s.layout.AddItem(flex, flex.GetItemCount(), 0, false) 75 | 76 | return nil 77 | } 78 | 79 | func (s *statusBarView) SetRootView(root *Views) { 80 | s.Views = root 81 | } 82 | 83 | func (s *statusBarView) Release() { 84 | s.scancel() 85 | } 86 | 87 | // SetInput sets the inputfield label and returns the input text. 88 | func (s *statusBarView) SetInput(label string, multichar ...struct{}) string { 89 | return s.waitForInput(context.Background(), label, multichar...) 90 | } 91 | 92 | func (s *statusBarView) waitForInput(ctx context.Context, label string, multichar ...struct{}) string { 93 | input := make(chan string) 94 | 95 | go func() { 96 | exited := make(chan struct{}, 1) 97 | exit := func() { 98 | s.SwitchToPage(statusMessagesPage.String()) 99 | 100 | _, item := s.pages.GetFrontPage() 101 | s.app.FocusPrimitive(item) 102 | 103 | exited <- struct{}{} 104 | } 105 | 106 | s.app.QueueDraw(func() { 107 | s.InputField.SetText("") 108 | s.InputField.SetLabel("[::b]" + label + " ") 109 | 110 | if multichar != nil { 111 | s.InputField.SetAcceptanceFunc(nil) 112 | s.InputField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 113 | switch s.kb.Key(event) { 114 | case keybindings.KeySelect: 115 | input <- s.InputField.GetText() 116 | 117 | exit() 118 | 119 | case keybindings.KeyClose: 120 | input <- "" 121 | 122 | exit() 123 | } 124 | 125 | return event 126 | }) 127 | } else { 128 | s.InputField.SetAcceptanceFunc(tview.InputFieldMaxLength(1)) 129 | s.InputField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 130 | input <- string(event.Rune()) 131 | exit() 132 | 133 | return event 134 | }) 135 | } 136 | 137 | s.SwitchToPage(statusInputPage.String()) 138 | s.app.FocusPrimitive(s.InputField) 139 | }) 140 | 141 | select { 142 | case <-ctx.Done(): 143 | s.app.QueueDraw(func() { 144 | s.InputField.SetText("") 145 | exit() 146 | }) 147 | 148 | case <-exited: 149 | } 150 | }() 151 | 152 | return <-input 153 | } 154 | 155 | // InfoMessage sends an info message to the status bar. 156 | func (s *statusBarView) InfoMessage(text string, persist bool) { 157 | if s.msgchan == nil { 158 | return 159 | } 160 | 161 | select { 162 | case s.msgchan <- message{theme.ColorWrap(theme.ThemeStatusInfo, text), persist}: 163 | return 164 | 165 | default: 166 | } 167 | } 168 | 169 | // ErrorMessage sends an error message to the status bar. 170 | func (s *statusBarView) ErrorMessage(err error) { 171 | if s.msgchan == nil { 172 | return 173 | } 174 | 175 | if errors.Is(err, context.Canceled) { 176 | return 177 | } 178 | 179 | select { 180 | case s.msgchan <- message{theme.ColorWrap(theme.ThemeStatusError, "Error: "+err.Error()), false}: 181 | return 182 | 183 | default: 184 | } 185 | } 186 | 187 | // startStatus starts the message event loop 188 | func (s *statusBarView) startStatus() { 189 | var text string 190 | var cleared bool 191 | 192 | t := time.NewTicker(2 * time.Second) 193 | defer t.Stop() 194 | 195 | for { 196 | select { 197 | case <-s.sctx.Done(): 198 | return 199 | 200 | case msg, ok := <-s.msgchan: 201 | if !ok { 202 | return 203 | } 204 | 205 | t.Reset(2 * time.Second) 206 | 207 | cleared = false 208 | 209 | if msg.persist { 210 | text = msg.text 211 | } 212 | 213 | if !msg.persist && text != "" { 214 | text = "" 215 | } 216 | 217 | s.app.QueueDraw(func() { 218 | s.MessageBox.SetText(msg.text) 219 | }) 220 | 221 | case <-t.C: 222 | if cleared { 223 | continue 224 | } 225 | 226 | cleared = true 227 | 228 | s.app.QueueDraw(func() { 229 | s.MessageBox.SetText(text) 230 | }) 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /ui/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 13 | "github.com/knadh/koanf/parsers/hjson" 14 | "github.com/knadh/koanf/providers/cliflagv2" 15 | "github.com/knadh/koanf/providers/file" 16 | "github.com/knadh/koanf/v2" 17 | "github.com/urfave/cli/v2" 18 | ) 19 | 20 | const ( 21 | configFile = "bluetuith.conf" 22 | oldConfigFile = "config" 23 | ) 24 | 25 | // Config describes the configuration for the app. 26 | type Config struct { 27 | path string 28 | 29 | Values Values 30 | } 31 | 32 | // NewConfig returns a new configuration. 33 | func NewConfig() *Config { 34 | return &Config{} 35 | } 36 | 37 | // Load loads the configuration from the configuration file and the command-line flags. 38 | func (c *Config) Load(k *koanf.Koanf, cliCtx *cli.Context) error { 39 | if err := c.createConfigDir(); err != nil { 40 | return err 41 | } 42 | 43 | cfgfile, err := c.FilePath(configFile) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if err := k.Load(file.Provider(cfgfile), hjson.Parser()); err != nil { 49 | return err 50 | } 51 | 52 | if err := k.Load(cliflagv2.Provider(cliCtx, "."), nil); err != nil { 53 | return err 54 | } 55 | 56 | return k.UnmarshalWithConf("", &c.Values, koanf.UnmarshalConf{Tag: "koanf"}) 57 | } 58 | 59 | // ValidateValues validates the configuration values. 60 | func (c *Config) ValidateValues() error { 61 | return c.Values.validateValues() 62 | } 63 | 64 | // ValidateSessionValues validates all configuration values that require a bluetooth session. 65 | func (c *Config) ValidateSessionValues(session bluetooth.Session) error { 66 | return c.Values.validateSessionValues(session) 67 | } 68 | 69 | // createConfigDir checks for and/or creates a configuration directory. 70 | func (c *Config) createConfigDir() error { 71 | homedir, err := os.UserHomeDir() 72 | if err != nil { 73 | return err 74 | } 75 | 76 | type configDir struct { 77 | path, fullpath string 78 | exist, hidden, prefixHomeDir bool 79 | } 80 | 81 | configPaths := []*configDir{ 82 | {path: os.Getenv("XDG_CONFIG_HOME")}, 83 | {path: ".config", prefixHomeDir: true}, 84 | {path: ".", hidden: true, prefixHomeDir: true}, 85 | } 86 | 87 | for _, dir := range configPaths { 88 | name := "bluetuith" 89 | 90 | if dir.path == "" { 91 | continue 92 | } 93 | 94 | if dir.hidden { 95 | name = "." + name 96 | } 97 | 98 | if dir.prefixHomeDir { 99 | dir.path = filepath.Join(homedir, dir.path) 100 | } 101 | 102 | if _, err := os.Stat(filepath.Clean(dir.path)); err == nil { 103 | dir.exist = true 104 | } 105 | 106 | dir.fullpath = filepath.Join(dir.path, name) 107 | if _, err := os.Stat(filepath.Clean(dir.fullpath)); err == nil { 108 | c.path = dir.fullpath 109 | break 110 | } 111 | } 112 | 113 | if c.path == "" { 114 | var pathErrors []string 115 | 116 | for _, dir := range configPaths { 117 | if err := os.Mkdir(dir.fullpath, os.ModePerm); err == nil { 118 | c.path = dir.fullpath 119 | break 120 | } 121 | 122 | pathErrors = append(pathErrors, dir.fullpath) 123 | } 124 | 125 | if len(pathErrors) == len(configPaths) { 126 | return fmt.Errorf("the configuration directories could not be created at %s%s", "\n", strings.Join(pathErrors, "\n")) 127 | } 128 | } 129 | 130 | return nil 131 | } 132 | 133 | // FilePath returns the absolute path for the given configuration file. 134 | func (c *Config) FilePath(configFile string) (string, error) { 135 | confPath := filepath.Join(c.path, configFile) 136 | 137 | if _, err := os.Stat(confPath); err != nil { 138 | fd, err := os.Create(confPath) 139 | fd.Close() 140 | if err != nil { 141 | return "", fmt.Errorf("Cannot create "+configFile+" file at %s", confPath) 142 | } 143 | } 144 | 145 | return confPath, nil 146 | } 147 | 148 | // GenerateAndSave generates and updates the configuration. 149 | // Any existing values are appended to it. 150 | func (c *Config) GenerateAndSave(currentCfg *koanf.Koanf) (bool, error) { 151 | var parsedOldCfg bool 152 | 153 | cfg, err := c.parseOldConfig(currentCfg) 154 | if err == nil { 155 | parsedOldCfg = true 156 | } 157 | 158 | cfg.Delete("generate") 159 | 160 | data, err := hjson.Parser().Marshal(cfg.Raw()) 161 | if err != nil { 162 | return parsedOldCfg, err 163 | } 164 | 165 | conf, err := c.FilePath(configFile) 166 | if err != nil { 167 | return parsedOldCfg, err 168 | } 169 | 170 | f, err := os.OpenFile(conf, os.O_WRONLY|os.O_TRUNC, os.ModePerm) 171 | if err != nil { 172 | return parsedOldCfg, err 173 | } 174 | defer f.Close() 175 | 176 | _, err = f.Write(data) 177 | if err != nil { 178 | return parsedOldCfg, err 179 | } 180 | 181 | if err := f.Sync(); err != nil { 182 | return parsedOldCfg, err 183 | } 184 | 185 | return parsedOldCfg, nil 186 | } 187 | 188 | // parseOldConfig parses and stores values from the old configuration. 189 | func (c *Config) parseOldConfig(currentCfg *koanf.Koanf) (*koanf.Koanf, error) { 190 | f, err := c.FilePath(oldConfigFile) 191 | if err != nil { 192 | return currentCfg, nil 193 | } 194 | 195 | fd, err := os.OpenFile(f, os.O_RDONLY, os.ModePerm) 196 | if err != nil { 197 | return currentCfg, errors.New("the old configuration could not be read") 198 | } 199 | 200 | k := koanf.New(".") 201 | scanner := bufio.NewScanner(fd) 202 | for scanner.Scan() { 203 | line := scanner.Text() 204 | if line == "" { 205 | continue 206 | } 207 | 208 | values := strings.Split(line, "=") 209 | if len(values) != 2 { 210 | continue 211 | } 212 | 213 | k.Set(values[0], values[1]) 214 | } 215 | 216 | fd.Close() 217 | 218 | if err = scanner.Err(); err != nil && err != io.EOF { 219 | return currentCfg, errors.New("the old configuration could not be parsed") 220 | } 221 | 222 | if err := k.Merge(currentCfg); err != nil { 223 | return currentCfg, err 224 | } 225 | 226 | return k, nil 227 | } 228 | -------------------------------------------------------------------------------- /ui/app/views/viewauthorizer.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path/filepath" 7 | 8 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | // authorizer holds a set of functions used to authenticate pairing and receiving 13 | // file transfer requests. A new instance of this is supposed to be passed to 14 | // [bluetooth.Session.Start] to handle any authorization requests. 15 | type authorizer struct { 16 | v *Views 17 | initialized bool 18 | 19 | alwaysAuthorize bool 20 | } 21 | 22 | // newAuthorizer returns a new authorizer. 23 | func newAuthorizer(v *Views) *authorizer { 24 | return &authorizer{v: v} 25 | } 26 | 27 | // setInitialized sets the authorizer to the initialized state. 28 | // This is called after all views have been initialized. 29 | func (a *authorizer) setInitialized() { 30 | a.initialized = true 31 | } 32 | 33 | // AuthorizeTransfer asks the user to authorize a file transfer (Object Push) that is about to be sent 34 | // from the remote device. 35 | func (a *authorizer) AuthorizeTransfer(timeout bluetooth.AuthTimeout, props bluetooth.ObjectPushData) error { 36 | if !a.initialized { 37 | return nil 38 | } 39 | 40 | device, err := a.v.app.Session().Device(props.Address).Properties() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | filename := props.Name 46 | if filename == "" { 47 | filename = filepath.Base(props.Filename) 48 | } 49 | 50 | reply := a.v.status.waitForInput(timeout, fmt.Sprintf("[::bu]%s[-:-:-]: Accept file '%s' (y/n/a)", device.Name, filename)) 51 | switch reply { 52 | case "a": 53 | a.alwaysAuthorize = true 54 | fallthrough 55 | 56 | case "y": 57 | a.v.progress.showStatus() 58 | return nil 59 | } 60 | 61 | return errors.New("Cancelled") 62 | } 63 | 64 | // DisplayPinCode displays the pincode from the remote device to the user during a pairing authorization session. 65 | func (a *authorizer) DisplayPinCode(timeout bluetooth.AuthTimeout, address bluetooth.MacAddress, pincode string) error { 66 | if !a.initialized { 67 | return nil 68 | } 69 | 70 | device, err := a.v.app.Session().Device(address).Properties() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | msg := fmt.Sprintf( 76 | "The pincode for [::bu]%s[-:-:-] is:\n\n[::b]%s[-:-:-]", 77 | device.Name, pincode, 78 | ) 79 | 80 | modal := a.generateDisplayModal(address, "pincode", "Pin Code", msg) 81 | modal.display(timeout) 82 | 83 | return nil 84 | } 85 | 86 | // DisplayPasskey only displays the passkey from the remote device to the user during a pairing authorization session. 87 | // This can be called multiple times, since each time the user enters a number on the remote device, this function 88 | // is called with the updated 'entered' value. 89 | // TODO: Handle multiple calls/draws when this function is called. 90 | func (a *authorizer) DisplayPasskey(timeout bluetooth.AuthTimeout, address bluetooth.MacAddress, passkey uint32, entered uint16) error { 91 | if !a.initialized { 92 | return nil 93 | } 94 | 95 | device, err := a.v.app.Session().Device(address).Properties() 96 | if err != nil { 97 | return err 98 | } 99 | 100 | msg := fmt.Sprintf( 101 | "The passkey for [::bu]%s[-:-:-] is:\n\n[::b]%d[-:-:-]", 102 | device.Name, passkey, 103 | ) 104 | if entered > 0 { 105 | msg += fmt.Sprintf("\n\nYou have entered %d", entered) 106 | } 107 | 108 | modal := a.generateDisplayModal(address, "passkey-display", "Passkey Display", msg) 109 | modal.display(timeout) 110 | 111 | return nil 112 | } 113 | 114 | // ConfirmPasskey asks the user to authorize the pairing request using the provided passkey. 115 | func (a *authorizer) ConfirmPasskey(timeout bluetooth.AuthTimeout, address bluetooth.MacAddress, passkey uint32) error { 116 | if !a.initialized { 117 | return nil 118 | } 119 | 120 | device, err := a.v.app.Session().Device(address).Properties() 121 | if err != nil { 122 | return err 123 | } 124 | 125 | msg := fmt.Sprintf( 126 | "Confirm passkey for [::bu]%s[-:-:-] is \n\n[::b]%d[-:-:-]", 127 | device.Name, passkey, 128 | ) 129 | 130 | modal := a.generateConfirmModal(address, "passkey-confirm", "Passkey Confirmation", msg) 131 | reply := modal.getReply(timeout) 132 | if reply != "y" { 133 | return errors.New("Reply was: " + reply) 134 | } 135 | 136 | _ = a.v.app.Session().Device(address).SetTrusted(true) 137 | 138 | return nil 139 | } 140 | 141 | // AuthorizePairing asks the user to authorize a pairing request. 142 | func (a *authorizer) AuthorizePairing(timeout bluetooth.AuthTimeout, address bluetooth.MacAddress) error { 143 | if !a.initialized { 144 | return nil 145 | } 146 | 147 | device, err := a.v.app.Session().Device(address).Properties() 148 | if err != nil { 149 | return err 150 | } 151 | msg := fmt.Sprintf("Confirm pairing with [::bu]%s[-:-:-]", device.Name) 152 | 153 | modal := a.generateConfirmModal(address, "pairing-confirm", "Pairing Confirmation", msg) 154 | reply := modal.getReply(timeout) 155 | if reply != "y" { 156 | return errors.New("Reply was: " + reply) 157 | } 158 | 159 | _ = a.v.app.Session().Device(address).SetTrusted(true) 160 | 161 | return nil 162 | } 163 | 164 | // AuthorizeService asks the user to authorize whether a specific Bluetooth Profile is allowed to be used. 165 | func (a *authorizer) AuthorizeService(timeout bluetooth.AuthTimeout, address bluetooth.MacAddress, profileUUID uuid.UUID) error { 166 | if !a.initialized || a.alwaysAuthorize { 167 | return nil 168 | } 169 | 170 | serviceName := bluetooth.ServiceType(profileUUID.String()) 171 | device, err := a.v.app.Session().Device(address).Properties() 172 | if err != nil { 173 | return err 174 | } 175 | 176 | reply := a.v.status.waitForInput(timeout, fmt.Sprintf("[::bu]%s[-:-:-]: Authorize service '%s' (y/n/a)", device.Name, serviceName)) 177 | switch reply { 178 | case "a": 179 | a.alwaysAuthorize = true 180 | fallthrough 181 | 182 | case "y": 183 | return nil 184 | } 185 | 186 | return errors.New("Cancelled") 187 | } 188 | 189 | // generateConfirmModal generates a confirmation modal with the provided parameters. 190 | func (a *authorizer) generateConfirmModal(address bluetooth.MacAddress, name, title, msg string) *confirmModalView { 191 | return a.v.modals.newConfirmModal(name+":"+address.String(), title, msg) 192 | } 193 | 194 | // generateDisplayModal generates a display modal with the provided parameters. 195 | func (a *authorizer) generateDisplayModal(address bluetooth.MacAddress, name, title, msg string) *displayModalView { 196 | return a.v.modals.newDisplayModal(name+":"+address.String(), title, msg) 197 | } 198 | -------------------------------------------------------------------------------- /cmd/cli.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/bluetuith-org/bluetooth-classic/api/appfeatures" 10 | scfg "github.com/bluetuith-org/bluetooth-classic/api/config" 11 | "github.com/bluetuith-org/bluetooth-classic/session" 12 | "github.com/darkhz/bluetuith/ui/app" 13 | "github.com/darkhz/bluetuith/ui/config" 14 | "github.com/knadh/koanf/v2" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | // These values are set at compile-time. 19 | var ( 20 | Version = "" 21 | Revision = "" 22 | ) 23 | 24 | // Run runs the commandline application. 25 | func Run() error { 26 | return newApp().Run(os.Args) 27 | } 28 | 29 | // newApp returns a new commandline application. 30 | func newApp() *cli.App { 31 | cli.VersionPrinter = func(cCtx *cli.Context) { 32 | fmt.Fprintf(cCtx.App.Writer, "%s (%s)\n", Version, Revision) 33 | } 34 | 35 | return &cli.App{ 36 | Name: "bluetuith", 37 | Usage: "Bluetooth Manager.", 38 | Version: Version + " (" + Revision + ")", 39 | Description: "A Bluetooth manager for the terminal.", 40 | DefaultCommand: "bluetuith", 41 | Copyright: "(c) bluetuith-org.", 42 | Compiled: time.Now(), 43 | EnableBashCompletion: true, 44 | UseShortOptionHandling: true, 45 | Suggest: true, 46 | Flags: []cli.Flag{ 47 | &cli.BoolFlag{ 48 | Name: "list-adapters", 49 | Aliases: []string{"l"}, 50 | Usage: "List available adapters.", 51 | Action: func(*cli.Context, bool) error { 52 | var sb strings.Builder 53 | 54 | s := session.NewSession() 55 | _, _, err := s.Start(nil, scfg.New()) 56 | if err != nil { 57 | return err 58 | } 59 | defer s.Stop() 60 | 61 | adapters, err := s.Adapters() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | sb.WriteString("List of adapters:") 67 | for _, adapter := range adapters { 68 | sb.WriteString("\n") 69 | sb.WriteString("- ") 70 | sb.WriteString(adapter.UniqueName) 71 | } 72 | 73 | fmt.Println(sb.String()) 74 | 75 | return nil 76 | }, 77 | }, 78 | &cli.StringFlag{ 79 | Name: "adapter", 80 | Aliases: []string{"a"}, 81 | EnvVars: []string{"BLUETUITH_ADAPTER"}, 82 | Usage: "Specify an adapter to use. (For example, hci0)", 83 | }, 84 | &cli.StringFlag{ 85 | Name: "receive-dir", 86 | Aliases: []string{"r"}, 87 | EnvVars: []string{"BLUETUITH_RECEIVE_DIR"}, 88 | Usage: "Specify a directory to store received files.", 89 | }, 90 | &cli.StringFlag{ 91 | Name: "gsm-apn", 92 | Aliases: []string{"m"}, 93 | EnvVars: []string{"BLUETUITH_GSM_APN"}, 94 | Usage: "Specify GSM APN to connect to. (Required for DUN)", 95 | }, 96 | &cli.StringFlag{ 97 | Name: "gsm-number", 98 | Aliases: []string{"b"}, 99 | EnvVars: []string{"BLUETUITH_GSM_NUMBER"}, 100 | Usage: "Specify GSM number to dial. (Required for DUN)", 101 | }, 102 | &cli.StringFlag{ 103 | Name: "adapter-states", 104 | Aliases: []string{"s"}, 105 | EnvVars: []string{"BLUETUITH_ADAPTER_STATES"}, 106 | Usage: "Specify adapter states to enable/disable. (For example, 'powered:yes,discoverable:yes,pairable:yes,scan:no')", 107 | }, 108 | &cli.StringFlag{ 109 | Name: "connect-bdaddr", 110 | Aliases: []string{"t"}, 111 | EnvVars: []string{"BLUETUITH_CONNECT_BDADDR"}, 112 | Usage: "Specify device address to connect (For example, 'AA:BB:CC:DD:EE:FF')", 113 | }, 114 | &cli.BoolFlag{ 115 | Name: "no-warning", 116 | Aliases: []string{"w"}, 117 | EnvVars: []string{"BLUETUITH_NO_WARNING"}, 118 | Usage: "Do not display warnings when the application has initialized.", 119 | }, 120 | &cli.BoolFlag{ 121 | Name: "no-help-display", 122 | Aliases: []string{"i"}, 123 | EnvVars: []string{"BLUETUITH_NO_HELP_DISPLAY"}, 124 | Usage: "Do not display help keybindings in the application.", 125 | }, 126 | &cli.BoolFlag{ 127 | Name: "confirm-on-quit", 128 | Aliases: []string{"c"}, 129 | EnvVars: []string{"BLUETUITH_CONFIRM_ON_QUIT"}, 130 | Usage: "Ask for confirmation before quitting the application.", 131 | }, 132 | &cli.BoolFlag{ 133 | Name: "generate", 134 | Aliases: []string{"g"}, 135 | Usage: "Generate configuration.", 136 | Action: func(cliCtx *cli.Context, _ bool) error { 137 | k := koanf.New(".") 138 | 139 | cliCtx.Command.Name = "global" 140 | 141 | conf := config.NewConfig() 142 | if err := conf.Load(k, cliCtx); err != nil { 143 | return err 144 | } 145 | 146 | oldcfgparsed, err := conf.GenerateAndSave(k) 147 | if !oldcfgparsed { 148 | printWarn("the old configuration could not be parsed") 149 | } 150 | 151 | return err 152 | }, 153 | }, 154 | }, 155 | Action: func(cliCtx *cli.Context) error { 156 | if cliCtx.Bool("list-adapters") || cliCtx.Bool("generate") { 157 | return nil 158 | } 159 | 160 | // required for koanf to merge all global flags under the root namespace. 161 | cliCtx.Command.Name = "global" 162 | 163 | k, cfg := koanf.New("."), config.NewConfig() 164 | if err := cfg.Load(k, cliCtx); err != nil { 165 | return err 166 | } 167 | if err := cfg.ValidateValues(); err != nil { 168 | return err 169 | } 170 | 171 | app, s := app.NewApplication(), session.NewSession() 172 | featureSet, _, err := s.Start(app.Authorizer(), scfg.New()) 173 | if err != nil { 174 | return err 175 | } 176 | defer s.Stop() 177 | 178 | if err := cfg.ValidateSessionValues(s); err != nil { 179 | return err 180 | } 181 | printUnsupportedFeatures(cfg, featureSet) 182 | 183 | return app.Start(s, featureSet, cfg) 184 | }, 185 | ExitErrHandler: func(_ *cli.Context, err error) { 186 | if err == nil { 187 | return 188 | } 189 | 190 | printError(err) 191 | }, 192 | } 193 | } 194 | 195 | // printUnsupportedFeatures prints all unsupported features of the session. 196 | func printUnsupportedFeatures(cfg *config.Config, featureSet *appfeatures.FeatureSet) { 197 | if cfg.Values.NoWarning { 198 | return 199 | } 200 | 201 | var warn strings.Builder 202 | 203 | featErrors, exists := featureSet.Errors.Exists() 204 | if !exists { 205 | return 206 | } 207 | 208 | warn.WriteString("The following features are not available:") 209 | for feature, errors := range featErrors { 210 | warn.WriteString("\n") 211 | warn.WriteString(feature.String()) 212 | warn.WriteString(": ") 213 | warn.WriteString(errors.Error()) 214 | } 215 | 216 | printWarn(warn.String()) 217 | time.Sleep(1 * time.Second) 218 | } 219 | -------------------------------------------------------------------------------- /ui/config/values.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 9 | 10 | "github.com/darkhz/bluetuith/ui/keybindings" 11 | "github.com/darkhz/bluetuith/ui/theme" 12 | ) 13 | 14 | // Values describes the possible configuration values that a user can 15 | // modify and supply to the application. 16 | type Values struct { 17 | Adapter string `koanf:"adapter"` 18 | ReceiveDir string `koanf:"receive-dir"` 19 | GsmApn string `koanf:"gsm-apn"` 20 | GsmNumber string `koanf:"gsm-number"` 21 | AdapterStates string `koanf:"adapter-states"` 22 | ConnectAddr string `koanf:"connect-bdaddr"` 23 | NoWarning bool `koanf:"no-warning"` 24 | NoHelpDisplay bool `koanf:"no-help-display"` 25 | ConfirmOnQuit bool `koanf:"confirm-on-quit"` 26 | Theme map[string]string `koanf:"theme"` 27 | Keybindings map[string]string `koanf:"keybindings"` 28 | 29 | AdapterStatesMap map[string]string 30 | SelectedAdapter *bluetooth.AdapterData 31 | AutoConnectDeviceAddr bluetooth.MacAddress 32 | Kb *keybindings.Keybindings 33 | } 34 | 35 | // validateValues validates all configuration values. 36 | func (v *Values) validateValues() error { 37 | for _, validate := range []func() error{ 38 | v.validateKeybindings, 39 | v.validateAdapterStates, 40 | v.validateConnectBDAddr, 41 | v.validateReceiveDir, 42 | v.validateGsm, 43 | v.validateTheme, 44 | } { 45 | if err := validate(); err != nil { 46 | return err 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | 53 | // validateSessionValues validates all configuration values that require a bluetooth session. 54 | func (v *Values) validateSessionValues(session bluetooth.Session) error { 55 | for _, validate := range []func(bluetooth.Session) error{ 56 | v.validateAdapter, 57 | v.validateDeviceExists, 58 | } { 59 | if err := validate(session); err != nil { 60 | return err 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | 67 | // validateAdapter validates if the adapter specified by the user exists in the system. 68 | func (v *Values) validateAdapter(session bluetooth.Session) error { 69 | adapters, err := session.Adapters() 70 | if err != nil { 71 | return fmt.Errorf("no adapters were found: %w", err) 72 | } 73 | 74 | if v.Adapter == "" { 75 | v.SelectedAdapter = &adapters[0] 76 | return nil 77 | } 78 | 79 | for _, adapter := range adapters { 80 | if adapter.UniqueName == v.Adapter || adapter.Address.String() == v.Adapter { 81 | v.SelectedAdapter = &adapter 82 | return nil 83 | } 84 | } 85 | 86 | return fmt.Errorf("%s: The adapter does not exist", v.Adapter) 87 | } 88 | 89 | // validateDeviceExists validates if a device specified by the user exists within any adapter in the system. 90 | func (v *Values) validateDeviceExists(session bluetooth.Session) error { 91 | if v.AutoConnectDeviceAddr.IsNil() { 92 | return nil 93 | } 94 | 95 | deviceAddr := v.AutoConnectDeviceAddr 96 | 97 | adapters, err := session.Adapters() 98 | if err != nil { 99 | return fmt.Errorf("no adapters were found: %w", err) 100 | } 101 | 102 | adapterlist := make([]string, 0, len(adapters)) 103 | for _, adapter := range adapters { 104 | if v.Adapter != "" && adapter.UniqueName != v.Adapter { 105 | continue 106 | } 107 | 108 | adapterlist = append(adapterlist, adapter.UniqueName) 109 | devices, err := session.Adapter(adapter.Address).Devices() 110 | if err != nil { 111 | continue 112 | } 113 | 114 | for _, device := range devices { 115 | if device.Address == deviceAddr { 116 | v.SelectedAdapter = &adapter 117 | return nil 118 | } 119 | } 120 | 121 | if v.Adapter != "" && adapter.UniqueName == v.Adapter { 122 | break 123 | } 124 | } 125 | 126 | return fmt.Errorf("no device with address %s found on adapters %s", deviceAddr.String(), strings.Join(adapterlist, ", ")) 127 | } 128 | 129 | // validateKeybindings validates the keybindings. 130 | func (v *Values) validateKeybindings() error { 131 | v.Kb = keybindings.NewKeybindings() 132 | if len(v.Keybindings) == 0 { 133 | return nil 134 | } 135 | 136 | return v.Kb.Validate(v.Keybindings) 137 | } 138 | 139 | // validateAdapterStates validates the adapter states to be set on application launch. 140 | // The result is appended to the 'AdapterStatesMap' property, which includes: 141 | // - A 'sequence' key with a comma-separated value of states to be toggled in order. 142 | // - Each adapter state as subsequent keys, with values of "yes"/"no" to determine how each adapter state should be toggled. 143 | func (v *Values) validateAdapterStates() error { 144 | if v.AdapterStates == "" { 145 | return nil 146 | } 147 | 148 | properties := make(map[string]string) 149 | propertyAndStates := strings.Split(v.AdapterStates, ",") 150 | 151 | propertyOptions := []string{ 152 | "powered", 153 | "scan", 154 | "discoverable", 155 | "pairable", 156 | } 157 | 158 | stateOptions := []string{ 159 | "yes", "no", 160 | "y", "n", 161 | "on", "off", 162 | } 163 | 164 | sequence := []string{} 165 | 166 | for _, ps := range propertyAndStates { 167 | property := strings.FieldsFunc(ps, func(r rune) bool { 168 | return r == ' ' || r == ':' 169 | }) 170 | if len(property) != 2 { 171 | return fmt.Errorf( 172 | "provided property:state format '%s' is incorrect", 173 | ps, 174 | ) 175 | } 176 | 177 | for _, prop := range propertyOptions { 178 | if property[0] == prop { 179 | goto CheckState 180 | } 181 | } 182 | return fmt.Errorf( 183 | "provided property '%s' is incorrect.\nValid properties are '%s'", 184 | property[0], 185 | strings.Join(propertyOptions, ", "), 186 | ) 187 | 188 | CheckState: 189 | state := property[1] 190 | switch state { 191 | case "yes", "y", "on": 192 | state = "yes" 193 | 194 | case "no", "n", "off": 195 | state = "no" 196 | 197 | default: 198 | return fmt.Errorf( 199 | "provided state '%s' for property '%s' is incorrect.\nValid states are '%s'", 200 | state, property[0], 201 | strings.Join(stateOptions, ", "), 202 | ) 203 | } 204 | 205 | properties[property[0]] = state 206 | sequence = append(sequence, property[0]) 207 | } 208 | 209 | properties["sequence"] = strings.Join(sequence, ",") 210 | v.AdapterStatesMap = properties 211 | 212 | return nil 213 | } 214 | 215 | // validateConnectBDAddr validates the device address that has to be automatically connected to on application 216 | // launch. 217 | func (v *Values) validateConnectBDAddr() error { 218 | if v.ConnectAddr == "" { 219 | return nil 220 | } 221 | 222 | deviceAddr, err := bluetooth.ParseMAC(v.ConnectAddr) 223 | if err != nil { 224 | return fmt.Errorf("invalid address format: %s", v.ConnectAddr) 225 | } 226 | 227 | v.AutoConnectDeviceAddr = deviceAddr 228 | 229 | return nil 230 | } 231 | 232 | // validateReceiveDir validates the path to the download directory for received files 233 | // via OBEX Object Push. 234 | func (v *Values) validateReceiveDir() error { 235 | if v.ReceiveDir == "" { 236 | return nil 237 | } 238 | 239 | if statpath, err := os.Stat(v.ReceiveDir); err != nil || !statpath.IsDir() { 240 | return fmt.Errorf("%s: Directory is not accessible", v.ReceiveDir) 241 | } 242 | 243 | return nil 244 | } 245 | 246 | // validateGsm validates the GSM number and APN for the DUN network type. 247 | func (v *Values) validateGsm() error { 248 | if v.GsmNumber == "" && v.GsmApn == "" { 249 | return nil 250 | } 251 | if v.GsmNumber == "" && v.GsmApn != "" { 252 | return fmt.Errorf("specify GSM Number") 253 | } 254 | 255 | if v.GsmNumber == "" { 256 | v.GsmNumber = "*99#" 257 | } 258 | 259 | return nil 260 | } 261 | 262 | // validateTheme validates the theme configuration. 263 | func (v *Values) validateTheme() error { 264 | if len(v.Theme) == 0 { 265 | return nil 266 | } 267 | 268 | return theme.ParseThemeConfig(v.Theme) 269 | } 270 | -------------------------------------------------------------------------------- /ui/app/views/views.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "github.com/bluetuith-org/bluetooth-classic/api/appfeatures" 5 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 6 | "github.com/darkhz/tview" 7 | "github.com/gdamore/tcell/v2" 8 | 9 | "github.com/darkhz/bluetuith/ui/config" 10 | "github.com/darkhz/bluetuith/ui/keybindings" 11 | "github.com/darkhz/bluetuith/ui/theme" 12 | ) 13 | 14 | // AppData holds all the necessary layout and event handling data for the root application to initialize. 15 | // This is passed to the application once all the views are initialized using [Views.Initialize]. 16 | type AppData struct { 17 | // layout holds the layout of the application. 18 | Layout *tview.Flex 19 | InitialFocus *tview.Flex 20 | MouseFunc func(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction) 21 | BeforeDrawFunc func(t tcell.Screen) bool 22 | InputCapture func(event *tcell.EventKey) *tcell.EventKey 23 | } 24 | 25 | // AppBinder binds all the root application's functions to the views manager ([Views]). 26 | type AppBinder interface { 27 | Session() bluetooth.Session 28 | Features() *appfeatures.FeatureSet 29 | 30 | QueueDraw(drawFunc func()) 31 | InstantDraw(drawFunc func()) 32 | Refresh() 33 | FocusPrimitive(primitive tview.Primitive) 34 | 35 | Suspend(t tcell.Screen) 36 | StartSuspend() 37 | GetFocused() tview.Primitive 38 | Close() 39 | } 40 | 41 | // viewInitializer represents an initializer for a view. 42 | // All views must implement this interface. 43 | type viewInitializer interface { 44 | Initialize() error 45 | SetRootView(v *Views) 46 | } 47 | 48 | // Views holds all the views as well as different managers for 49 | // the view layouts, operations and actions. 50 | type Views struct { 51 | // pages holds and renders the different views, along with 52 | // any menu popups that will be added. 53 | pages *viewPages 54 | layout *tview.Flex 55 | 56 | menu *menuBarView 57 | help *helpView 58 | status *statusBarView 59 | modals *modalViews 60 | device *deviceView 61 | adapter *adapterView 62 | filepicker *filePickerView 63 | progress *progressView 64 | player *mediaPlayer 65 | audioProfiles *audioProfilesView 66 | network *networkView 67 | 68 | actions *viewActions 69 | op *viewOperation 70 | kb *keybindings.Keybindings 71 | cfg *config.Config 72 | 73 | app AppBinder 74 | auth *authorizer 75 | } 76 | 77 | // NewViews returns a new Views instance. 78 | func NewViews() *Views { 79 | v := &Views{ 80 | pages: &viewPages{}, 81 | menu: &menuBarView{}, 82 | help: &helpView{}, 83 | status: &statusBarView{}, 84 | modals: &modalViews{}, 85 | device: &deviceView{}, 86 | adapter: &adapterView{}, 87 | filepicker: &filePickerView{}, 88 | progress: &progressView{}, 89 | player: &mediaPlayer{}, 90 | audioProfiles: &audioProfilesView{}, 91 | network: &networkView{}, 92 | actions: &viewActions{}, 93 | op: &viewOperation{}, 94 | kb: &keybindings.Keybindings{}, 95 | } 96 | 97 | v.auth = newAuthorizer(v) 98 | 99 | return v 100 | } 101 | 102 | // Initialize initializes all the views. 103 | func (v *Views) Initialize(binder AppBinder, cfg *config.Config) (*AppData, error) { 104 | v.app = binder 105 | v.cfg = cfg 106 | v.kb = v.cfg.Values.Kb 107 | 108 | v.actions = newViewActions(v) 109 | v.op = newViewOperation(v) 110 | 111 | v.pages = newViewPages() 112 | v.layout = tview.NewFlex(). 113 | SetDirection(tview.FlexRow). 114 | AddItem(v.pages, 0, 10, true) 115 | 116 | initializers := []viewInitializer{ 117 | v.menu, 118 | v.status, 119 | v.help, 120 | v.modals, 121 | v.adapter, 122 | v.device, 123 | v.filepicker, v.progress, 124 | v.player, v.audioProfiles, 125 | v.network, 126 | } 127 | 128 | dontInit := map[viewInitializer]struct{}{} 129 | if !v.app.Features().HasAny(appfeatures.FeatureSendFile, appfeatures.FeatureReceiveFile) { 130 | dontInit[v.filepicker] = struct{}{} 131 | dontInit[v.progress] = struct{}{} 132 | } 133 | if !v.app.Features().Has(appfeatures.FeatureMediaPlayer) { 134 | dontInit[v.player] = struct{}{} 135 | dontInit[v.audioProfiles] = struct{}{} 136 | } 137 | if !v.app.Features().Has(appfeatures.FeatureNetwork) { 138 | dontInit[v.network] = struct{}{} 139 | } 140 | 141 | for _, i := range initializers { 142 | i.SetRootView(v) 143 | if _, ok := dontInit[i]; ok { 144 | continue 145 | } 146 | 147 | if err := i.Initialize(); err != nil { 148 | return nil, err 149 | } 150 | } 151 | 152 | v.menu.setHeader("") 153 | 154 | v.kb.Initialize() 155 | v.auth.setInitialized() 156 | 157 | return &AppData{ 158 | Layout: v.layout, 159 | InitialFocus: v.arrangeViews(), 160 | MouseFunc: func(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction) { 161 | return v.modals.modalMouseHandler(event, action) 162 | }, 163 | BeforeDrawFunc: func(t tcell.Screen) bool { 164 | v.modals.resizeModal() 165 | v.app.Suspend(t) 166 | 167 | return false 168 | }, 169 | InputCapture: func(event *tcell.EventKey) *tcell.EventKey { 170 | operation := v.kb.Key(event) 171 | 172 | if e, ok := v.kb.IsNavigation(operation, event); ok { 173 | focused := v.app.GetFocused() 174 | if focused != nil && focused.InputHandler() != nil { 175 | focused.InputHandler()(e, nil) 176 | return nil 177 | } 178 | } 179 | 180 | switch operation { 181 | case keybindings.KeySuspend: 182 | v.app.StartSuspend() 183 | 184 | case keybindings.KeyCancel: 185 | v.op.cancelOperation(true) 186 | } 187 | 188 | return tcell.NewEventKey(event.Key(), event.Rune(), event.Modifiers()) 189 | }, 190 | }, nil 191 | } 192 | 193 | // Authorizer returns an authorization manager. 194 | func (v *Views) Authorizer() bluetooth.SessionAuthorizer { 195 | return v.auth 196 | } 197 | 198 | // arrangeViews arranges all the views and their layouts. 199 | func (v *Views) arrangeViews() *tview.Flex { 200 | box := tview.NewBox(). 201 | SetBackgroundColor(theme.GetColor(theme.ThemeMenuBar)) 202 | 203 | menuArea := tview.NewFlex(). 204 | AddItem(v.adapter.topAdapterName, 0, 1, false). 205 | AddItem(v.menu.bar, len(v.menu.bar.GetText(true)), 1, false). 206 | AddItem(box, 1, 1, false). 207 | AddItem(v.adapter.topStatus, 0, 4, false) 208 | menuArea.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 209 | menuArea.SetDrawFunc(func(_ tcell.Screen, x, y, width, height int) (int, int, int, int) { 210 | w := len(v.adapter.topAdapterName.GetText(true)) 211 | resize := min(w, width/8) 212 | 213 | menuArea.ResizeItem(v.adapter.topAdapterName, resize, 0) 214 | 215 | return x, y, width, height 216 | }) 217 | 218 | flex := tview.NewFlex(). 219 | SetDirection(tview.FlexRow). 220 | AddItem(menuArea, 1, 0, false). 221 | AddItem(nil, 1, 0, false). 222 | AddItem(v.device.table, 0, 10, true) 223 | flex.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 224 | 225 | v.pages.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 226 | v.pages.SetChangedFunc(func() { 227 | page, _ := v.pages.GetFrontPage() 228 | 229 | contexts := map[string]keybindings.Context{ 230 | devicePage.String(): keybindings.ContextDevice, 231 | filePickerPage.String(): keybindings.ContextFiles, 232 | progressPage.String(): keybindings.ContextProgress, 233 | } 234 | 235 | switch page { 236 | case devicePage.String(), filePickerPage.String(), progressPage.String(): 237 | v.pages.currentPage(page) 238 | v.pages.currentContext(contexts[page]) 239 | 240 | default: 241 | v.pages.currentContext(keybindings.ContextApp) 242 | } 243 | 244 | v.help.showStatusHelp(page) 245 | }) 246 | 247 | v.layout.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 248 | 249 | v.pages.AddAndSwitchToPage(devicePage.String(), flex, true) 250 | v.status.InfoMessage("bluetuith is ready.", false) 251 | 252 | return flex 253 | } 254 | 255 | // viewName represents the name of a particular view. 256 | type viewName string 257 | 258 | // String returns the string representation of the view's name. 259 | func (v viewName) String() string { 260 | return string(v) 261 | } 262 | 263 | // getSelectionXY gets the coordinates of the current table selection. 264 | func getSelectionXY(table *tview.Table) (x int, y int) { 265 | row, _ := table.GetSelection() 266 | 267 | cell := table.GetCell(row, 0) 268 | x, y, _ = cell.GetLastPosition() 269 | 270 | return x, y 271 | } 272 | 273 | // ignoreDefaultEvent ignores the default keyevents in the provided event. 274 | func ignoreDefaultEvent(event *tcell.EventKey) *tcell.EventKey { 275 | switch event.Key() { 276 | case tcell.KeyCtrlF, tcell.KeyCtrlB: 277 | return nil 278 | } 279 | 280 | switch event.Rune() { 281 | case 'g', 'G', 'j', 'k', 'h', 'l': 282 | return nil 283 | } 284 | 285 | return event 286 | } 287 | 288 | // horizontalLine returns a box with a thick horizontal line. 289 | func horizontalLine() *tview.Box { 290 | return tview.NewBox(). 291 | SetBackgroundColor(tcell.ColorDefault). 292 | SetDrawFunc(func( 293 | screen tcell.Screen, 294 | x, y, width, height int) (int, int, int, int) { 295 | centerY := y + height/2 296 | for cx := x; cx < x+width; cx++ { 297 | screen.SetContent( 298 | cx, 299 | centerY, 300 | tview.BoxDrawingsLightHorizontal, 301 | nil, 302 | tcell.StyleDefault.Foreground(tcell.ColorWhite), 303 | ) 304 | } 305 | 306 | return x + 1, 307 | centerY + 1, 308 | width - 2, 309 | height - (centerY + 1 - y) 310 | }) 311 | } 312 | -------------------------------------------------------------------------------- /ui/app/views/adapterview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 11 | "github.com/darkhz/tview" 12 | "github.com/gdamore/tcell/v2" 13 | "go.uber.org/atomic" 14 | 15 | "github.com/darkhz/bluetuith/ui/theme" 16 | ) 17 | 18 | // adapterView holds the adapter view, which contains the displays of: 19 | // - The adapter name on the left-most side of the menubar. 20 | // - The adapter statuses on the roght-most side of the menubar. 21 | type adapterView struct { 22 | topAdapterName *tview.TextView 23 | topStatus *tview.TextView 24 | currentAdapter atomic.Pointer[bluetooth.AdapterData] 25 | 26 | *Views 27 | } 28 | 29 | // Initialize initializes the adapter view. 30 | func (a *adapterView) Initialize() error { 31 | a.topStatus = tview.NewTextView() 32 | a.topStatus.SetRegions(true) 33 | a.topStatus.SetDynamicColors(true) 34 | a.topStatus.SetTextAlign(tview.AlignRight) 35 | a.topStatus.SetBackgroundColor(theme.GetColor(theme.ThemeMenuBar)) 36 | 37 | a.topAdapterName = tview.NewTextView() 38 | a.topAdapterName.SetRegions(true) 39 | a.topAdapterName.SetDynamicColors(true) 40 | a.topAdapterName.SetTextAlign(tview.AlignLeft) 41 | a.topAdapterName.SetBackgroundColor(theme.GetColor(theme.ThemeMenuBar)) 42 | a.topAdapterName.SetHighlightedFunc(func(added, _, _ []string) { 43 | if len(added) == 0 { 44 | return 45 | } 46 | 47 | a.change() 48 | }) 49 | 50 | a.setAdapter(a.cfg.Values.SelectedAdapter) 51 | a.updateTopStatus() 52 | a.setStates() 53 | 54 | go a.event() 55 | if err := a.adapter.currentSession().SetPairableState(false); err == nil { 56 | a.adapter.currentSession().SetPairableState(true) 57 | } 58 | 59 | return nil 60 | } 61 | 62 | // SetRootView sets the root view for the adapter view. 63 | func (a *adapterView) SetRootView(v *Views) { 64 | a.Views = v 65 | } 66 | 67 | // refreshHeader displays the selected adapter's name and unique name 68 | // on the menu bar. 69 | func (a *adapterView) refreshHeader() { 70 | props, err := a.currentSession().Properties() 71 | if err != nil { 72 | a.menu.setHeader(theme.ColorWrap(theme.ThemeAdapter, "(Connect adapter)", "::bu")) 73 | return 74 | } 75 | 76 | headerText := fmt.Sprintf("[\"%s\"]%s (%s)[\"\"]", 77 | menuAdapterChangeName.String(), 78 | props.Name, 79 | props.UniqueName, 80 | ) 81 | a.topAdapterName.SetText(theme.ColorWrap(theme.ThemeAdapter, headerText, "::bu")) 82 | } 83 | 84 | // getAdapter returns the currently selected adapter. 85 | // Note that the properties of this adapter are not updated, use 86 | // 'currentSession' to get the updated properties. 87 | func (a *adapterView) getAdapter() *bluetooth.AdapterData { 88 | return a.currentAdapter.Load() 89 | } 90 | 91 | // currentSession wraps a bluetooth session with the current adapter. 92 | func (a *adapterView) currentSession() bluetooth.Adapter { 93 | return a.app.Session().Adapter(a.currentAdapter.Load().Address) 94 | } 95 | 96 | // setAdapter sets the current adapter. 97 | func (a *adapterView) setAdapter(adapter *bluetooth.AdapterData) { 98 | a.currentAdapter.Swap(adapter) 99 | a.refreshHeader() 100 | } 101 | 102 | // selectAdapter selects the first available adapter. 103 | func (a *adapterView) selectAdapter() bool { 104 | adapters, err := a.app.Session().Adapters() 105 | if err != nil { 106 | a.status.ErrorMessage(err) 107 | a.setAdapter(&bluetooth.AdapterData{}) 108 | 109 | return false 110 | } 111 | 112 | a.setAdapter(&adapters[0]) 113 | 114 | return true 115 | } 116 | 117 | // change launches a popup with a list of adapters. 118 | // Changing the selection will change the currently selected adapter. 119 | func (a *adapterView) change() { 120 | if modal, ok := a.modals.getModal(menuAdapterName.String()); ok { 121 | modal.remove(false) 122 | } 123 | 124 | a.menu.drawContextMenu( 125 | menuAdapterName.String(), nil, 126 | func(adapterMenu *tview.Table, row, _ int) { 127 | cell := adapterMenu.GetCell(row, 0) 128 | if cell == nil { 129 | return 130 | } 131 | 132 | adapter, ok := cell.GetReference().(bluetooth.AdapterData) 133 | if !ok { 134 | return 135 | } 136 | 137 | a.op.cancelOperation(false) 138 | 139 | a.setAdapter(&adapter) 140 | a.updateTopStatus() 141 | 142 | a.device.list() 143 | }, 144 | func(adapterMenu *tview.Table) (int, int) { 145 | var width, index int 146 | 147 | adapters, err := a.app.Session().Adapters() 148 | if err != nil { 149 | a.status.ErrorMessage(err) 150 | return -1, -1 151 | } 152 | 153 | sort.Slice(adapters, func(i, j int) bool { 154 | return adapters[i].UniqueName < adapters[j].UniqueName 155 | }) 156 | 157 | for row, adapter := range adapters { 158 | if len(adapter.Name) > width { 159 | width = len(adapter.Name) 160 | } 161 | 162 | if adapter.UniqueName == a.getAdapter().UniqueName { 163 | index = row 164 | } 165 | 166 | adapterMenu.SetCell(row, 0, tview.NewTableCell(adapter.Name). 167 | SetExpansion(1). 168 | SetReference(adapter). 169 | SetAlign(tview.AlignLeft). 170 | SetTextColor(theme.GetColor(theme.ThemeAdapter)). 171 | SetSelectedStyle(tcell.Style{}. 172 | Foreground(theme.GetColor(theme.ThemeAdapter)). 173 | Background(theme.BackgroundColor(theme.ThemeAdapter)), 174 | ), 175 | ) 176 | adapterMenu.SetCell(row, 1, tview.NewTableCell("("+adapter.UniqueName+")"). 177 | SetAlign(tview.AlignRight). 178 | SetTextColor(theme.GetColor(theme.ThemeAdapter)). 179 | SetSelectedStyle(tcell.Style{}. 180 | Foreground(theme.GetColor(theme.ThemeAdapter)). 181 | Background(theme.BackgroundColor(theme.ThemeAdapter)), 182 | ), 183 | ) 184 | } 185 | 186 | a.topAdapterName.Highlight(menuAdapterChangeName.String()) 187 | 188 | return width, index 189 | }) 190 | } 191 | 192 | // updateTopStatus updates the adapter status display. 193 | func (a *adapterView) updateTopStatus() { 194 | a.topStatus.Clear() 195 | 196 | props, err := a.currentSession().Properties() 197 | if err != nil { 198 | a.status.ErrorMessage(err) 199 | return 200 | } 201 | 202 | for _, status := range []struct { 203 | Title string 204 | Enabled bool 205 | Color theme.Context 206 | }{ 207 | { 208 | Title: "Powered", 209 | Enabled: props.Powered, 210 | Color: theme.ThemeAdapterPowered, 211 | }, 212 | { 213 | Title: "Scanning", 214 | Enabled: props.Discovering, 215 | Color: theme.ThemeAdapterScanning, 216 | }, 217 | { 218 | Title: "Discoverable", 219 | Enabled: props.Discoverable, 220 | Color: theme.ThemeAdapterDiscoverable, 221 | }, 222 | { 223 | Title: "Pairable", 224 | Enabled: props.Pairable, 225 | Color: theme.ThemeAdapterPairable, 226 | }, 227 | } { 228 | if !status.Enabled { 229 | if status.Title != "Powered" { 230 | continue 231 | } 232 | 233 | status.Title = "Not " + status.Title 234 | status.Color = "AdapterNotPowered" 235 | } 236 | 237 | textColor := theme.ColorName(theme.BackgroundColor(status.Color)) 238 | bgColor := theme.ThemeConfig[status.Color] 239 | 240 | region := strings.ToLower(status.Title) 241 | fmt.Fprintf(a.topStatus, "[\"%s\"][%s:%s:b] %s [-:-:-][\"\"] ", region, textColor, bgColor, status.Title) 242 | } 243 | } 244 | 245 | // setStates sets the adapter states which were parsed from 246 | // the "adapter-states" command-line option. 247 | func (a *adapterView) setStates() { 248 | var lock sync.Mutex 249 | 250 | properties := a.cfg.Values.AdapterStatesMap 251 | if len(properties) == 0 { 252 | return 253 | } 254 | 255 | seq, ok := properties["sequence"] 256 | if !ok { 257 | a.status.InfoMessage("Cannot get adapter states", false) 258 | return 259 | } 260 | 261 | sequence := strings.SplitSeq(seq, ",") 262 | for property := range sequence { 263 | var handler func(set ...string) bool 264 | 265 | state, ok := properties[property] 266 | if !ok { 267 | a.status.InfoMessage("Cannot set adapter "+property+" state", false) 268 | return 269 | } 270 | 271 | switch property { 272 | case "powered": 273 | handler = a.actions.power 274 | 275 | case "scan": 276 | handler = a.actions.scan 277 | 278 | case "discoverable": 279 | handler = a.actions.discoverable 280 | 281 | case "pairable": 282 | handler = a.actions.pairable 283 | 284 | default: 285 | continue 286 | } 287 | 288 | go func() { 289 | lock.Lock() 290 | defer lock.Unlock() 291 | 292 | handler(state) 293 | }() 294 | } 295 | } 296 | 297 | // event handles adapter-specific events. 298 | func (a *adapterView) event() { 299 | adapterSub, ok := bluetooth.AdapterEvents().Subscribe() 300 | if !ok { 301 | a.status.ErrorMessage(errors.New("cannot subscribe to adapter events")) 302 | return 303 | } 304 | 305 | for { 306 | select { 307 | case <-adapterSub.Done: 308 | return 309 | 310 | case ev := <-adapterSub.AddedEvents: 311 | address := a.currentAdapter.Load().Address 312 | if address.IsNil() && !a.selectAdapter() { 313 | continue 314 | } 315 | 316 | go a.app.QueueDraw(func() { 317 | a.change() 318 | 319 | if ev.Address == address { 320 | a.updateTopStatus() 321 | a.device.list() 322 | } 323 | }) 324 | 325 | case ev := <-adapterSub.UpdatedEvents: 326 | if ev.Address == a.currentAdapter.Load().Address { 327 | go a.app.QueueDraw(func() { 328 | a.updateTopStatus() 329 | }) 330 | } 331 | 332 | case <-adapterSub.RemovedEvents: 333 | if a.selectAdapter() { 334 | go a.app.QueueDraw(func() { 335 | a.updateTopStatus() 336 | a.change() 337 | }) 338 | } 339 | } 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /ui/app/views/helpview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/darkhz/bluetuith/ui/keybindings" 7 | "github.com/darkhz/bluetuith/ui/theme" 8 | "github.com/darkhz/tview" 9 | "github.com/gdamore/tcell/v2" 10 | ) 11 | 12 | // helpView holds the help view. 13 | type helpView struct { 14 | popup modalView 15 | page string 16 | area *tview.Flex 17 | 18 | topics map[string][]HelpData 19 | 20 | *Views 21 | } 22 | 23 | // Initialize initializes the help view. 24 | func (h *helpView) Initialize() error { 25 | if !h.cfg.Values.NoHelpDisplay { 26 | h.area = tview.NewFlex(). 27 | SetDirection(tview.FlexRow). 28 | AddItem(horizontalLine(), 1, 0, false). 29 | AddItem(h.status.Help, 1, 0, false) 30 | } 31 | 32 | h.initHelpData() 33 | h.statusHelpArea(true) 34 | 35 | return nil 36 | } 37 | 38 | // SetRootView sets the root view for the help view. 39 | func (h *helpView) SetRootView(v *Views) { 40 | h.Views = v 41 | } 42 | 43 | // statusHelpArea shows or hides the status help text. 44 | func (h *helpView) statusHelpArea(add bool) { 45 | if h.cfg.Values.NoHelpDisplay { 46 | return 47 | } 48 | 49 | if !add && h.area != nil { 50 | h.layout.RemoveItem(h.area) 51 | return 52 | } 53 | 54 | h.layout.AddItem(h.area, 2, 0, false) 55 | } 56 | 57 | // swapStatusHelp adds or removes the provided primitive from the layout and displays 58 | // the help text below the statusbar. 59 | func (h *helpView) swapStatusHelp(primitive tview.Primitive, add bool) { 60 | h.statusHelpArea(false) 61 | defer h.statusHelpArea(true) 62 | 63 | if add { 64 | h.layout.AddItem(primitive, 8, 0, false) 65 | } else { 66 | h.layout.RemoveItem(primitive) 67 | } 68 | } 69 | 70 | // showStatusHelp shows a condensed help text for the currently focused screen below the statusbar. 71 | func (h *helpView) showStatusHelp(page string) { 72 | if h.cfg.Values.NoHelpDisplay || h.page == page { 73 | return 74 | } 75 | 76 | h.page = page 77 | pages := map[string]string{ 78 | devicePage.String(): "Device Screen", 79 | filePickerPage.String(): "File Picker", 80 | progressPage.String(): "Progress View", 81 | } 82 | 83 | items, ok := h.topics[pages[page]] 84 | if !ok { 85 | h.status.Help.Clear() 86 | return 87 | } 88 | 89 | groups := map[string][]HelpData{} 90 | 91 | for _, item := range items { 92 | if !item.ShowInStatus { 93 | continue 94 | } 95 | 96 | var group string 97 | 98 | for _, key := range item.Keys { 99 | switch key { 100 | case keybindings.KeyMenu, keybindings.KeySwitch: 101 | group = "Open" 102 | 103 | case keybindings.KeyFilebrowserSelect, keybindings.KeyFilebrowserInvertSelection, keybindings.KeyFilebrowserSelectAll: 104 | group = "Select" 105 | 106 | case keybindings.KeyProgressTransferSuspend, keybindings.KeyProgressTransferResume, keybindings.KeyProgressTransferCancel: 107 | group = "Transfer" 108 | 109 | case keybindings.KeyDeviceConnect, keybindings.KeyDevicePair, keybindings.KeyAdapterToggleScan, keybindings.KeyAdapterTogglePower: 110 | group = "Toggle" 111 | } 112 | } 113 | if group == "" { 114 | group = item.Title 115 | } 116 | 117 | helpItem := groups[group] 118 | if helpItem == nil { 119 | helpItem = []HelpData{} 120 | } 121 | 122 | helpItem = append(helpItem, item) 123 | groups[group] = helpItem 124 | } 125 | 126 | text := "" 127 | count := 0 128 | for group, items := range groups { 129 | var names, keys []string 130 | 131 | for _, item := range items { 132 | if item.Title != group { 133 | names = append(names, item.Title) 134 | } 135 | for _, k := range item.Keys { 136 | keys = append(keys, h.kb.Name(h.kb.Data(k).Kb)) 137 | } 138 | } 139 | if names != nil { 140 | group += " " + strings.Join(names, "/") 141 | } 142 | 143 | helpKeys := strings.Join(keys, "/") 144 | if count < len(groups)-1 { 145 | helpKeys += ", " 146 | } 147 | 148 | title := theme.ColorWrap(theme.ThemeText, group, "::bu") 149 | helpKeys = theme.ColorWrap(theme.ThemeText, ": "+helpKeys) 150 | 151 | text += title + helpKeys 152 | count++ 153 | } 154 | 155 | h.status.Help.SetText(text) 156 | } 157 | 158 | // showHelp displays a modal with the help items for all the screens. 159 | func (h *helpView) showHelp() { 160 | var row int 161 | 162 | helpModal := h.modals.newModalWithTable("help", "Help", 40, 60) 163 | helpModal.table.SetSelectionChangedFunc(func(row, _ int) { 164 | if row == 1 { 165 | helpModal.table.ScrollToBeginning() 166 | } 167 | }) 168 | helpModal.table.SetMouseCapture(func(action tview.MouseAction, event *tcell.EventMouse) (tview.MouseAction, *tcell.EventMouse) { 169 | if action == tview.MouseScrollUp { 170 | helpModal.table.InputHandler()(tcell.NewEventKey(tcell.KeyUp, ' ', tcell.ModNone), nil) 171 | } 172 | 173 | return action, event 174 | }) 175 | 176 | for title, helpItems := range h.topics { 177 | helpModal.table.SetCell(row, 0, tview.NewTableCell("[::bu]"+title). 178 | SetSelectable(false). 179 | SetAlign(tview.AlignCenter). 180 | SetTextColor(theme.GetColor(theme.ThemeText)), 181 | ) 182 | 183 | row++ 184 | 185 | for _, item := range helpItems { 186 | var names []string 187 | 188 | for _, k := range item.Keys { 189 | names = append(names, h.kb.Name(h.kb.Data(k).Kb)) 190 | } 191 | 192 | keybinding := strings.Join(names, "/") 193 | 194 | helpModal.table.SetCell(row, 0, tview.NewTableCell(theme.ColorWrap(theme.ThemeText, item.Description)). 195 | SetExpansion(1). 196 | SetAlign(tview.AlignLeft). 197 | SetTextColor(theme.GetColor(theme.ThemeText)). 198 | SetSelectedStyle(tcell.Style{}. 199 | Foreground(theme.GetColor(theme.ThemeText)). 200 | Background(theme.BackgroundColor(theme.ThemeText)), 201 | ), 202 | ) 203 | 204 | helpModal.table.SetCell(row, 1, tview.NewTableCell(theme.ColorWrap(theme.ThemeText, keybinding)). 205 | SetExpansion(0). 206 | SetAlign(tview.AlignLeft). 207 | SetTextColor(theme.GetColor(theme.ThemeText)). 208 | SetSelectedStyle(tcell.Style{}. 209 | Foreground(theme.GetColor(theme.ThemeText)). 210 | Background(theme.BackgroundColor(theme.ThemeText)), 211 | ), 212 | ) 213 | 214 | row++ 215 | } 216 | 217 | row++ 218 | 219 | } 220 | 221 | helpModal.show() 222 | } 223 | 224 | // HelpData describes the help item. 225 | type HelpData struct { 226 | Title, Description string 227 | Keys []keybindings.Key 228 | ShowInStatus bool 229 | } 230 | 231 | // initHelpData initializes the help data for all the specified screens. 232 | func (h *helpView) initHelpData() { 233 | h.topics = map[string][]HelpData{ 234 | "Device Screen": { 235 | {"Menu", "Open the menu", []keybindings.Key{keybindings.KeyMenu}, true}, 236 | {"Switch", "Navigate between menus", []keybindings.Key{keybindings.KeySwitch}, true}, 237 | {"Navigation", "Navigate between devices/options", []keybindings.Key{keybindings.KeyNavigateUp, keybindings.KeyNavigateDown}, true}, 238 | {"Power", "Toggle adapter power state", []keybindings.Key{keybindings.KeyAdapterTogglePower}, true}, 239 | {"Discoverable", "Toggle discoverable state", []keybindings.Key{keybindings.KeyAdapterToggleDiscoverable}, false}, 240 | {"Pairable", "Toggle pairable state", []keybindings.Key{keybindings.KeyAdapterTogglePairable}, false}, 241 | {"Scan", "Toggle scan (discovery state)", []keybindings.Key{keybindings.KeyAdapterToggleScan}, true}, 242 | {"Adapter", "Change adapter", []keybindings.Key{keybindings.KeyAdapterChange}, true}, 243 | {"Send", "Send files", []keybindings.Key{keybindings.KeyDeviceSendFiles}, true}, 244 | {"Network", "Connect to network", []keybindings.Key{keybindings.KeyDeviceNetwork}, false}, 245 | {"Progress", "Progress view", []keybindings.Key{keybindings.KeyProgressView}, false}, 246 | {"Player", "Show/Hide player", []keybindings.Key{keybindings.KeyPlayerShow, keybindings.KeyPlayerHide}, false}, 247 | {"Device Info", "Show device information", []keybindings.Key{keybindings.KeyDeviceInfo}, false}, 248 | {"Connect", "Toggle connection with selected device", []keybindings.Key{keybindings.KeyDeviceConnect}, true}, 249 | {"Pair", "Toggle pair with selected device", []keybindings.Key{keybindings.KeyDevicePair}, true}, 250 | {"Trust", "Toggle trust with selected device", []keybindings.Key{keybindings.KeyDeviceTrust}, false}, 251 | {"Remove", "Remove device from adapter", []keybindings.Key{keybindings.KeyDeviceRemove}, false}, 252 | {"Cancel", "Cancel operation", []keybindings.Key{keybindings.KeyCancel}, false}, 253 | {"Help", "Show help", []keybindings.Key{keybindings.KeyHelp}, true}, 254 | {"Quit", "Quit", []keybindings.Key{keybindings.KeyQuit}, false}, 255 | }, 256 | "File Picker": { 257 | {"Navigation", "Navigate between directory entries", []keybindings.Key{keybindings.KeyNavigateUp, keybindings.KeyNavigateDown}, true}, 258 | {"ChgDir Fwd/Back", "Enter/Go back a directory", []keybindings.Key{keybindings.KeyNavigateRight, keybindings.KeyNavigateLeft}, true}, 259 | {"One", "Select one file", []keybindings.Key{keybindings.KeyFilebrowserSelect}, true}, 260 | {"Invert", "Invert file selection", []keybindings.Key{keybindings.KeyFilebrowserInvertSelection}, true}, 261 | {"All", "Select all files", []keybindings.Key{keybindings.KeyFilebrowserSelectAll}, true}, 262 | {"Refresh", "Refresh current directory", []keybindings.Key{keybindings.KeyFilebrowserRefresh}, false}, 263 | {"Hidden", "Toggle hidden files", []keybindings.Key{keybindings.KeyFilebrowserToggleHidden}, false}, 264 | {"Confirm", "Confirm file(s) selection", []keybindings.Key{keybindings.KeyFilebrowserConfirmSelection}, true}, 265 | {"Exit", "Exit", []keybindings.Key{keybindings.KeyClose}, false}, 266 | }, 267 | "Progress View": { 268 | {"Navigation", "Navigate between transfers", []keybindings.Key{keybindings.KeyNavigateUp, keybindings.KeyNavigateDown}, true}, 269 | {"Suspend", "Suspend transfer", []keybindings.Key{keybindings.KeyProgressTransferSuspend}, true}, 270 | {"Resume", "Resume transfer", []keybindings.Key{keybindings.KeyProgressTransferResume}, true}, 271 | {"Cancel", "Cancel transfer", []keybindings.Key{keybindings.KeyProgressTransferCancel}, true}, 272 | {"Exit", "Exit", []keybindings.Key{keybindings.KeyClose}, true}, 273 | }, 274 | "Media Player": { 275 | {"Play/Pause", "Toggle play/pause", []keybindings.Key{keybindings.KeyNavigateUp, keybindings.KeyNavigateDown}, false}, 276 | {"Next", "Next", []keybindings.Key{keybindings.KeyPlayerNext}, false}, 277 | {"Previous", "Previous", []keybindings.Key{keybindings.KeyPlayerPrevious}, false}, 278 | {"Rewind", "Rewind", []keybindings.Key{keybindings.KeyPlayerSeekBackward}, false}, 279 | {"Forward", "Fast forward", []keybindings.Key{keybindings.KeyPlayerSeekForward}, false}, 280 | {"Stop", "Stop", []keybindings.Key{keybindings.KeyPlayerStop}, false}, 281 | }, 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /ui/app/views/deviceview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 9 | "github.com/darkhz/bluetuith/ui/keybindings" 10 | "github.com/darkhz/bluetuith/ui/theme" 11 | "github.com/darkhz/tview" 12 | "github.com/gdamore/tcell/v2" 13 | ) 14 | 15 | const devicePage viewName = "devices" 16 | 17 | // deviceView holds the devices view. 18 | type deviceView struct { 19 | table *tview.Table 20 | 21 | *Views 22 | } 23 | 24 | // Initialize initializes the devices view. 25 | func (d *deviceView) Initialize() error { 26 | d.table = tview.NewTable() 27 | d.table.SetSelectorWrap(true) 28 | d.table.SetSelectable(true, false) 29 | d.table.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 30 | d.table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 31 | switch d.kb.Key(event) { 32 | case keybindings.KeyMenu: 33 | d.menu.highlight(menuAdapterName) 34 | return event 35 | 36 | case keybindings.KeyHelp: 37 | d.help.showHelp() 38 | return event 39 | } 40 | 41 | d.player.keyEvents(event) 42 | 43 | d.menu.inputHandler(event) 44 | 45 | return ignoreDefaultEvent(event) 46 | }) 47 | d.table.SetMouseCapture(func(action tview.MouseAction, event *tcell.EventMouse) (tview.MouseAction, *tcell.EventMouse) { 48 | if action == tview.MouseRightClick && d.table.HasFocus() { 49 | device := d.getSelection(false) 50 | if device.Address.IsNil() { 51 | return action, event 52 | } 53 | 54 | d.menu.setupSubMenu(0, 0, menuDeviceName, struct{}{}) 55 | } 56 | 57 | return action, event 58 | }) 59 | 60 | d.list() 61 | d.connectByAddress() 62 | go d.event() 63 | 64 | return nil 65 | } 66 | 67 | // SetRootView sets the root view of the devices view. 68 | func (d *deviceView) SetRootView(v *Views) { 69 | d.Views = v 70 | } 71 | 72 | // list lists the devices belonging to the selected adapter within the devices view. 73 | func (d *deviceView) list() { 74 | devices, err := d.adapter.currentSession().Devices() 75 | if err != nil { 76 | d.status.ErrorMessage(err) 77 | return 78 | } 79 | 80 | d.table.Clear() 81 | for i, device := range devices { 82 | d.setInfo(i, device) 83 | } 84 | d.table.Select(0, 0) 85 | } 86 | 87 | // connectByAddress connects to a device based on the provided address 88 | // which was parsed from the "connect-bdaddr" command-line option. 89 | func (d *deviceView) connectByAddress() { 90 | address := d.cfg.Values.AutoConnectDeviceAddr 91 | if address.IsNil() { 92 | return 93 | } 94 | 95 | go d.actions.connect(address.String()) 96 | } 97 | 98 | // showDetailedInfo shows detailed information about a device. 99 | func (d *deviceView) showDetailedInfo() { 100 | device := d.getSelection(false) 101 | if device.Address.IsNil() { 102 | return 103 | } 104 | 105 | yesno := func(val bool) string { 106 | if !val { 107 | return "no" 108 | } 109 | 110 | return "yes" 111 | } 112 | 113 | assocAdapter, err := d.app.Session().Adapter(device.AssociatedAdapter).Properties() 114 | if err != nil { 115 | d.status.ErrorMessage(err) 116 | return 117 | } 118 | 119 | device, err = d.app.Session().Device(device.Address).Properties() 120 | if err != nil { 121 | d.status.ErrorMessage(err) 122 | return 123 | } 124 | 125 | props := [][]string{ 126 | {"Name", device.Name}, 127 | {"Alias", device.Alias}, 128 | {"Address", device.Address.String()}, 129 | {"Class", strconv.FormatUint(uint64(device.Class), 10)}, 130 | {"Adapter", assocAdapter.UniqueName}, 131 | {"Connected", yesno(device.Connected)}, 132 | {"Paired", yesno(device.Paired)}, 133 | {"Bonded", yesno(device.Bonded)}, 134 | {"Trusted", yesno(device.Trusted)}, 135 | {"Blocked", yesno(device.Blocked)}, 136 | {"LegacyPairing", yesno(device.LegacyPairing)}, 137 | } 138 | props = append(props, []string{"UUIDs", ""}) 139 | 140 | infoModal := d.modals.newModalWithTable("info", "Device Information", 40, 100) 141 | infoModal.table.SetSelectionChangedFunc(func(row, _ int) { 142 | _, _, _, height := infoModal.table.GetRect() 143 | infoModal.table.SetOffset(row-((height-1)/2), 0) 144 | }) 145 | 146 | for i, prop := range props { 147 | propName := prop[0] 148 | propValue := prop[1] 149 | 150 | if propName == "Class" { 151 | propValue += " (" + device.Type + ")" 152 | } 153 | 154 | infoModal.table.SetCell(i, 0, tview.NewTableCell("[::b]"+propName+":"). 155 | SetExpansion(1). 156 | SetAlign(tview.AlignLeft). 157 | SetTextColor(theme.GetColor(theme.ThemeText)). 158 | SetSelectedStyle(tcell.Style{}. 159 | Bold(true). 160 | Underline(true), 161 | ), 162 | ) 163 | 164 | infoModal.table.SetCell(i, 1, tview.NewTableCell(propValue). 165 | SetExpansion(1). 166 | SetAlign(tview.AlignLeft). 167 | SetTextColor(theme.GetColor(theme.ThemeText)), 168 | ) 169 | } 170 | 171 | rows := infoModal.table.GetRowCount() - 1 172 | for i, serviceUUID := range device.UUIDs { 173 | serviceType := bluetooth.ServiceType(serviceUUID) 174 | serviceUUID = "(" + serviceUUID + ")" 175 | 176 | infoModal.table.SetCell(rows+i, 1, tview.NewTableCell(serviceType). 177 | SetExpansion(1). 178 | SetAlign(tview.AlignLeft). 179 | SetTextColor(theme.GetColor(theme.ThemeText)), 180 | ) 181 | 182 | infoModal.table.SetCell(rows+i, 2, tview.NewTableCell(serviceUUID). 183 | SetExpansion(0). 184 | SetTextColor(theme.GetColor(theme.ThemeText)), 185 | ) 186 | } 187 | 188 | infoModal.height = min(infoModal.table.GetRowCount()+4, 60) 189 | 190 | infoModal.show() 191 | } 192 | 193 | // getSelection retrieves device information from the current selection in the devices view. 194 | func (d *deviceView) getSelection(lock bool) bluetooth.DeviceData { 195 | var device bluetooth.DeviceData 196 | 197 | getdevice := func() { 198 | row, _ := d.table.GetSelection() 199 | 200 | cell := d.table.GetCell(row, 0) 201 | if cell == nil { 202 | device = bluetooth.DeviceData{} 203 | } 204 | 205 | d, ok := cell.GetReference().(bluetooth.DeviceData) 206 | if !ok { 207 | device = bluetooth.DeviceData{} 208 | } 209 | 210 | device = d 211 | } 212 | 213 | if lock { 214 | d.app.QueueDraw(func() { 215 | getdevice() 216 | }) 217 | 218 | return device 219 | } 220 | 221 | getdevice() 222 | 223 | return device 224 | } 225 | 226 | // getRowByAddress iterates through the devices view and checks 227 | // if a device whose path matches the path parameter exists. 228 | func (d *deviceView) getRowByAddress(address bluetooth.MacAddress) (int, bool) { 229 | for row := range d.table.GetRowCount() { 230 | cell := d.table.GetCell(row, 0) 231 | if cell == nil { 232 | continue 233 | } 234 | 235 | ref, ok := cell.GetReference().(bluetooth.DeviceData) 236 | if !ok { 237 | continue 238 | } 239 | 240 | if ref.Address == address { 241 | return row, true 242 | } 243 | } 244 | 245 | return -1, false 246 | } 247 | 248 | // setInfo writes device information into the specified row of the devices view. 249 | func (d *deviceView) setInfo(row int, device bluetooth.DeviceData) { 250 | var sb strings.Builder 251 | 252 | name := device.Name 253 | if name == "" { 254 | name = device.Address.String() 255 | } 256 | sb.WriteString(name) 257 | sb.WriteString(" (") 258 | if device.Alias != "" && device.Alias != device.Name { 259 | sb.WriteString(theme.ColorWrap(theme.ThemeDeviceAlias, device.Alias)) 260 | sb.WriteString(", ") 261 | } 262 | sb.WriteString(theme.ColorWrap(theme.ThemeDeviceType, device.Type)) 263 | sb.WriteString(")") 264 | 265 | nameDisplay := sb.String() 266 | nameColor := theme.ThemeDevice 267 | 268 | d.table.SetCell( 269 | row, 0, tview.NewTableCell(nameDisplay). 270 | SetExpansion(1). 271 | SetReference(device). 272 | SetAlign(tview.AlignLeft). 273 | SetAttributes(tcell.AttrBold). 274 | SetTextColor(theme.GetColor(nameColor)). 275 | SetSelectedStyle(tcell.Style{}. 276 | Foreground(theme.GetColor(nameColor)). 277 | Background(theme.BackgroundColor(nameColor)), 278 | ), 279 | ) 280 | 281 | d.setPropertyInfo(row, device.DeviceEventData, false) 282 | } 283 | 284 | // setPropertyInfo writes the property information of a device into the specified row of the devices view. 285 | func (d *deviceView) setPropertyInfo(row int, deviceEvent bluetooth.DeviceEventData, isPartialUpdate bool) { 286 | var sb strings.Builder 287 | 288 | nameColor := theme.ThemeDevice 289 | propColor := theme.ThemeDeviceProperty 290 | 291 | sb.WriteString("(") 292 | 293 | appendProperty := func(prop string) { 294 | if sb.Len() == 1 { 295 | sb.WriteString(prop) 296 | return 297 | } 298 | 299 | sb.WriteString(", ") 300 | sb.WriteString(prop) 301 | } 302 | 303 | if deviceEvent.Connected { 304 | appendProperty("Connected") 305 | 306 | nameColor = theme.ThemeDeviceConnected 307 | propColor = theme.ThemeDevicePropertyConnected 308 | 309 | if deviceEvent.RSSI < 0 { 310 | rssi := strconv.FormatInt(int64(deviceEvent.RSSI), 10) 311 | sb.WriteString(" [") 312 | sb.WriteString(rssi) 313 | sb.WriteString("[]") 314 | } 315 | 316 | if deviceEvent.Percentage > 0 { 317 | appendProperty("Battery ") 318 | sb.WriteString(strconv.Itoa(deviceEvent.Percentage)) 319 | sb.WriteString("%") 320 | } 321 | } 322 | if deviceEvent.Trusted { 323 | appendProperty("Trusted") 324 | } 325 | if deviceEvent.Blocked { 326 | appendProperty("Blocked") 327 | } 328 | 329 | if deviceEvent.Bonded && deviceEvent.Paired { 330 | appendProperty("Bonded") 331 | } else if !deviceEvent.Bonded && deviceEvent.Paired { 332 | appendProperty("Paired") 333 | } 334 | 335 | if sb.Len() == 1 { 336 | sb.Reset() 337 | sb.WriteString("[New Device[]") 338 | nameColor = theme.ThemeDeviceDiscovered 339 | propColor = theme.ThemeDevicePropertyDiscovered 340 | } else { 341 | sb.WriteString(")") 342 | } 343 | 344 | deviceNameCell := d.table.GetCell(row, 0) 345 | if deviceNameCell != nil { 346 | deviceNameCell.SetTextColor(theme.GetColor(nameColor)) 347 | } 348 | 349 | if isPartialUpdate { 350 | device, ok := deviceNameCell.GetReference().(bluetooth.DeviceData) 351 | if ok { 352 | device.DeviceEventData = deviceEvent 353 | deviceNameCell.SetReference(device) 354 | } 355 | 356 | devicePropertyCell := d.table.GetCell(row, 1) 357 | if devicePropertyCell != nil { 358 | devicePropertyCell.SetText(sb.String()) 359 | devicePropertyCell.SetTextColor(theme.GetColor(propColor)) 360 | } 361 | 362 | return 363 | } 364 | 365 | d.table.SetCell( 366 | row, 1, tview.NewTableCell(sb.String()). 367 | SetExpansion(1). 368 | SetAlign(tview.AlignRight). 369 | SetTextColor(theme.GetColor(propColor)). 370 | SetSelectedStyle(tcell.Style{}. 371 | Bold(true), 372 | ), 373 | ) 374 | } 375 | 376 | // event handles device-specific events. 377 | func (d *deviceView) event() { 378 | deviceSub, ok := bluetooth.DeviceEvents().Subscribe() 379 | if !ok { 380 | d.status.ErrorMessage(errors.New("cannot subscribe to device events")) 381 | return 382 | } 383 | 384 | for { 385 | select { 386 | case <-deviceSub.Done: 387 | return 388 | 389 | case ev := <-deviceSub.AddedEvents: 390 | go d.app.QueueDraw(func() { 391 | deviceRow := d.table.GetRowCount() 392 | 393 | row, ok := d.getRowByAddress(ev.Address) 394 | if ok { 395 | deviceRow = row 396 | } 397 | d.setInfo(deviceRow, ev) 398 | }) 399 | 400 | case ev := <-deviceSub.UpdatedEvents: 401 | go d.app.QueueDraw(func() { 402 | row, ok := d.getRowByAddress(ev.Address) 403 | if ok { 404 | d.setPropertyInfo(row, ev, true) 405 | 406 | } 407 | }) 408 | 409 | case ev := <-deviceSub.RemovedEvents: 410 | go d.app.QueueDraw(func() { 411 | row, ok := d.getRowByAddress(ev.Address) 412 | if ok { 413 | d.table.RemoveRow(row) 414 | d.player.closeForDevice(ev.Address) 415 | } 416 | }) 417 | } 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Southclaws/fault v0.8.2 h1:hbQANoRWYVWnQjpwJlNlfaolM+oIihgoFowaY3EBLCs= 2 | github.com/Southclaws/fault v0.8.2/go.mod h1:VUVkAWutC59SL16s6FTqf3I6I2z77RmnaW5XRz4bLOE= 3 | github.com/Wifx/gonetworkmanager v0.5.0 h1:P209z0yj705bl5tmyHTlpXPSv3QzjPtIM4X0SyDAqWA= 4 | github.com/Wifx/gonetworkmanager v0.5.0/go.mod h1:EdhHf2O00IZXfMv9LC6CS6SgTwcMTg/ZSDhGvch0cs8= 5 | github.com/bluetuith-org/bluetooth-classic v0.0.5 h1:TBToJcVG6wWWeqokoY8ERZJlfyOTvh3rln5ZAQjLjHU= 6 | github.com/bluetuith-org/bluetooth-classic v0.0.5/go.mod h1:32nZYGxDaHtzsCxQuXNvQ6B33+X6HV6tHJuwlA8ZzHY= 7 | github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= 8 | github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= 9 | github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 10 | github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 11 | github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= 12 | github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 13 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 14 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 15 | github.com/cskr/pubsub/v2 v2.0.2 h1:395hhPXEsyI1b+5nfj+s5Q3gdxpg0jsWd3t/QAdmU1Y= 16 | github.com/cskr/pubsub/v2 v2.0.2/go.mod h1:XYuiN8dhcXTCzQDa5SH4+B3zLso94FTwAk0maAEGJJw= 17 | github.com/darkhz/tview v0.0.0-20240308094543-6078a888ff79 h1:sRV/sfsevZQJt6GGYr89F2E6PuXI+Zapjn5fSHNIdXA= 18 | github.com/darkhz/tview v0.0.0-20240308094543-6078a888ff79/go.mod h1:LqVfmEdNqFdmpbjOhcIq9wkTUrw8Pik6eUEAq/YJyGk= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 22 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 23 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 24 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 25 | github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= 26 | github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= 27 | github.com/gdamore/tcell/v2 v2.13.1 h1:Ca2N6mHxhXuElCgn+nfKuZjS7gwNiIRKHFiljrZQ26A= 28 | github.com/gdamore/tcell/v2 v2.13.1/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= 29 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 30 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 31 | github.com/godbus/dbus/v5 v5.0.2/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 32 | github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= 33 | github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 34 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 35 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 36 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 37 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 38 | github.com/hjson/hjson-go/v4 v4.5.0 h1:ZHLiZ+HaGqPOtEe8T6qY8QHnoEsAeBv8wqxniQAp+CY= 39 | github.com/hjson/hjson-go/v4 v4.5.0/go.mod h1:4zx6c7Y0vWcm8IRyVoQJUHAPJLXLvbG6X8nk1RLigSo= 40 | github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= 41 | github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 42 | github.com/knadh/koanf/parsers/hjson v1.0.0 h1:MQGEzN2VFgBf+xkIwBU8HO5GxR+JGqdBD5QdkXcvwps= 43 | github.com/knadh/koanf/parsers/hjson v1.0.0/go.mod h1:n5pKeiKAnLXLiiBAPpaH9XVYL9Y3g9Ymz5tGqAY9240= 44 | github.com/knadh/koanf/providers/cliflagv2 v1.0.1 h1:TqQTJRYL/74OuXtYDrT/i0vuVgKNJIl1yQfi107a1XA= 45 | github.com/knadh/koanf/providers/cliflagv2 v1.0.1/go.mod h1:FuTfL7AuwsS6+1egpdw7N8Sh5Ia/rXs5MmcmxhTDIrg= 46 | github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U= 47 | github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= 48 | github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= 49 | github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= 50 | github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 51 | github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 52 | github.com/mafik/pulseaudio v0.0.0-20240327130323-384e01075e6e h1:y6jINFpNNS+IEizD6s9j35aKVNRsfsoEKN8IStRDQm0= 53 | github.com/mafik/pulseaudio v0.0.0-20240327130323-384e01075e6e/go.mod h1:dlpd1fnLAhI6g9tM/aCobgN/Yka1/SkHrBTfAgDdb9Q= 54 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 55 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 56 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 57 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 58 | github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 59 | github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 60 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 61 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 62 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 63 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 64 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 65 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 66 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 67 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 68 | github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= 69 | github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= 70 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 71 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 72 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 73 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 74 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 75 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 76 | github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= 77 | github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= 78 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 79 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 80 | github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= 81 | github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 82 | github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= 83 | github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= 84 | github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= 85 | github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 86 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 87 | go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 88 | go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 89 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 90 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 91 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 92 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 93 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 94 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 95 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 96 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 97 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 98 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 99 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 100 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 101 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 102 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 103 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 104 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 105 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 106 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 107 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 108 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 109 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 110 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 111 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 112 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 113 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 114 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 115 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 116 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 117 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 118 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 119 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 120 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 121 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 122 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 123 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 124 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 125 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 126 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 127 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 128 | -------------------------------------------------------------------------------- /ui/app/views/menubarview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/darkhz/bluetuith/ui/keybindings" 7 | "github.com/darkhz/bluetuith/ui/theme" 8 | "github.com/darkhz/tview" 9 | "github.com/gdamore/tcell/v2" 10 | ) 11 | 12 | const ( 13 | menuBarName viewName = "menu" 14 | menuAdapterChangeName viewName = "adapterchange" 15 | menuAdapterName viewName = "adapter" 16 | menuDeviceName viewName = "device" 17 | ) 18 | 19 | const menuBarRegions = `["adapter"][::b][Adapter[][""] ["device"][::b][Device[][""]` 20 | 21 | // menuBarView holds the menu bar view. 22 | type menuBarView struct { 23 | bar *tview.TextView 24 | modal *tableModalView 25 | 26 | menuOptions map[string][]menuOption 27 | optionByKey map[keybindings.Key]*menuOptionState 28 | 29 | sync.RWMutex 30 | 31 | *Views 32 | } 33 | 34 | // menuOption describes an option layout for a submenu. 35 | type menuOption struct { 36 | key keybindings.Key 37 | 38 | enabledText, disabledText string 39 | initBeforeInvoke, checkVisibility bool 40 | } 41 | 42 | // menuOptionState holds the current state of each menu item within a submenu. 43 | type menuOptionState struct { 44 | index int 45 | menuName string 46 | 47 | displayText string 48 | toggledState bool 49 | 50 | kdata *keybindings.KeyData 51 | 52 | *menuOption 53 | } 54 | 55 | // Initialize initializes the menu bar. 56 | func (m *menuBarView) Initialize() error { 57 | m.initOrderedOptions() 58 | m.setOptions() 59 | 60 | m.bar = tview.NewTextView() 61 | m.bar.SetRegions(true) 62 | m.bar.SetDynamicColors(true) 63 | m.bar.SetBackgroundColor(theme.GetColor(theme.ThemeMenuBar)) 64 | m.bar.SetHighlightedFunc(func(added, _, _ []string) { 65 | if added == nil { 66 | return 67 | } 68 | 69 | pos := m.bar.GetRegionStart(added[0]) 70 | _, _, w, _ := m.adapter.topAdapterName.GetInnerRect() 71 | 72 | m.setupSubMenu(pos+w, 1, viewName(added[0])) 73 | }) 74 | 75 | m.modal = m.modals.newMenuModal(menuBarName.String(), 0, 0) 76 | 77 | return nil 78 | } 79 | 80 | // SetRootView sets the root view for the menu bar. 81 | func (m *menuBarView) SetRootView(v *Views) { 82 | m.Views = v 83 | } 84 | 85 | // highlight highlights the menu's name in the menu bar. 86 | func (m *menuBarView) highlight(name viewName) { 87 | m.bar.Highlight(name.String()) 88 | } 89 | 90 | // setOptions sets up the menu options with its attributes. 91 | func (m *menuBarView) setOptions() { 92 | m.optionByKey = make(map[keybindings.Key]*menuOptionState) 93 | 94 | for menuName, kb := range m.menuOptions { 95 | for index, option := range kb { 96 | m.optionByKey[option.key] = &menuOptionState{ 97 | index: index, 98 | menuName: menuName, 99 | displayText: "", 100 | toggledState: false, 101 | kdata: m.kb.Data(option.key), 102 | menuOption: &option, 103 | } 104 | } 105 | } 106 | } 107 | 108 | // toggleItemByKey sets the toggled state of the specified menu item using its attached keybinding key name. 109 | // This function must be invoked instead of 'toggleMenuItem' for concurrent use. 110 | func (m *menuBarView) toggleItemByKey(menuKey keybindings.Key, toggle bool) { 111 | var name, displayText string 112 | var index int 113 | 114 | m.Lock() 115 | optstate, ok := m.optionByKey[menuKey] 116 | if ok { 117 | item := m.toggleMenuItem(optstate, toggle) 118 | name, displayText, index = item.menuName, item.displayText, item.index 119 | } 120 | m.Unlock() 121 | if !ok { 122 | return 123 | } 124 | 125 | m.app.QueueDraw(func() { 126 | highlighted := m.bar.GetHighlights() 127 | 128 | if m.modal.isOpen && highlighted != nil && highlighted[0] == name { 129 | cell := m.modal.table.GetCell(index, 0) 130 | if cell == nil { 131 | return 132 | } 133 | 134 | cell.Text = displayText 135 | } 136 | }) 137 | } 138 | 139 | // toggleMenuItem toggles the state of the provided menu item. 140 | func (m *menuBarView) toggleMenuItem(menuItem *menuOptionState, toggle bool) *menuOptionState { 141 | title := menuItem.kdata.Title 142 | switch { 143 | case menuItem.disabledText == "": 144 | menuItem.displayText = title 145 | return menuItem 146 | 147 | case menuItem.enabledText == "" && menuItem.disabledText != "": 148 | if toggle { 149 | menuItem.displayText = menuItem.disabledText 150 | } else { 151 | menuItem.displayText = title 152 | } 153 | 154 | menuItem.toggledState = toggle 155 | 156 | return menuItem 157 | } 158 | 159 | if toggle { 160 | menuItem.displayText = title + " " + menuItem.disabledText 161 | } else { 162 | menuItem.displayText = title + " " + menuItem.enabledText 163 | } 164 | 165 | menuItem.toggledState = toggle 166 | 167 | return menuItem 168 | } 169 | 170 | // setupSubMenu sets up a submenu for the specified menu. 171 | func (m *menuBarView) setupSubMenu(x, y int, menuID viewName, device ...struct{}) { 172 | var width, skipped int 173 | 174 | modal := m.modal 175 | modal.table.SetSelectorWrap(false) 176 | modal.table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 177 | switch m.kb.Key(event) { 178 | case keybindings.KeyClose: 179 | m.exit() 180 | return event 181 | 182 | case keybindings.KeySwitch: 183 | m.next() 184 | return event 185 | 186 | case keybindings.KeySelect: 187 | m.exit() 188 | } 189 | 190 | if m.inputHandler(event) { 191 | m.exit() 192 | } 193 | 194 | return ignoreDefaultEvent(event) 195 | }) 196 | modal.table.SetSelectedFunc(func(row, _ int) { 197 | cell := m.modal.table.GetCell(row, 0) 198 | if cell == nil { 199 | return 200 | } 201 | 202 | ref, ok := cell.GetReference().(*menuOption) 203 | if !ok || ref == nil { 204 | return 205 | } 206 | 207 | m.actions.handler(ref.key, actionInvoke)() 208 | }) 209 | 210 | m.Lock() 211 | defer m.Unlock() 212 | 213 | modal.table.Clear() 214 | for index, menuopt := range m.menuOptions[menuID.String()] { 215 | if menuopt.checkVisibility && !m.actions.handler(menuopt.key, actionVisibility)() { 216 | skipped++ 217 | continue 218 | } 219 | 220 | optstate, ok := m.optionByKey[menuopt.key] 221 | if !ok { 222 | continue 223 | } 224 | 225 | toggle := optstate.toggledState 226 | if menuopt.initBeforeInvoke { 227 | if initializer := m.actions.handler(menuopt.key, actionInitializer); initializer != nil { 228 | toggle = initializer() 229 | } 230 | } 231 | 232 | newstate := m.toggleMenuItem(optstate, toggle) 233 | 234 | display := newstate.displayText 235 | keybinding := m.kb.Name(newstate.kdata.Kb) 236 | clickedFunc := func() bool { 237 | m.exit() 238 | return m.actions.handler(menuopt.key, actionInvoke)() 239 | } 240 | 241 | displayWidth := len(display) + len(keybinding) + 6 242 | if displayWidth > width { 243 | width = displayWidth 244 | } 245 | 246 | modal.table.SetCell(index-skipped, 0, tview.NewTableCell(display). 247 | SetExpansion(1). 248 | SetReference(&menuopt). 249 | SetAlign(tview.AlignLeft). 250 | SetTextColor(theme.GetColor(theme.ThemeMenuItem)). 251 | SetClickedFunc(clickedFunc). 252 | SetSelectedStyle(tcell.Style{}. 253 | Foreground(theme.GetColor(theme.ThemeMenuItem)). 254 | Background(theme.BackgroundColor(theme.ThemeMenuItem)), 255 | ), 256 | ) 257 | modal.table.SetCell(index-skipped, 1, tview.NewTableCell(keybinding). 258 | SetExpansion(1). 259 | SetAlign(tview.AlignRight). 260 | SetClickedFunc(clickedFunc). 261 | SetTextColor(theme.GetColor(theme.ThemeMenuItem)). 262 | SetSelectedStyle(tcell.Style{}. 263 | Foreground(theme.GetColor(theme.ThemeMenuItem)). 264 | Background(theme.BackgroundColor(theme.ThemeMenuItem)), 265 | ), 266 | ) 267 | } 268 | 269 | modal.table.Select(0, 0) 270 | 271 | m.drawSubMenu(x, y, width, device != nil) 272 | } 273 | 274 | // drawContextMenu draws a context menu (for example, on right-clicking a device in the devices view). 275 | func (m *menuBarView) drawContextMenu( 276 | menuID string, 277 | selected func(table *tview.Table), 278 | changed func(table *tview.Table, row, col int), 279 | listContents func(table *tview.Table) (int, int), 280 | ) { 281 | var changeEnabled bool 282 | 283 | x, y := 0, 1 284 | 285 | modal := m.modal 286 | modal.table.Clear() 287 | modal.table.SetSelectorWrap(false) 288 | 289 | width, index := listContents(modal.table) 290 | if width < 0 && index < 0 { 291 | return 292 | } 293 | 294 | modal.table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 295 | switch m.kb.Key(event) { 296 | case keybindings.KeySelect: 297 | if selected != nil { 298 | selected(modal.table) 299 | } 300 | 301 | fallthrough 302 | 303 | case keybindings.KeyClose: 304 | m.exit() 305 | } 306 | 307 | return ignoreDefaultEvent(event) 308 | }) 309 | modal.table.SetSelectionChangedFunc(func(row, col int) { 310 | if changed == nil { 311 | return 312 | } 313 | 314 | if !changeEnabled { 315 | changeEnabled = true 316 | return 317 | } 318 | 319 | changed(modal.table, row, col) 320 | }) 321 | modal.table.SetMouseCapture(func(action tview.MouseAction, event *tcell.EventMouse) (tview.MouseAction, *tcell.EventMouse) { 322 | if action == tview.MouseLeftClick && modal.table.InRect(event.Position()) { 323 | m.exit() 324 | } 325 | 326 | return action, event 327 | }) 328 | 329 | modal.name = menuID 330 | modal.table.Select(index, 0) 331 | m.drawSubMenu(x, y, width+20, menuID == menuDeviceName.String()) 332 | } 333 | 334 | // drawSubMenu draws the list of menu items for a highlighted menu. 335 | func (m *menuBarView) drawSubMenu(x, y, width int, device bool) { 336 | if m.modal.isOpen { 337 | m.exit(struct{}{}) 338 | } 339 | 340 | if device { 341 | _, _, _, tableHeight := m.device.table.GetInnerRect() 342 | deviceX, deviceY := getSelectionXY(m.device.table) 343 | 344 | x = deviceX + 10 345 | if deviceY >= tableHeight-6 { 346 | y = deviceY - m.modal.table.GetRowCount() 347 | } else { 348 | y = deviceY + 1 349 | } 350 | } 351 | 352 | m.modal.height = m.modal.table.GetRowCount() + 2 353 | m.modal.width = width 354 | 355 | m.modal.regionX = x 356 | m.modal.regionY = y 357 | 358 | m.modal.show() 359 | } 360 | 361 | // setHeader appends the header text with the menu bar's regions. 362 | func (m *menuBarView) setHeader(header string) { 363 | m.bar.SetText(header + "[-:-:-] " + theme.ColorWrap(theme.ThemeMenu, menuBarRegions)) 364 | } 365 | 366 | // next switches between menus. 367 | func (m *menuBarView) next() { 368 | highlighted := m.bar.GetHighlights() 369 | if highlighted == nil { 370 | return 371 | } 372 | 373 | for _, region := range m.bar.GetRegionIDs() { 374 | if highlighted[0] != region { 375 | m.bar.Highlight(region) 376 | } 377 | } 378 | } 379 | 380 | // exit exits the menu. 381 | func (m *menuBarView) exit(highlight ...struct{}) { 382 | m.modal.remove(false) 383 | 384 | if highlight == nil { 385 | m.modal.name = menuBarName.String() 386 | m.bar.Highlight("") 387 | m.adapter.topAdapterName.Highlight("") 388 | } 389 | 390 | m.app.FocusPrimitive(m.device.table) 391 | } 392 | 393 | // inputHandler handles key events for a submenu. 394 | func (m *menuBarView) inputHandler(event *tcell.EventKey) bool { 395 | key := m.kb.Key(event, m.pages.currentContext(), keybindings.ContextProgress) 396 | if key == "" { 397 | return false 398 | } 399 | 400 | option, ok := m.optionByKey[key] 401 | if !ok { 402 | return false 403 | } 404 | 405 | if option.checkVisibility && !m.actions.handler(key, actionVisibility)() { 406 | return ok 407 | } 408 | 409 | m.actions.handler(key, actionInvoke)() 410 | 411 | return ok 412 | } 413 | 414 | // initOrderedOptions initializes and stores the ordered menu options. 415 | func (m *menuBarView) initOrderedOptions() { 416 | m.menuOptions = map[string][]menuOption{ 417 | menuAdapterName.String(): { 418 | { 419 | key: keybindings.KeyAdapterTogglePower, 420 | enabledText: "On", 421 | disabledText: "Off", 422 | initBeforeInvoke: true, 423 | }, 424 | { 425 | key: keybindings.KeyAdapterToggleDiscoverable, 426 | enabledText: "On", 427 | disabledText: "Off", 428 | initBeforeInvoke: true, 429 | }, 430 | { 431 | key: keybindings.KeyAdapterTogglePairable, 432 | enabledText: "On", 433 | disabledText: "Off", 434 | initBeforeInvoke: true, 435 | }, 436 | { 437 | key: keybindings.KeyAdapterToggleScan, 438 | disabledText: "Stop Scan", 439 | }, 440 | { 441 | key: keybindings.KeyAdapterChange, 442 | }, 443 | { 444 | key: keybindings.KeyProgressView, 445 | }, 446 | { 447 | key: keybindings.KeyPlayerHide, 448 | }, 449 | { 450 | key: keybindings.KeyQuit, 451 | }, 452 | }, 453 | menuDeviceName.String(): { 454 | { 455 | key: keybindings.KeyDeviceConnect, 456 | disabledText: "Disconnect", 457 | initBeforeInvoke: true, 458 | }, 459 | { 460 | key: keybindings.KeyDevicePair, 461 | }, 462 | { 463 | key: keybindings.KeyDeviceTrust, 464 | disabledText: "Untrust", 465 | initBeforeInvoke: true, 466 | }, 467 | { 468 | key: keybindings.KeyDeviceBlock, 469 | disabledText: "Unblock", 470 | initBeforeInvoke: true, 471 | }, 472 | { 473 | key: keybindings.KeyDeviceSendFiles, 474 | checkVisibility: true, 475 | }, 476 | { 477 | key: keybindings.KeyDeviceNetwork, 478 | checkVisibility: true, 479 | }, 480 | { 481 | key: keybindings.KeyDeviceAudioProfiles, 482 | checkVisibility: true, 483 | }, 484 | { 485 | key: keybindings.KeyPlayerShow, 486 | checkVisibility: true, 487 | }, 488 | { 489 | key: keybindings.KeyDeviceInfo, 490 | }, 491 | { 492 | key: keybindings.KeyDeviceRemove, 493 | }, 494 | }, 495 | } 496 | } 497 | -------------------------------------------------------------------------------- /ui/app/views/modalsview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/darkhz/bluetuith/ui/keybindings" 7 | "github.com/darkhz/bluetuith/ui/theme" 8 | "github.com/darkhz/tview" 9 | "github.com/gdamore/tcell/v2" 10 | ) 11 | 12 | // modalViews holds all the displayed modals on the screen. 13 | type modalViews struct { 14 | modals []*modalView 15 | 16 | rv *Views 17 | } 18 | 19 | // Initialize initializes the modals view. 20 | func (m *modalViews) Initialize() error { 21 | m.modals = make([]*modalView, 0, 10) 22 | 23 | return nil 24 | } 25 | 26 | // SetRootView sets the root view of the modals view. 27 | func (m *modalViews) SetRootView(v *Views) { 28 | m.rv = v 29 | } 30 | 31 | // newModal returns a modal. 32 | func (m *modalViews) newModal(name, title string, item tview.Primitive, height, width int) *modalView { 33 | var modal *modalView 34 | 35 | box := tview.NewBox() 36 | box.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 37 | 38 | modalTitle := tview.NewTextView() 39 | modalTitle.SetDynamicColors(true) 40 | modalTitle.SetText("[::bu]" + title) 41 | modalTitle.SetTextAlign(tview.AlignCenter) 42 | modalTitle.SetTextColor(theme.GetColor(theme.ThemeText)) 43 | modalTitle.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 44 | 45 | closeButton := tview.NewTextView() 46 | closeButton.SetRegions(true) 47 | closeButton.SetDynamicColors(true) 48 | closeButton.SetText(`["close"][::b][X[]`) 49 | closeButton.SetTextAlign(tview.AlignRight) 50 | closeButton.SetTextColor(theme.GetColor(theme.ThemeText)) 51 | closeButton.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 52 | closeButton.SetHighlightedFunc(func(added, _, _ []string) { 53 | if added == nil { 54 | return 55 | } 56 | 57 | modal.remove(false) 58 | }) 59 | 60 | titleFlex := tview.NewFlex(). 61 | SetDirection(tview.FlexColumn). 62 | AddItem(box, 0, 1, false). 63 | AddItem(modalTitle, 0, 10, false). 64 | AddItem(closeButton, 0, 1, false) 65 | 66 | flex := tview.NewFlex() 67 | flex.SetBorder(true) 68 | flex.SetDirection(tview.FlexRow) 69 | 70 | flex.AddItem(titleFlex, 1, 0, false) 71 | flex.AddItem(horizontalLine(), 1, 0, false) 72 | flex.AddItem(item, 0, 1, true) 73 | flex.SetBorderColor(theme.GetColor(theme.ThemeBorder)) 74 | flex.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 75 | 76 | modal = &modalView{ 77 | name: name, 78 | flex: flex, 79 | 80 | height: height, 81 | width: width, 82 | 83 | closeButton: closeButton, 84 | mgr: m, 85 | } 86 | 87 | return modal 88 | } 89 | 90 | // newModalWithTable returns a new modal with an embedded table. 91 | func (m *modalViews) newModalWithTable(name, title string, height, width int) *tableModalView { 92 | var modal *modalView 93 | 94 | table := tview.NewTable() 95 | table.SetSelectorWrap(true) 96 | table.SetSelectable(true, false) 97 | table.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 98 | table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 99 | if m.rv.kb.Key(event) == keybindings.KeyClose { 100 | modal.remove(false) 101 | } 102 | 103 | return ignoreDefaultEvent(event) 104 | }) 105 | 106 | modal = m.newModal(name, title, table, height, width) 107 | 108 | return &tableModalView{ 109 | table: table, 110 | modalView: modal, 111 | } 112 | } 113 | 114 | // newMenuModal returns a menu modal. 115 | func (m *modalViews) newMenuModal(name string, regionX, regionY int) *tableModalView { 116 | table := tview.NewTable() 117 | table.SetBorder(true) 118 | table.SetSelectable(true, false) 119 | table.SetBackgroundColor(tcell.ColorDefault) 120 | table.SetBorderColor(theme.GetColor(theme.ThemeBorder)) 121 | table.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 122 | 123 | flex := tview.NewFlex(). 124 | SetDirection(tview.FlexRow). 125 | AddItem(table, 0, 1, true) 126 | 127 | return &tableModalView{ 128 | table: table, 129 | modalView: &modalView{ 130 | name: name, 131 | flex: flex, 132 | 133 | isMenu: true, 134 | regionX: regionX, 135 | regionY: regionY, 136 | mgr: m, 137 | }, 138 | } 139 | 140 | } 141 | 142 | // newDisplayModal returns a display modal. 143 | func (m *modalViews) newDisplayModal(name, title, message string) *displayModalView { 144 | message += "\n\nPress any key or click the 'X' button to close this dialog." 145 | 146 | width, height := m.getModalDimensions(message, "") 147 | 148 | textview := tview.NewTextView() 149 | textview.SetText(message) 150 | textview.SetDynamicColors(true) 151 | textview.SetTextAlign(tview.AlignCenter) 152 | textview.SetTextColor(theme.GetColor(theme.ThemeText)) 153 | textview.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 154 | 155 | return &displayModalView{ 156 | textview: textview, 157 | modalView: m.newModal(name, title, textview, width, height), 158 | } 159 | } 160 | 161 | // newConfirmModal returns a confirmation modal. 162 | func (m *modalViews) newConfirmModal(name, title, message string) *confirmModalView { 163 | message += "\n\nPress y/n to Confirm/Cancel, click the required button or click the 'X' button to close this dialog." 164 | buttonsText := `["confirm"][::b][Confirm[] ["cancel"][::b][Cancel[]` 165 | 166 | width, height := m.getModalDimensions(message, buttonsText) 167 | 168 | buttons := tview.NewTextView() 169 | buttons.SetRegions(true) 170 | buttons.SetText(buttonsText) 171 | buttons.SetDynamicColors(true) 172 | buttons.SetTextAlign(tview.AlignCenter) 173 | buttons.SetTextColor(theme.GetColor(theme.ThemeText)) 174 | buttons.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 175 | 176 | textview := tview.NewTextView() 177 | textview.SetText(message) 178 | textview.SetDynamicColors(true) 179 | textview.SetTextAlign(tview.AlignCenter) 180 | textview.SetTextColor(theme.GetColor(theme.ThemeText)) 181 | textview.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 182 | 183 | flex := tview.NewFlex(). 184 | SetDirection(tview.FlexRow). 185 | AddItem(textview, 0, 1, false). 186 | AddItem(buttons, 1, 0, true) 187 | 188 | return &confirmModalView{ 189 | textview: textview, 190 | buttons: buttons, 191 | modalView: m.newModal(name, title, flex, height, width), 192 | } 193 | } 194 | 195 | // isModalDisplayed checks if the specified modal is displayed. 196 | func (m *modalViews) isModalDisplayed(modalName string) bool { 197 | for _, modal := range m.modals { 198 | if modal.name == modalName { 199 | return true 200 | } 201 | } 202 | 203 | return false 204 | } 205 | 206 | // removeModal removes the specified modal from the screen. 207 | func (m *modalViews) removeModal(currentModal *modalView, focusInput bool) { 208 | m.rv.pages.RemovePage(currentModal.name) 209 | 210 | for i, modal := range m.modals { 211 | if modal == currentModal { 212 | m.modals[i] = m.modals[len(m.modals)-1] 213 | m.modals = m.modals[:len(m.modals)-1] 214 | 215 | break 216 | } 217 | } 218 | 219 | if focusInput { 220 | m.rv.app.FocusPrimitive(m.rv.status.InputField) 221 | return 222 | } 223 | 224 | m.setPrimaryFocus() 225 | } 226 | 227 | // displayModal displays the specified modal to the screen. 228 | func (m *modalViews) displayModal(modal *modalView) { 229 | m.rv.pages.AddAndSwitchToPage(modal.name, modal.x, true) 230 | for _, modal := range m.modals { 231 | m.rv.pages.ShowPage(modal.name) 232 | } 233 | m.rv.pages.ShowPage(m.rv.pages.currentPage()) 234 | m.rv.app.FocusPrimitive(modal.flex) 235 | 236 | m.modals = append(m.modals, modal) 237 | 238 | m.resizeModal() 239 | } 240 | 241 | // resizeModal resizes all the displayed modals according to the current screen dimensions. 242 | func (m *modalViews) resizeModal() { 243 | var drawn bool 244 | 245 | for _, modal := range m.modals { 246 | _, _, pageWidth, pageHeight := m.rv.layout.GetInnerRect() 247 | 248 | if modal == nil || !modal.isOpen || 249 | (modal.pageHeight == pageHeight && modal.pageWidth == pageWidth) { 250 | continue 251 | } 252 | 253 | modal.pageHeight = pageHeight 254 | modal.pageWidth = pageWidth 255 | 256 | height := modal.height 257 | width := modal.width 258 | if height >= pageHeight { 259 | height = pageHeight 260 | } 261 | if width >= pageWidth { 262 | width = pageWidth 263 | } 264 | 265 | var x, y int 266 | 267 | if modal.isMenu { 268 | x, y = modal.regionX, modal.regionY 269 | } else { 270 | x = (pageWidth - modal.width) / 2 271 | y = (pageHeight - modal.height) / 2 272 | } 273 | 274 | modal.y.ResizeItem(modal.flex, height, 0) 275 | modal.y.ResizeItem(nil, y, 0) 276 | 277 | modal.x.ResizeItem(modal.y, width, 0) 278 | modal.x.ResizeItem(nil, x, 0) 279 | 280 | drawn = true 281 | } 282 | 283 | if drawn { 284 | go m.rv.app.Refresh() 285 | } 286 | } 287 | 288 | // setPrimaryFocus sets the focus to the appropriate primitive. 289 | func (m *modalViews) setPrimaryFocus() { 290 | if pg, _ := m.rv.status.GetFrontPage(); pg == statusInputPage.String() { 291 | m.rv.app.FocusPrimitive(m.rv.status.InputField) 292 | return 293 | } 294 | 295 | if len(m.modals) > 0 { 296 | m.rv.app.FocusPrimitive(m.modals[len(m.modals)-1].flex) 297 | return 298 | } 299 | 300 | m.rv.app.FocusPrimitive(m.rv.pages) 301 | } 302 | 303 | // getModal returns whether the getModal with the given name is displayed. 304 | func (m *modalViews) getModal(name string) (*modalView, bool) { 305 | for _, modal := range m.modals { 306 | if modal.name == name { 307 | return modal, true 308 | } 309 | } 310 | 311 | return nil, false 312 | } 313 | 314 | // modalMouseHandler handles mouse events for a modal. 315 | func (m *modalViews) modalMouseHandler(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction) { 316 | for _, modal := range m.modals { 317 | if modal == nil || !modal.isOpen { 318 | continue 319 | } 320 | 321 | x, y := event.Position() 322 | 323 | switch action { 324 | case tview.MouseRightClick: 325 | if m.rv.menu.bar.InRect(x, y) { 326 | return nil, action 327 | } 328 | 329 | case tview.MouseLeftClick: 330 | if modal.flex.InRect(x, y) { 331 | m.rv.app.FocusPrimitive(modal.flex) 332 | } else { 333 | if modal.isMenu { 334 | m.rv.menu.exit() 335 | continue 336 | } 337 | 338 | modal.remove(false) 339 | } 340 | } 341 | } 342 | 343 | return event, action 344 | } 345 | 346 | // getModalDimensions returns the height and width to set for the modal 347 | // according to the provided text. 348 | // Adapted from: https://github.com/rivo/tview/blob/1b91b8131c43011d923fe59855b4de3571dac997/modal.go#L156 349 | func (m *modalViews) getModalDimensions(text, buttons string) (width int, height int) { 350 | _, _, screenWidth, _ := m.rv.pages.GetRect() 351 | buttonWidth := tview.TaggedStringWidth(buttons) - 2 352 | 353 | width = max(screenWidth/3, buttonWidth) 354 | 355 | padding := 6 356 | if buttonWidth < 0 { 357 | padding -= 2 358 | } 359 | 360 | return width + 4, len(tview.WordWrap(text, width)) + padding 361 | } 362 | 363 | // modalView stores a layout to display a floating modal. 364 | type modalView struct { 365 | name string 366 | isOpen, isMenu bool 367 | 368 | height, width int 369 | pageHeight, pageWidth int 370 | 371 | y, x *tview.Flex 372 | regionX, regionY int 373 | 374 | flex *tview.Flex 375 | closeButton *tview.TextView 376 | 377 | mgr *modalViews 378 | } 379 | 380 | // show shows the modal onto the screen. 381 | func (m *modalView) show() { 382 | var x, y, xprop, xattach int 383 | 384 | if m.mgr.isModalDisplayed(m.name) { 385 | return 386 | } 387 | 388 | switch { 389 | case m.isMenu: 390 | xprop = 1 391 | x, y = m.regionX, m.regionY 392 | 393 | default: 394 | xattach = 1 395 | } 396 | 397 | m.isOpen = true 398 | 399 | m.y = tview.NewFlex(). 400 | SetDirection(tview.FlexRow). 401 | AddItem(nil, y, 0, false). 402 | AddItem(m.flex, m.height, 0, true). 403 | AddItem(nil, 1, 0, false) 404 | 405 | m.x = tview.NewFlex(). 406 | SetDirection(tview.FlexColumn). 407 | AddItem(nil, x, xattach, false). 408 | AddItem(m.y, m.width, 0, true). 409 | AddItem(nil, xprop, xattach, false) 410 | 411 | m.mgr.displayModal(m) 412 | } 413 | 414 | // remove removes the modal from the screen. 415 | func (m *modalView) remove(focusInput bool) { 416 | if m == nil { 417 | return 418 | } 419 | 420 | m.isOpen = false 421 | m.pageWidth = 0 422 | m.pageHeight = 0 423 | 424 | m.mgr.removeModal(m, focusInput) 425 | } 426 | 427 | // tableModalView holds a modal view with an embedded table. 428 | type tableModalView struct { 429 | table *tview.Table 430 | 431 | *modalView 432 | } 433 | 434 | // textModalView holds a modal with an embedded textview and buttons. 435 | type textModalView struct { 436 | textview *tview.TextView 437 | buttons *tview.TextView 438 | 439 | *modalView 440 | } 441 | 442 | // displayModalView holds a modal which only displays certain text. 443 | type displayModalView textModalView 444 | 445 | // display displays the modal, and waits for the provided context to be completed or a user input 446 | // to close the modal. 447 | func (d *displayModalView) display(ctx context.Context) { 448 | reply := make(chan struct{}, 1) 449 | 450 | d.textview.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 451 | d.remove(false) 452 | 453 | return event 454 | }) 455 | 456 | go d.mgr.rv.app.QueueDraw(func() { 457 | if m, ok := d.mgr.getModal(d.name); ok { 458 | m.remove(false) 459 | } 460 | 461 | d.show() 462 | }) 463 | 464 | select { 465 | case <-ctx.Done(): 466 | d.closeButton.InputHandler()(nil, nil) 467 | 468 | case <-reply: 469 | d.closeButton.InputHandler()(nil, nil) 470 | } 471 | } 472 | 473 | // confirmModalView holds a modal which displays a message and asks for confirmation from the user. 474 | type confirmModalView textModalView 475 | 476 | // getReply displays a modal, and waits for the user to confirm or the provided context to be completed 477 | // to close the modal. 478 | func (c *confirmModalView) getReply(ctx context.Context) string { 479 | reply := make(chan string, 1) 480 | 481 | send := func(msg string) { 482 | c.remove(false) 483 | 484 | reply <- msg 485 | } 486 | 487 | c.buttons.SetHighlightedFunc(func(added, _, _ []string) { 488 | if added == nil { 489 | return 490 | } 491 | 492 | switch added[0] { 493 | case "confirm": 494 | send("y") 495 | 496 | case "cancel": 497 | send("n") 498 | } 499 | }) 500 | c.buttons.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 501 | switch event.Rune() { 502 | case 'y', 'n': 503 | send(string(event.Rune())) 504 | } 505 | 506 | if c.mgr.rv.kb.Key(event) == keybindings.KeyClose { 507 | send("n") 508 | } 509 | 510 | return event 511 | }) 512 | c.closeButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 513 | send("n") 514 | return event 515 | }) 516 | 517 | go c.mgr.rv.app.QueueDraw(func() { 518 | if m, ok := c.mgr.getModal(c.name); ok { 519 | m.remove(false) 520 | } 521 | c.show() 522 | }) 523 | 524 | select { 525 | case <-ctx.Done(): 526 | c.closeButton.InputHandler()(nil, nil) 527 | 528 | case r := <-reply: 529 | c.closeButton.InputHandler()(nil, nil) 530 | return r 531 | } 532 | 533 | return "" 534 | } 535 | -------------------------------------------------------------------------------- /ui/app/views/playerview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "errors" 5 | "math/bits" 6 | "strconv" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "go.uber.org/atomic" 12 | 13 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 14 | "github.com/darkhz/tview" 15 | "github.com/gdamore/tcell/v2" 16 | 17 | "github.com/darkhz/bluetuith/ui/keybindings" 18 | "github.com/darkhz/bluetuith/ui/theme" 19 | ) 20 | 21 | // mediaPlayer holds the media player view. 22 | type mediaPlayer struct { 23 | isSupported atomic.Bool 24 | isOpen atomic.Bool 25 | skip atomic.Bool 26 | 27 | keyEvent chan string 28 | stopEvent, buttonEvent chan struct{} 29 | 30 | currentMedia bluetooth.MediaPlayer 31 | address bluetooth.MacAddress 32 | 33 | *Views 34 | 35 | sync.Mutex 36 | } 37 | 38 | // playerElements holds the individual player view display elements. 39 | type playerElements struct { 40 | player *tview.Flex 41 | info, title, progress, track, buttons *tview.TextView 42 | } 43 | 44 | // Initialize initializes the media player. 45 | func (m *mediaPlayer) Initialize() error { 46 | m.isSupported.Store(true) 47 | 48 | m.stopEvent = make(chan struct{}) 49 | m.keyEvent = make(chan string, 1) 50 | m.buttonEvent = make(chan struct{}, 1) 51 | 52 | return nil 53 | } 54 | 55 | // SetRootView sets the root view for the media player. 56 | func (m *mediaPlayer) SetRootView(v *Views) { 57 | m.Views = v 58 | } 59 | 60 | // show shows the media player. 61 | func (m *mediaPlayer) show() { 62 | if !m.isSupported.Load() { 63 | m.status.ErrorMessage(errors.New("this operation is not supported")) 64 | return 65 | } 66 | 67 | if m.isOpen.Load() { 68 | return 69 | } 70 | 71 | m.Lock() 72 | defer m.Unlock() 73 | 74 | device := m.device.getSelection(false) 75 | if device.Address.IsNil() { 76 | return 77 | } 78 | 79 | m.currentMedia = m.app.Session().MediaPlayer(device.Address) 80 | properties, err := m.currentMedia.Properties() 81 | if err != nil { 82 | m.status.ErrorMessage(err) 83 | return 84 | } 85 | 86 | m.address = device.Address 87 | 88 | go m.updateLoop(device, properties) 89 | } 90 | 91 | // close closes the media player. 92 | func (m *mediaPlayer) close() { 93 | if !m.isSupported.Load() || !m.isOpen.Load() { 94 | return 95 | } 96 | 97 | select { 98 | case <-m.stopEvent: 99 | 100 | case m.stopEvent <- struct{}{}: 101 | 102 | default: 103 | } 104 | } 105 | 106 | // closeForDevice closes the media player for the specific device. 107 | func (m *mediaPlayer) closeForDevice(deviceAddress bluetooth.MacAddress) { 108 | if !m.isSupported.Load() || !m.isOpen.Load() || m.address != deviceAddress { 109 | return 110 | } 111 | 112 | select { 113 | case <-m.stopEvent: 114 | 115 | case m.stopEvent <- struct{}{}: 116 | 117 | default: 118 | } 119 | } 120 | 121 | // renderProgress renders the player progress bar. 122 | func (m *mediaPlayer) renderProgress(progressView *tview.TextView, media bluetooth.MediaData) { 123 | var length int 124 | 125 | _, _, width, _ := m.pages.GetRect() 126 | position := media.Position 127 | duration := media.Duration 128 | 129 | width /= 2 130 | if position >= duration { 131 | position = duration 132 | } 133 | 134 | if duration <= 0 { 135 | length = 0 136 | } else { 137 | length = width * int(position) / int(duration) 138 | } 139 | 140 | endlength := width - length 141 | if endlength < 0 { 142 | endlength = width 143 | } 144 | 145 | var sb strings.Builder 146 | 147 | sb.WriteString(" ") 148 | sb.WriteString(formatDuration(position)) 149 | sb.WriteString(" |") 150 | sb.WriteString(strings.Repeat("█", length)) 151 | sb.WriteString(strings.Repeat(" ", endlength)) 152 | sb.WriteString("| ") 153 | sb.WriteString(formatDuration(duration)) 154 | 155 | progressView.SetText(sb.String()) 156 | } 157 | 158 | // renderButtons renders the player buttons. 159 | func (m *mediaPlayer) renderButtons(buttonsView *tview.TextView, mediaStatus bluetooth.MediaStatus, skip bool) { 160 | const ( 161 | mediaLeftButtons = `["rewind"][::b]<<[""] ["prev"][::b]<[""] ["play"][::b]` 162 | mediaRightButtons = `[""] ["next"][::b]>[""] ["fastforward"][::b]>>[""]` 163 | ) 164 | 165 | button := "|>" 166 | 167 | if !skip { 168 | switch mediaStatus { 169 | case bluetooth.MediaPlaying: 170 | button = "||" 171 | 172 | case bluetooth.MediaPaused: 173 | button = "|>" 174 | 175 | case bluetooth.MediaStopped: 176 | button = "[]" 177 | } 178 | } 179 | 180 | var sb strings.Builder 181 | 182 | sb.WriteString(mediaLeftButtons) 183 | sb.WriteString(button) 184 | sb.WriteString(mediaRightButtons) 185 | 186 | buttonsView.SetText(sb.String()) 187 | } 188 | 189 | // renderTrackData renders the track details. 190 | func (m *mediaPlayer) renderTrackData(infoView, titleView, trackView *tview.TextView, trackData bluetooth.TrackData) { 191 | number := strconv.FormatUint(uint64(trackData.TrackNumber), 10) 192 | total := strconv.FormatUint(uint64(trackData.TotalTracks), 10) 193 | 194 | track := "Track " + number + "/" + total 195 | 196 | titleView.SetText(trackData.Title) 197 | infoView.SetText(trackData.Artist + " - " + trackData.Album) 198 | trackView.SetText(track) 199 | } 200 | 201 | // renderPlayer renders the entire media player. 202 | func (m *mediaPlayer) renderPlayer(cached bluetooth.MediaData, elements playerElements, track, progress, buttons bool) bool { 203 | if track { 204 | m.renderTrackData(elements.info, elements.title, elements.track, cached.TrackData) 205 | } 206 | 207 | if progress { 208 | m.renderProgress(elements.progress, cached) 209 | } 210 | 211 | if buttons { 212 | m.renderButtons(elements.buttons, cached.Status, m.skip.Load()) 213 | } 214 | 215 | return track || progress || buttons 216 | } 217 | 218 | // updateLoop updates the media player. 219 | func (m *mediaPlayer) updateLoop(device bluetooth.DeviceData, props bluetooth.MediaData) { 220 | mediaSub, ok := bluetooth.MediaEvents().Subscribe() 221 | if !ok { 222 | return 223 | } 224 | defer mediaSub.Unsubscribe() 225 | 226 | elements := m.setup(device.Name) 227 | go m.app.QueueDraw(func() { 228 | m.help.swapStatusHelp(elements.player, true) 229 | }) 230 | defer m.app.QueueDraw(func() { 231 | m.help.swapStatusHelp(elements.player, false) 232 | }) 233 | 234 | m.isOpen.Store(true) 235 | defer m.isOpen.Store(false) 236 | 237 | t := time.NewTicker(1 * time.Second) 238 | defer t.Stop() 239 | 240 | var cached bluetooth.MediaData 241 | var delta uint32 242 | 243 | cached = props 244 | if cached.Title == "" { 245 | cached.Title = "" 246 | } 247 | 248 | m.app.QueueDraw(func() { 249 | m.renderPlayer(props, elements, true, true, true) 250 | }) 251 | 252 | PlayerLoop: 253 | for { 254 | select { 255 | case <-m.stopEvent: 256 | break PlayerLoop 257 | 258 | case h := <-m.keyEvent: 259 | switch h { 260 | case "fastforward", "rewind": 261 | t.Reset(250 * time.Millisecond) 262 | default: 263 | t.Reset(1 * time.Second) 264 | } 265 | 266 | go m.app.QueueDraw(func() { 267 | elements.buttons.Highlight(h) 268 | }) 269 | 270 | case ev, ok := <-mediaSub.UpdatedEvents: 271 | if !ok { 272 | break PlayerLoop 273 | } 274 | 275 | var track, progress, buttons bool 276 | 277 | if ev.TrackData != (bluetooth.TrackData{}) && ev.TrackData != cached.TrackData { 278 | cached.TrackData = ev.TrackData 279 | track = true 280 | } 281 | 282 | if ev != (bluetooth.MediaData{}) && ev != cached { 283 | if ev.Status != "" && ev.Status != cached.Status { 284 | cached.Status = ev.Status 285 | buttons = true 286 | } 287 | 288 | if ev.Position > 0 { 289 | switch cached.Status { 290 | case bluetooth.MediaForwardSeek, bluetooth.MediaReverseSeek: 291 | if ev.Position > cached.Position { 292 | delta = ev.Position - cached.Position 293 | } else { 294 | delta = cached.Position - ev.Position 295 | } 296 | 297 | default: 298 | t.Reset(1 * time.Second) 299 | delta = 0 300 | } 301 | 302 | if ev.Position != cached.Position { 303 | cached.Position = ev.Position 304 | progress = true 305 | } 306 | } 307 | } 308 | 309 | data := cached 310 | m.app.QueueDraw(func() { 311 | if m.renderPlayer(data, elements, track, progress, buttons) { 312 | t.Reset(1 * time.Second) 313 | } 314 | }) 315 | 316 | case <-t.C: 317 | if cached.Position >= cached.Duration { 318 | continue 319 | } 320 | 321 | switch cached.Status { 322 | case bluetooth.MediaForwardSeek: 323 | t.Reset(250 * time.Millisecond) 324 | pos, c := bits.Add32(cached.Position, delta, 0) 325 | if c != 0 { 326 | cached.Position = 0 327 | break 328 | } 329 | cached.Position = pos 330 | 331 | case bluetooth.MediaReverseSeek: 332 | t.Reset(250 * time.Millisecond) 333 | pos, b := bits.Sub32(cached.Position, delta, 0) 334 | if b != 0 { 335 | cached.Position = 0 336 | break 337 | } 338 | cached.Position = pos 339 | 340 | case bluetooth.MediaPlaying: 341 | cached.Position += 1000 342 | 343 | default: 344 | continue 345 | } 346 | 347 | mediaData := cached 348 | m.app.QueueDraw(func() { 349 | m.renderProgress(elements.progress, mediaData) 350 | }) 351 | } 352 | } 353 | } 354 | 355 | // setup sets up the media player elements. 356 | func (m *mediaPlayer) setup(deviceName string) playerElements { 357 | info := tview.NewTextView() 358 | info.SetDynamicColors(true) 359 | info.SetTextAlign(tview.AlignCenter) 360 | info.SetTextColor(theme.GetColor(theme.ThemeText)) 361 | info.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 362 | 363 | title := tview.NewTextView() 364 | title.SetDynamicColors(true) 365 | title.SetTextAlign(tview.AlignCenter) 366 | title.SetTextColor(theme.GetColor(theme.ThemeText)) 367 | title.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 368 | 369 | progress := tview.NewTextView() 370 | progress.SetDynamicColors(true) 371 | progress.SetTextAlign(tview.AlignCenter) 372 | progress.SetTextColor(theme.GetColor(theme.ThemeText)) 373 | progress.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 374 | 375 | track := tview.NewTextView() 376 | track.SetDynamicColors(true) 377 | track.SetTextAlign(tview.AlignLeft) 378 | track.SetTextColor(theme.GetColor(theme.ThemeText)) 379 | track.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 380 | 381 | device := tview.NewTextView() 382 | device.SetText(deviceName) 383 | device.SetDynamicColors(true) 384 | device.SetTextAlign(tview.AlignRight) 385 | device.SetTextColor(theme.GetColor(theme.ThemeText)) 386 | device.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 387 | 388 | buttonNames := []string{ 389 | "play", 390 | "next", 391 | "prev", 392 | "fastforward", 393 | "rewind", 394 | } 395 | 396 | buttons := tview.NewTextView() 397 | buttons.SetRegions(true) 398 | buttons.SetDynamicColors(true) 399 | buttons.SetTextAlign(tview.AlignCenter) 400 | buttons.SetTextColor(theme.GetColor(theme.ThemeText)) 401 | buttons.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 402 | buttons.SetMouseCapture(func(action tview.MouseAction, event *tcell.EventMouse) (tview.MouseAction, *tcell.EventMouse) { 403 | if action != tview.MouseLeftClick { 404 | return action, event 405 | } 406 | 407 | var key keybindings.Key 408 | var region string 409 | 410 | x, y := event.Position() 411 | rectx, recty, _, _ := buttons.GetInnerRect() 412 | 413 | x -= rectx 414 | y -= recty 415 | 416 | for _, r := range buttonNames { 417 | start := buttons.GetRegionStart(r) 418 | if x == start || x == start+1 { 419 | region = r 420 | break 421 | } 422 | } 423 | if region == "" { 424 | return action, event 425 | } 426 | 427 | switch region { 428 | case "play": 429 | key = keybindings.KeyPlayerTogglePlay 430 | 431 | case "next": 432 | key = keybindings.KeyPlayerNext 433 | 434 | case "prev": 435 | key = keybindings.KeyPlayerPrevious 436 | 437 | case "fastforward": 438 | key = keybindings.KeyPlayerSeekForward 439 | 440 | case "rewind": 441 | key = keybindings.KeyPlayerSeekBackward 442 | 443 | default: 444 | return action, event 445 | } 446 | 447 | keyData := m.kb.Data(key) 448 | go m.keyEvents(tcell.NewEventKey(keyData.Kb.Key, keyData.Kb.Rune, keyData.Kb.Mod)) 449 | 450 | return action, event 451 | }) 452 | buttons.SetHighlightedFunc(func(added, _, _ []string) { 453 | if added == nil { 454 | return 455 | } 456 | 457 | if added[0] == "fastforward" || added[0] == "rewind" { 458 | return 459 | } 460 | 461 | go func() { 462 | time.Sleep(100 * time.Millisecond) 463 | m.app.QueueDraw(func() { 464 | buttons.Highlight() 465 | }) 466 | }() 467 | }) 468 | m.renderButtons(buttons, bluetooth.MediaPlaying, false) 469 | 470 | buttonFlex := tview.NewFlex(). 471 | SetDirection(tview.FlexColumn). 472 | AddItem(track, 0, 1, false). 473 | AddItem(nil, 1, 0, false). 474 | AddItem(buttons, 0, 1, false). 475 | AddItem(nil, 1, 0, false). 476 | AddItem(device, 0, 1, false) 477 | buttonFlex.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 478 | 479 | player := tview.NewFlex(). 480 | AddItem(nil, 1, 0, false). 481 | AddItem(title, 1, 0, false). 482 | AddItem(nil, 1, 0, false). 483 | AddItem(info, 1, 0, false). 484 | AddItem(nil, 1, 0, false). 485 | AddItem(progress, 1, 0, false). 486 | AddItem(nil, 1, 0, false). 487 | AddItem(buttonFlex, 1, 0, false). 488 | SetDirection(tview.FlexRow) 489 | player.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 490 | 491 | return playerElements{player, info, title, progress, track, buttons} 492 | } 493 | 494 | // keyEvents handles the media player events. 495 | func (m *mediaPlayer) keyEvents(event *tcell.EventKey) { 496 | if !m.isSupported.Load() || !m.isOpen.Load() { 497 | return 498 | } 499 | 500 | var nokey bool 501 | var highlight string 502 | 503 | switch m.kb.Key(event, keybindings.ContextDevice) { 504 | case keybindings.KeyPlayerSeekForward: 505 | highlight = "fastforward" 506 | m.currentMedia.FastForward() 507 | m.skip.Store(true) 508 | 509 | case keybindings.KeyPlayerSeekBackward: 510 | highlight = "rewind" 511 | m.currentMedia.Rewind() 512 | m.skip.Store(true) 513 | 514 | case keybindings.KeyPlayerPrevious: 515 | highlight = "prev" 516 | m.currentMedia.Previous() 517 | 518 | case keybindings.KeyPlayerNext: 519 | highlight = "next" 520 | m.currentMedia.Next() 521 | 522 | case keybindings.KeyPlayerStop: 523 | highlight = "play" 524 | m.currentMedia.Stop() 525 | 526 | case keybindings.KeyPlayerTogglePlay: 527 | highlight = "play" 528 | if m.skip.Load() { 529 | m.currentMedia.Play() 530 | m.skip.Store(false) 531 | 532 | break 533 | } 534 | 535 | m.currentMedia.TogglePlayPause() 536 | 537 | default: 538 | nokey = true 539 | } 540 | 541 | if !nokey { 542 | select { 543 | case m.keyEvent <- highlight: 544 | 545 | default: 546 | } 547 | } 548 | } 549 | 550 | // formatDuration converts a duration into a human-readable format. 551 | func formatDuration(duration uint32) string { 552 | var durationtext strings.Builder 553 | 554 | input, err := time.ParseDuration(strconv.FormatUint(uint64(duration), 10) + "ms") 555 | if err != nil { 556 | return "00:00" 557 | } 558 | 559 | d := input.Round(time.Second) 560 | 561 | h := d / time.Hour 562 | d -= h * time.Hour 563 | 564 | m := d / time.Minute 565 | d -= m * time.Minute 566 | 567 | s := d / time.Second 568 | 569 | if h > 0 { 570 | if h < 10 { 571 | durationtext.WriteString("0") 572 | } 573 | 574 | durationtext.WriteString(strconv.Itoa(int(h))) 575 | durationtext.WriteString(":") 576 | } 577 | 578 | if m > 0 { 579 | if m < 10 { 580 | durationtext.WriteString("0") 581 | } 582 | 583 | durationtext.WriteString(strconv.Itoa(int(m))) 584 | } else { 585 | durationtext.WriteString("00") 586 | } 587 | 588 | durationtext.WriteString(":") 589 | 590 | if s < 10 { 591 | durationtext.WriteString("0") 592 | } 593 | 594 | durationtext.WriteString(strconv.Itoa(int(s))) 595 | 596 | return durationtext.String() 597 | } 598 | -------------------------------------------------------------------------------- /ui/app/views/progressview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "slices" 9 | "strconv" 10 | "sync" 11 | "time" 12 | 13 | "go.uber.org/atomic" 14 | 15 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 16 | "github.com/darkhz/tview" 17 | "github.com/gdamore/tcell/v2" 18 | "github.com/puzpuzpuz/xsync/v3" 19 | "github.com/schollz/progressbar/v3" 20 | 21 | "github.com/darkhz/bluetuith/ui/keybindings" 22 | "github.com/darkhz/bluetuith/ui/theme" 23 | ) 24 | 25 | const progressPage viewName = "progressview" 26 | 27 | const progressViewButtonRegion = `["resume"][::b][Resume[][""] ["suspend"][::b][Pause[][""] ["cancel"][::b][Cancel[][""]` 28 | 29 | // progressView describes a file transfer progress display. 30 | type progressView struct { 31 | isSupported atomic.Bool 32 | 33 | view, statusProgress *tview.Table 34 | flex *tview.Flex 35 | 36 | total atomic.Uint32 37 | 38 | sessions *xsync.MapOf[bluetooth.MacAddress, *progressViewSession] 39 | 40 | *Views 41 | } 42 | 43 | type progressViewSession struct { 44 | sessionRemoved bool 45 | 46 | transferSession bluetooth.ObexObjectPush 47 | transfers map[bluetooth.ObjectPushTransferID]struct{} 48 | 49 | mu sync.Mutex 50 | } 51 | 52 | // progressIndicator describes a progress indicator, which will display 53 | // a description and a progress bar. 54 | type progressIndicator struct { 55 | desc *tview.TableCell 56 | progress *tview.TableCell 57 | progressBar *progressbar.ProgressBar 58 | 59 | recv, drawn bool 60 | status bluetooth.ObjectPushStatus 61 | 62 | deviceAddress bluetooth.MacAddress 63 | 64 | appDrawFunc func(func()) 65 | } 66 | 67 | func (p *progressView) Initialize() error { 68 | title := tview.NewTextView() 69 | title.SetDynamicColors(true) 70 | title.SetTextAlign(tview.AlignLeft) 71 | title.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 72 | title.SetText(theme.ColorWrap(theme.ThemeText, "Progress View", "::bu")) 73 | 74 | p.view = tview.NewTable() 75 | p.view.SetSelectable(true, false) 76 | p.view.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 77 | p.view.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 78 | switch p.kb.Key(event, keybindings.ContextProgress) { 79 | case keybindings.KeyClose: 80 | if p.status.HasPage(progressPage.String()) && p.total.Load() > 0 { 81 | p.status.SwitchToPage(progressPage.String()) 82 | } 83 | 84 | p.pages.SwitchToPage(devicePage.String()) 85 | 86 | case keybindings.KeyProgressTransferCancel: 87 | p.cancelTransfer() 88 | 89 | case keybindings.KeyProgressTransferSuspend: 90 | p.suspendTransfer() 91 | 92 | case keybindings.KeyProgressTransferResume: 93 | p.resumeTransfer() 94 | 95 | case keybindings.KeyQuit: 96 | go p.actions.quit() 97 | } 98 | 99 | return ignoreDefaultEvent(event) 100 | }) 101 | 102 | progressViewButtons := tview.NewTextView() 103 | progressViewButtons.SetRegions(true) 104 | progressViewButtons.SetDynamicColors(true) 105 | progressViewButtons.SetTextAlign(tview.AlignLeft) 106 | progressViewButtons.SetText(progressViewButtonRegion) 107 | progressViewButtons.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 108 | progressViewButtons.SetHighlightedFunc(func(added, _, _ []string) { 109 | if added == nil { 110 | return 111 | } 112 | 113 | if slices.Contains(progressViewButtons.GetRegionIDs(), added[0]) { 114 | switch added[0] { 115 | case "resume": 116 | p.resumeTransfer() 117 | 118 | case "suspend": 119 | p.suspendTransfer() 120 | 121 | case "cancel": 122 | p.cancelTransfer() 123 | } 124 | 125 | progressViewButtons.Highlight("") 126 | } 127 | }) 128 | 129 | p.flex = tview.NewFlex(). 130 | SetDirection(tview.FlexRow). 131 | AddItem(title, 1, 0, false). 132 | AddItem(p.view, 0, 10, true). 133 | AddItem(progressViewButtons, 2, 0, false) 134 | 135 | p.statusProgress = tview.NewTable() 136 | p.statusProgress.SetSelectable(true, true) 137 | p.statusProgress.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 138 | 139 | p.status.AddPage(progressPage.String(), p.statusProgress, true, false) 140 | 141 | p.isSupported.Store(true) 142 | p.sessions = xsync.NewMapOf[bluetooth.MacAddress, *progressViewSession]() 143 | 144 | go p.monitorTransfers() 145 | 146 | return nil 147 | } 148 | 149 | func (p *progressView) SetRootView(v *Views) { 150 | p.Views = v 151 | } 152 | 153 | // show displays the progress view. 154 | func (p *progressView) show() { 155 | if !p.isSupported.Load() { 156 | p.status.ErrorMessage(errors.New("this operation is not supported")) 157 | return 158 | } 159 | 160 | if pg, _ := p.status.GetFrontPage(); pg == progressPage.String() { 161 | p.status.SwitchToPage(statusMessagesPage.String()) 162 | } 163 | 164 | if p.total.Load() == 0 { 165 | p.status.InfoMessage("No transfers are in progress", false) 166 | return 167 | } 168 | 169 | p.pages.AddAndSwitchToPage(progressPage.String(), p.flex, true) 170 | } 171 | 172 | // showStatus displays the progress view in the status bar. 173 | func (p *progressView) showStatus() { 174 | if !p.isSupported.Load() { 175 | p.status.ErrorMessage(errors.New("this operation is not supported")) 176 | return 177 | } 178 | 179 | if pg, _ := p.pages.GetFrontPage(); pg != progressPage.String() { 180 | p.status.SwitchToPage(progressPage.String()) 181 | } 182 | } 183 | 184 | // newIndicator returns a new Progress. 185 | func (p *progressView) newIndicator(props bluetooth.ObjectPushData, recv bool) *progressIndicator { 186 | var progress progressIndicator 187 | var progressText string 188 | 189 | if recv { 190 | progressText = "Receiving" 191 | } else { 192 | progressText = "Sending" 193 | } 194 | 195 | p.total.Add(1) 196 | 197 | name := props.Name 198 | if name == "" && props.Filename != "" { 199 | name = filepath.Base(filepath.Clean(props.Filename)) 200 | } 201 | if props.Filename == "" && props.Name == "" { 202 | progressText = "" 203 | name = "Unknown file transfer" 204 | } 205 | 206 | title := fmt.Sprintf(" [::b]%s %s[-:-:-]", progressText, name) 207 | 208 | progress.recv = recv 209 | progress.deviceAddress = props.Address 210 | progress.appDrawFunc = p.app.QueueDraw 211 | 212 | progress.desc = tview.NewTableCell(title). 213 | SetExpansion(1). 214 | SetSelectable(false). 215 | SetAlign(tview.AlignLeft). 216 | SetTextColor(theme.GetColor(theme.ThemeProgressText)) 217 | 218 | progress.progress = tview.NewTableCell(""). 219 | SetExpansion(1). 220 | SetSelectable(false). 221 | SetReference(&progress). 222 | SetAlign(tview.AlignRight). 223 | SetTextColor(theme.GetColor(theme.ThemeProgressBar)) 224 | 225 | progress.progressBar = progressbar.NewOptions64( 226 | int64(props.Size), 227 | progressbar.OptionSpinnerType(34), 228 | progressbar.OptionSetWriter(&progress), 229 | progressbar.OptionSetPredictTime(false), 230 | progressbar.OptionSetRenderBlankState(true), 231 | progressbar.OptionThrottle(200*time.Millisecond), 232 | ) 233 | 234 | return &progress 235 | } 236 | 237 | // drawIndicator draws a progress indicator onto the screen. 238 | func (p *progressView) drawIndicator(progress *progressIndicator, props bluetooth.ObjectPushEventData) { 239 | if progress.drawn { 240 | return 241 | } 242 | 243 | count := p.total.Load() 244 | p.app.QueueDraw(func() { 245 | p.showStatus() 246 | 247 | rows := p.view.GetRowCount() 248 | 249 | p.statusProgress.SetCell(0, 0, progress.desc) 250 | p.statusProgress.SetCell(0, 1, progress.progress) 251 | 252 | p.view.SetCell(rows+1, 0, tview.NewTableCell("#"+strconv.FormatUint(uint64(count), 10)). 253 | SetReference(props). 254 | SetAlign(tview.AlignCenter), 255 | ) 256 | p.view.SetCell(rows+1, 1, progress.desc) 257 | p.view.SetCell(rows+1, 2, progress.progress) 258 | 259 | progress.drawn = true 260 | }) 261 | } 262 | 263 | // monitorTransfers monitors all incoming and outgoing Object Push transfers. 264 | func (p *progressView) monitorTransfers() { 265 | oppSub, ok := bluetooth.ObjectPushEvents().Subscribe() 266 | if !ok { 267 | return 268 | } 269 | defer oppSub.Unsubscribe() 270 | 271 | type transferProperty struct { 272 | indicator *progressIndicator 273 | 274 | bluetooth.ObjectPushData 275 | } 276 | 277 | sessionMap := map[bluetooth.ObjectPushSessionID]map[bluetooth.ObjectPushTransferID]*transferProperty{} 278 | 279 | getIndicator := func(ev bluetooth.ObjectPushEventData, remove bool) (*transferProperty, bool) { 280 | var prop *transferProperty 281 | 282 | tmap, ok := sessionMap[ev.SessionID] 283 | if !ok { 284 | return nil, false 285 | } 286 | 287 | prop, ok = tmap[ev.TransferID] 288 | if remove { 289 | if ok { 290 | delete(sessionMap[ev.SessionID], ev.TransferID) 291 | } 292 | 293 | if len(sessionMap[ev.SessionID]) == 0 { 294 | delete(sessionMap, ev.SessionID) 295 | } 296 | } 297 | 298 | if prop != nil { 299 | prop.ObjectPushEventData = ev 300 | } 301 | 302 | return prop, prop != nil 303 | } 304 | 305 | addIndicator := func(ev bluetooth.ObjectPushData) (*transferProperty, bool) { 306 | if ev.SessionID == "" || ev.TransferID == "" { 307 | return nil, false 308 | } 309 | 310 | indicator := p.newIndicator(ev, ev.Receiving) 311 | indicator.progressBar.Set64(int64(ev.Transferred)) 312 | property := &transferProperty{indicator: indicator, ObjectPushData: ev} 313 | 314 | tmap, ok := sessionMap[ev.SessionID] 315 | if !ok { 316 | sessionMap[ev.SessionID] = make(map[bluetooth.ObjectPushTransferID]*transferProperty) 317 | tmap = sessionMap[ev.SessionID] 318 | } 319 | 320 | tmap[ev.TransferID] = property 321 | sessionMap[ev.SessionID] = tmap 322 | 323 | return property, true 324 | } 325 | 326 | Transfer: 327 | for { 328 | select { 329 | case <-oppSub.Done: 330 | break Transfer 331 | 332 | case ev := <-oppSub.AddedEvents: 333 | _, _ = addIndicator(ev) 334 | 335 | case ev := <-oppSub.UpdatedEvents: 336 | property, ok := getIndicator(ev, false) 337 | if !ok { 338 | property, _ = addIndicator(bluetooth.ObjectPushData{ObjectPushEventData: ev}) 339 | } 340 | 341 | p.drawIndicator(property.indicator, property.ObjectPushEventData) 342 | property.indicator.progressBar.Set64(int64(ev.Transferred)) 343 | 344 | switch property.Status { 345 | case bluetooth.TransferError: 346 | p.status.ErrorMessage(fmt.Errorf("transfer could not be completed for %s", ev.Address.String())) 347 | fallthrough 348 | 349 | case bluetooth.TransferComplete: 350 | p.removeProgress(property.ObjectPushData) 351 | _, _ = getIndicator(ev, true) 352 | } 353 | 354 | case ev := <-oppSub.RemovedEvents: 355 | property, ok := getIndicator(ev, true) 356 | if ok { 357 | p.removeProgress(property.ObjectPushData) 358 | } 359 | } 360 | } 361 | } 362 | 363 | // startTransfer creates a new progress indicator, monitors the OBEX DBus interface for transfer events, 364 | // and displays the progress on the screen. If the optional path parameter is provided, it means that 365 | // a file is being received, and on transfer completion, the received file should be moved to a user-accessible 366 | // directory. 367 | func (p *progressView) startTransfer(address bluetooth.MacAddress, session bluetooth.ObexObjectPush, files []bluetooth.ObjectPushData) { 368 | psession := &progressViewSession{transferSession: session} 369 | psession.transfers = make(map[bluetooth.ObjectPushTransferID]struct{}) 370 | for _, f := range files { 371 | psession.transfers[f.TransferID] = struct{}{} 372 | } 373 | 374 | p.sessions.Store(address, psession) 375 | } 376 | 377 | // suspendTransfer suspends the transfer. 378 | // This does not work when a file is being received. 379 | func (p *progressView) suspendTransfer() { 380 | transferProps, progress := p.transferData() 381 | if transferProps.Address.IsNil() { 382 | return 383 | } 384 | 385 | if progress.recv { 386 | p.status.InfoMessage("Cannot suspend receiving transfer", false) 387 | return 388 | } 389 | 390 | p.app.Session().Obex(progress.deviceAddress).ObjectPush().SuspendTransfer() 391 | } 392 | 393 | // resumeTransfer resumes the transfer. 394 | // This does not work when a file is being received. 395 | func (p *progressView) resumeTransfer() { 396 | transferProps, progress := p.transferData() 397 | if transferProps.Address.IsNil() { 398 | return 399 | } 400 | 401 | if progress.recv { 402 | p.status.InfoMessage("Cannot resume receiving transfer", false) 403 | return 404 | } 405 | 406 | p.app.Session().Obex(progress.deviceAddress).ObjectPush().ResumeTransfer() 407 | } 408 | 409 | // cancelTransfer cancels the transfer. 410 | func (p *progressView) cancelTransfer() { 411 | transferProps, progress := p.transferData() 412 | if transferProps.Address.IsNil() { 413 | return 414 | } 415 | 416 | p.app.Session().Obex(progress.deviceAddress).ObjectPush().CancelTransfer() 417 | } 418 | 419 | // removeProgress removes the progress indicator from the screen. 420 | func (p *progressView) removeProgress(transferProps bluetooth.ObjectPushData) { 421 | p.total.Add(^uint32(0)) 422 | 423 | isComplete := transferProps.Status == bluetooth.TransferComplete 424 | path := transferProps.Filename 425 | 426 | if psession, ok := p.sessions.Load(transferProps.Address); ok { 427 | psession.mu.Lock() 428 | if !psession.sessionRemoved { 429 | delete(psession.transfers, transferProps.TransferID) 430 | 431 | if isComplete && len(psession.transfers) == 0 { 432 | psession.sessionRemoved = true 433 | 434 | if psession.transferSession != nil { 435 | go psession.transferSession.RemoveSession() 436 | } 437 | 438 | p.sessions.Delete(transferProps.Address) 439 | } 440 | } 441 | psession.mu.Unlock() 442 | } 443 | 444 | p.app.QueueDraw(func() { 445 | for row := range p.view.GetRowCount() { 446 | cell := p.view.GetCell(row, 0) 447 | if cell == nil { 448 | continue 449 | } 450 | 451 | props, ok := cell.GetReference().(bluetooth.ObjectPushEventData) 452 | if !ok { 453 | continue 454 | } 455 | 456 | if props.TransferID == transferProps.TransferID { 457 | p.view.RemoveRow(row) 458 | p.view.RemoveRow(row - 1) 459 | 460 | break 461 | } 462 | } 463 | 464 | if p.total.Load() == 0 { 465 | p.statusProgress.Clear() 466 | p.status.SwitchToPage(statusMessagesPage.String()) 467 | } 468 | }) 469 | 470 | if path != "" && isComplete && transferProps.Receiving { 471 | go func() { 472 | if err := savefile(path, p.cfg.Values.ReceiveDir); err != nil { 473 | p.status.ErrorMessage(err) 474 | } 475 | }() 476 | } 477 | } 478 | 479 | // transferData gets the file transfer properties and the progress data 480 | // from the current selection in the progress view. 481 | func (p *progressView) transferData() (bluetooth.ObjectPushEventData, *progressIndicator) { 482 | row, _ := p.view.GetSelection() 483 | 484 | pathCell := p.view.GetCell(row, 0) 485 | if pathCell == nil { 486 | return bluetooth.ObjectPushEventData{}, nil 487 | } 488 | 489 | progCell := p.view.GetCell(row, 2) 490 | if progCell == nil { 491 | return bluetooth.ObjectPushEventData{}, nil 492 | } 493 | 494 | props, ok := pathCell.GetReference().(bluetooth.ObjectPushEventData) 495 | if !ok { 496 | return bluetooth.ObjectPushEventData{}, nil 497 | } 498 | 499 | progress, ok := progCell.GetReference().(*progressIndicator) 500 | if !ok { 501 | return bluetooth.ObjectPushEventData{}, nil 502 | } 503 | 504 | return props, progress 505 | } 506 | 507 | // Write is used by the progressbar to display the progress on the screen. 508 | func (p *progressIndicator) Write(b []byte) (int, error) { 509 | p.appDrawFunc(func() { 510 | p.progress.SetText(string(b)) 511 | }) 512 | 513 | return 0, nil 514 | } 515 | 516 | // savefile moves a file from the obex cache to a specified user-accessible directory. 517 | // If the directory is not specified, it automatically creates a directory in the 518 | // user's home path and moves the file there. 519 | func savefile(path string, userpath string) error { 520 | if userpath == "" { 521 | homedir, err := os.UserHomeDir() 522 | if err != nil { 523 | return err 524 | } 525 | 526 | userpath = filepath.Join(homedir, "bluetuith") 527 | 528 | if _, err := os.Stat(userpath); err != nil { 529 | err = os.Mkdir(userpath, 0700) 530 | if err != nil { 531 | return err 532 | } 533 | } 534 | } 535 | 536 | return os.Rename(path, filepath.Join(userpath, filepath.Base(path))) 537 | } 538 | -------------------------------------------------------------------------------- /ui/app/views/filepickerview.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "strings" 11 | "sync" 12 | 13 | "go.uber.org/atomic" 14 | 15 | "slices" 16 | 17 | "github.com/darkhz/bluetuith/ui/keybindings" 18 | "github.com/darkhz/bluetuith/ui/theme" 19 | "github.com/darkhz/tview" 20 | "github.com/gdamore/tcell/v2" 21 | ) 22 | 23 | const filePickerPage viewName = "filepicker" 24 | 25 | // filePickerView holds the file picker view. 26 | type filePickerView struct { 27 | isSupported bool 28 | 29 | table *tview.Table 30 | title, buttons *tview.TextView 31 | pickerFlex *tview.Flex 32 | 33 | prevDir, currentPath string 34 | isHidden atomic.Bool 35 | 36 | listChan chan []string 37 | prevFileInfo fs.DirEntry 38 | 39 | selectedFiles map[string]fs.DirEntry 40 | selectMu sync.Mutex 41 | 42 | mu sync.Mutex 43 | 44 | *Views 45 | } 46 | 47 | const filePickButtonRegion = `["ok"][::b][OK[][""] ["cancel"][::b][Cancel[][""] ["hidden"][::b][Toggle hidden[][""] ["invert"][Invert selection[][""] ["all"][Select All[][""]` 48 | 49 | // Initialize initializes the file picker. 50 | func (f *filePickerView) Initialize() error { 51 | f.reset() 52 | 53 | infoTitle := tview.NewTextView() 54 | infoTitle.SetDynamicColors(true) 55 | infoTitle.SetTextAlign(tview.AlignCenter) 56 | infoTitle.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 57 | infoTitle.SetText(theme.ColorWrap(theme.ThemeText, "Select files to send", "::bu")) 58 | 59 | f.pickerFlex = tview.NewFlex(). 60 | SetDirection(tview.FlexRow). 61 | AddItem(infoTitle, 1, 0, false). 62 | AddItem(nil, 1, 0, false). 63 | AddItem(f.filePickerTitle(), 1, 0, false). 64 | AddItem(nil, 1, 0, false). 65 | AddItem(f.filePickerTable(), 0, 10, true). 66 | AddItem(nil, 1, 0, false). 67 | AddItem(f.filePickerButtons(), 2, 0, false) 68 | f.pickerFlex.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 69 | 70 | if !f.getHidden() { 71 | f.toggleHidden() 72 | } 73 | 74 | f.isSupported = true 75 | 76 | return nil 77 | } 78 | 79 | // SetRootView sets the root view for the file picker. 80 | func (f *filePickerView) SetRootView(v *Views) { 81 | f.Views = v 82 | } 83 | 84 | // Show shows a file picker, and returns 85 | // a list of all the selected files. 86 | func (f *filePickerView) Show() ([]string, error) { 87 | if !f.isSupported { 88 | return nil, errors.New("the filepicker cannot be opened since sending files is not supported") 89 | } 90 | 91 | f.reset() 92 | f.app.QueueDraw(func() { 93 | f.pages.AddAndSwitchToPage(filePickerPage.String(), f.pickerFlex, true) 94 | go f.changeDir(false, false) 95 | }) 96 | 97 | return <-f.listChan, nil 98 | } 99 | 100 | // reset resets the list of selected files. 101 | func (f *filePickerView) reset() { 102 | f.listChan = make(chan []string) 103 | f.selectedFiles = make(map[string]fs.DirEntry) 104 | } 105 | 106 | // filePickerTable sets up and returns the filepicker table. 107 | func (f *filePickerView) filePickerTable() *tview.Table { 108 | f.table = tview.NewTable() 109 | f.table.SetSelectorWrap(true) 110 | f.table.SetSelectable(true, false) 111 | f.table.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 112 | f.table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 113 | switch f.kb.Key(event, keybindings.ContextFiles) { 114 | case keybindings.KeyFilebrowserDirForward: 115 | go f.changeDir(true, false) 116 | 117 | case keybindings.KeyFilebrowserDirBack: 118 | go f.changeDir(false, true) 119 | 120 | case keybindings.KeyFilebrowserToggleHidden: 121 | f.toggleHidden() 122 | fallthrough 123 | 124 | case keybindings.KeyFilebrowserRefresh: 125 | go f.changeDir(false, false) 126 | 127 | case keybindings.KeyFilebrowserConfirmSelection: 128 | f.sendFileList() 129 | fallthrough 130 | 131 | case keybindings.KeyClose: 132 | f.buttonHandler("cancel") 133 | 134 | case keybindings.KeyQuit: 135 | go f.actions.quit() 136 | 137 | case keybindings.KeyHelp: 138 | f.help.showHelp() 139 | 140 | case keybindings.KeyFilebrowserSelectAll, keybindings.KeyFilebrowserInvertSelection, keybindings.KeyFilebrowserSelect: 141 | f.selectFile(event.Rune()) 142 | } 143 | 144 | return ignoreDefaultEvent(event) 145 | }) 146 | 147 | return f.table 148 | } 149 | 150 | // filePickerTitle sets up and returns the filepicker title area. 151 | // This will be used to show the current directory path. 152 | func (f *filePickerView) filePickerTitle() *tview.TextView { 153 | f.title = tview.NewTextView() 154 | f.title.SetDynamicColors(true) 155 | f.title.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 156 | f.title.SetTextAlign(tview.AlignLeft) 157 | 158 | return f.title 159 | } 160 | 161 | // filePickerButtons sets up and returns the filepicker buttons. 162 | func (f *filePickerView) filePickerButtons() *tview.TextView { 163 | f.buttons = tview.NewTextView() 164 | f.buttons.SetRegions(true) 165 | f.buttons.SetDynamicColors(true) 166 | f.buttons.SetBackgroundColor(theme.GetColor(theme.ThemeBackground)) 167 | f.buttons.SetHighlightedFunc(func(added, _, _ []string) { 168 | if added == nil { 169 | return 170 | } 171 | 172 | if slices.Contains(f.buttons.GetRegionIDs(), added[0]) { 173 | f.buttonHandler(added[0]) 174 | } 175 | }) 176 | 177 | f.buttons.SetTextAlign(tview.AlignLeft) 178 | f.buttons.SetText(theme.ColorWrap(theme.ThemeText, filePickButtonRegion)) 179 | 180 | return f.buttons 181 | } 182 | 183 | // sendFileList sends a slice of all the selected files 184 | // to the file list channel, which is received by filePicker(). 185 | func (f *filePickerView) sendFileList() { 186 | f.selectMu.Lock() 187 | defer f.selectMu.Unlock() 188 | 189 | var fileList []string 190 | 191 | for path := range f.selectedFiles { 192 | fileList = append(fileList, path) 193 | } 194 | 195 | f.listChan <- fileList 196 | } 197 | 198 | // selectFile sets the parameters for the file selection handler. 199 | func (f *filePickerView) selectFile(key rune) { 200 | var all, inverse bool 201 | 202 | switch key { 203 | case 'A': 204 | all = true 205 | inverse = false 206 | 207 | case 'a': 208 | all = false 209 | inverse = true 210 | 211 | case ' ': 212 | all = false 213 | inverse = false 214 | } 215 | 216 | f.selectFileHandler(all, inverse) 217 | } 218 | 219 | // changeDir changes to a directory and lists its contents. 220 | func (f *filePickerView) changeDir(cdFwd bool, cdBack bool) { 221 | var testPath string 222 | 223 | f.mu.Lock() 224 | defer f.mu.Unlock() 225 | 226 | if f.currentPath == "" { 227 | var err error 228 | 229 | f.currentPath, err = os.UserHomeDir() 230 | if err != nil { 231 | f.status.ErrorMessage(err) 232 | return 233 | } 234 | } 235 | 236 | testPath = f.currentPath 237 | 238 | row, _ := f.table.GetSelection() 239 | cell := f.table.GetCell(row, 1) 240 | if cell == nil { 241 | return 242 | } 243 | if cdFwd && tview.Escape(cell.Text) == "../" { 244 | cdFwd = false 245 | cdBack = true 246 | } 247 | 248 | switch { 249 | case cdFwd: 250 | entry, ok := cell.GetReference().(fs.DirEntry) 251 | if !ok { 252 | return 253 | } 254 | 255 | testPath = f.trimPath(testPath, false) 256 | testPath = filepath.Join(testPath, entry.Name()) 257 | 258 | case cdBack: 259 | f.prevDir = filepath.Base(testPath) 260 | testPath = f.trimPath(testPath, cdBack) 261 | } 262 | 263 | dlist, listed := f.dirList(filepath.FromSlash(testPath)) 264 | if !listed { 265 | return 266 | } 267 | 268 | sort.Slice(dlist, func(i, j int) bool { 269 | if dlist[i].IsDir() != dlist[j].IsDir() { 270 | return dlist[i].IsDir() 271 | } 272 | 273 | return dlist[i].Name() < dlist[j].Name() 274 | }) 275 | 276 | f.currentPath = testPath 277 | 278 | f.createDirList(dlist, cdBack) 279 | } 280 | 281 | // createDirList displays the contents of the directory in the f. 282 | func (f *filePickerView) createDirList(dlist []fs.DirEntry, cdBack bool) { 283 | f.app.QueueDraw(func() { 284 | var pos int 285 | 286 | prevrow := -1 287 | rowpadding := -1 288 | 289 | f.table.SetSelectable(false, false) 290 | f.table.Clear() 291 | 292 | for row, entry := range dlist { 293 | var attr tcell.AttrMask 294 | var entryColor tcell.Color 295 | 296 | info, err := entry.Info() 297 | if err != nil { 298 | continue 299 | } 300 | 301 | name := info.Name() 302 | fileTotalSize := formatSize(info.Size()) 303 | fileModifiedTime := info.ModTime().Format("02 Jan 2006 03:04 PM") 304 | permissions := strings.ToLower(entry.Type().String()) 305 | if len(permissions) > 10 { 306 | permissions = permissions[1:] 307 | } 308 | 309 | if entry.IsDir() { 310 | if f.currentPath != "/" { 311 | rowpadding = 0 312 | 313 | if cdBack && name == f.prevDir { 314 | pos = row 315 | } 316 | 317 | if entry == f.prevFileInfo { 318 | name = ".." 319 | prevrow = row 320 | 321 | row = 0 322 | f.table.InsertRow(0) 323 | 324 | if f.table.GetRowCount() > 0 { 325 | pos++ 326 | } 327 | } 328 | } else if f.currentPath == "/" && name == "/" { 329 | rowpadding = -1 330 | continue 331 | } 332 | 333 | attr = tcell.AttrBold 334 | entryColor = tcell.ColorBlue 335 | name += string(os.PathSeparator) 336 | } else { 337 | entryColor = theme.GetColor(theme.ThemeText) 338 | } 339 | 340 | f.table.SetCell(row+rowpadding, 0, tview.NewTableCell(" "). 341 | SetSelectable(false), 342 | ) 343 | 344 | f.table.SetCell(row+rowpadding, 1, tview.NewTableCell(tview.Escape(name)). 345 | SetExpansion(1). 346 | SetReference(entry). 347 | SetAttributes(attr). 348 | SetTextColor(entryColor). 349 | SetAlign(tview.AlignLeft). 350 | SetOnClickedFunc(f.cellHandler). 351 | SetSelectedStyle(tcell.Style{}. 352 | Bold(true). 353 | Foreground(entryColor). 354 | Background(theme.BackgroundColor(theme.ThemeText)), 355 | ), 356 | ) 357 | 358 | for col, text := range []string{ 359 | permissions, 360 | fileTotalSize, 361 | fileModifiedTime, 362 | } { 363 | f.table.SetCell(row+rowpadding, col+2, tview.NewTableCell(text). 364 | SetAlign(tview.AlignRight). 365 | SetTextColor(tcell.ColorGrey). 366 | SetSelectedStyle(tcell.Style{}. 367 | Bold(true), 368 | ), 369 | ) 370 | } 371 | 372 | if prevrow > -1 { 373 | row = prevrow 374 | prevrow = -1 375 | } 376 | 377 | f.markFileSelection(row, entry, f.checkFileSelected(filepath.Join(f.currentPath, name))) 378 | } 379 | 380 | f.title.SetText(theme.ColorWrap(theme.ThemeText, "Directory: "+f.currentPath)) 381 | 382 | f.table.ScrollToBeginning() 383 | f.table.SetSelectable(true, false) 384 | f.table.Select(pos, 0) 385 | }) 386 | } 387 | 388 | // dirList lists a directory's contents. 389 | func (f *filePickerView) dirList(testPath string) ([]fs.DirEntry, bool) { 390 | var dlist []fs.DirEntry 391 | 392 | _, err := os.Lstat(testPath) 393 | if err != nil { 394 | return nil, false 395 | } 396 | 397 | dir, err := os.Lstat(f.trimPath(testPath, true)) 398 | if err != nil { 399 | return nil, false 400 | } 401 | 402 | list, err := os.ReadDir(testPath) 403 | if err != nil { 404 | return nil, false 405 | } 406 | 407 | dirEntry := fs.FileInfoToDirEntry(dir) 408 | 409 | f.prevFileInfo = dirEntry 410 | dlist = append(dlist, dirEntry) 411 | 412 | for _, entry := range list { 413 | if f.getHidden() && strings.HasPrefix(entry.Name(), ".") { 414 | continue 415 | } 416 | 417 | dlist = append(dlist, entry) 418 | } 419 | 420 | return dlist, true 421 | } 422 | 423 | // cellHandler handles on-click events for a table cell. 424 | func (f *filePickerView) cellHandler(_ *tview.Table, row, _ int) { 425 | f.selectFileHandler(false, false, row) 426 | } 427 | 428 | // buttonHandler handles button on-click events for the filepicker buttons. 429 | func (f *filePickerView) buttonHandler(button string) { 430 | switch button { 431 | case "ok": 432 | f.sendFileList() 433 | fallthrough 434 | 435 | case "cancel": 436 | close(f.listChan) 437 | 438 | f.pages.RemovePage(filePickerPage.String()) 439 | f.pages.SwitchToPage(devicePage.String()) 440 | 441 | case "hidden": 442 | f.toggleHidden() 443 | go f.changeDir(false, false) 444 | 445 | case "invert": 446 | f.selectFileHandler(false, true) 447 | 448 | case "all": 449 | f.selectFileHandler(true, false) 450 | } 451 | 452 | f.buttons.Highlight("") 453 | } 454 | 455 | // selectFileHandler iterates over the f.table's rows, 456 | // determines the type of selection to be made (single, inverse or all), 457 | // and marks the selections. 458 | func (f *filePickerView) selectFileHandler(all, inverse bool, row ...int) { 459 | var pos int 460 | 461 | singleSelection := !all && !inverse 462 | inverseSelection := !all && inverse 463 | 464 | if row != nil { 465 | pos = row[0] 466 | } else { 467 | pos, _ = f.table.GetSelection() 468 | } 469 | totalrows := f.table.GetRowCount() 470 | 471 | for i := 0; i < totalrows; i++ { 472 | var checkSelected bool 473 | var userSelected []struct{} 474 | 475 | if singleSelection { 476 | i = pos 477 | userSelected = append(userSelected, struct{}{}) 478 | } 479 | 480 | cell := f.table.GetCell(i, 1) 481 | if cell == nil { 482 | return 483 | } 484 | 485 | entry, ok := cell.GetReference().(fs.DirEntry) 486 | if !ok { 487 | return 488 | } 489 | 490 | fullpath := filepath.Join(f.currentPath, entry.Name()) 491 | if singleSelection || inverseSelection { 492 | checkSelected = f.checkFileSelected(fullpath) 493 | } 494 | if !checkSelected { 495 | f.addFileSelection(fullpath, entry) 496 | } else { 497 | f.removeFileSelection(fullpath) 498 | } 499 | 500 | f.markFileSelection(i, entry, !checkSelected, userSelected...) 501 | 502 | if singleSelection { 503 | if i+1 < totalrows { 504 | f.table.Select(i+1, 0) 505 | return 506 | } 507 | 508 | break 509 | } 510 | } 511 | 512 | f.table.Select(pos, 0) 513 | } 514 | 515 | // addFileSelection adds a file to the f.selectedFiles list. 516 | func (f *filePickerView) addFileSelection(path string, info fs.DirEntry) { 517 | f.selectMu.Lock() 518 | defer f.selectMu.Unlock() 519 | 520 | if !info.Type().IsRegular() { 521 | return 522 | } 523 | 524 | f.selectedFiles[path] = info 525 | } 526 | 527 | // removeFileSelection removes a file from the f.selectedFiles list. 528 | func (f *filePickerView) removeFileSelection(path string) { 529 | f.selectMu.Lock() 530 | defer f.selectMu.Unlock() 531 | 532 | delete(f.selectedFiles, path) 533 | } 534 | 535 | // checkFileSelected checks if a file is selected. 536 | func (f *filePickerView) checkFileSelected(path string) bool { 537 | f.selectMu.Lock() 538 | defer f.selectMu.Unlock() 539 | 540 | _, selected := f.selectedFiles[path] 541 | 542 | return selected 543 | } 544 | 545 | // markFileSelection marks the selection for files only, directories are skipped. 546 | func (f *filePickerView) markFileSelection(row int, info fs.DirEntry, selected bool, userSelected ...struct{}) { 547 | if !info.Type().IsRegular() { 548 | if info.IsDir() && userSelected != nil { 549 | go f.changeDir(true, false) 550 | } 551 | 552 | return 553 | } 554 | 555 | cell := f.table.GetCell(row, 0) 556 | 557 | if selected { 558 | cell.Text = "+" 559 | } else { 560 | cell.Text = " " 561 | } 562 | 563 | cell.Text = theme.ColorWrap(theme.ThemeText, cell.Text) 564 | } 565 | 566 | // trimPath trims a given path and appends a path separator where appropriate. 567 | func (f *filePickerView) trimPath(testPath string, cdBack bool) string { 568 | testPath = filepath.Clean(testPath) 569 | 570 | if cdBack { 571 | testPath = filepath.Dir(testPath) 572 | } 573 | 574 | return filepath.FromSlash(testPath) 575 | } 576 | 577 | // getHidden checks if hidden files can be shown or not. 578 | func (f *filePickerView) getHidden() bool { 579 | return f.isHidden.Load() 580 | } 581 | 582 | // toggleHidden toggles the hidden files mode. 583 | func (f *filePickerView) toggleHidden() { 584 | f.isHidden.Store(!f.isHidden.Load()) 585 | } 586 | 587 | // formatSize returns the human readable form of a size value in bytes. 588 | // Adapted from: https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format/ 589 | func formatSize(size int64) string { 590 | const unit = 1000 591 | if size < unit { 592 | return fmt.Sprintf("%d B", size) 593 | } 594 | div, exp := int64(unit), 0 595 | for n := size / unit; n >= unit; n /= unit { 596 | div *= unit 597 | exp++ 598 | } 599 | 600 | return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "kMGTPE"[exp]) 601 | } 602 | -------------------------------------------------------------------------------- /ui/app/views/viewactions.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/bluetuith-org/bluetooth-classic/api/appfeatures" 8 | "github.com/bluetuith-org/bluetooth-classic/api/bluetooth" 9 | "github.com/darkhz/bluetuith/ui/keybindings" 10 | ) 11 | 12 | // viewActions holds an instance of a view actions manager, 13 | // which maps different actions to their respective view action contexts and actions. 14 | type viewActions struct { 15 | rv *Views 16 | 17 | fnmap map[viewActionContext]map[keybindings.Key]func(set ...string) bool 18 | } 19 | 20 | // viewActionContext describes the context in which the 21 | // action is supposed to be executed in. 22 | type viewActionContext int 23 | 24 | // The different context types for actions. 25 | const ( 26 | actionInvoke viewActionContext = iota 27 | actionInitializer 28 | actionVisibility 29 | ) 30 | 31 | // newViewActions returns a new view actions manager. 32 | func newViewActions(rv *Views) *viewActions { 33 | v := &viewActions{rv: rv} 34 | 35 | return v.initViewActions() 36 | } 37 | 38 | // initViewActions initializes and stores the different view actions based on their view action contexts. 39 | func (v *viewActions) initViewActions() *viewActions { 40 | v.fnmap = map[viewActionContext]map[keybindings.Key]func(set ...string) bool{ 41 | actionInvoke: { 42 | keybindings.KeyAdapterTogglePower: v.power, 43 | keybindings.KeyAdapterToggleDiscoverable: v.discoverable, 44 | keybindings.KeyAdapterTogglePairable: v.pairable, 45 | keybindings.KeyAdapterToggleScan: v.scan, 46 | keybindings.KeyAdapterChange: v.changeAdapter, 47 | keybindings.KeyDeviceConnect: v.connect, 48 | keybindings.KeyDevicePair: v.pair, 49 | keybindings.KeyDeviceTrust: v.trust, 50 | keybindings.KeyDeviceBlock: v.block, 51 | keybindings.KeyDeviceSendFiles: v.send, 52 | keybindings.KeyDeviceNetwork: v.networkAP, 53 | keybindings.KeyDeviceAudioProfiles: v.profiles, 54 | keybindings.KeyPlayerShow: v.showplayer, 55 | keybindings.KeyDeviceInfo: v.info, 56 | keybindings.KeyDeviceRemove: v.remove, 57 | keybindings.KeyProgressView: v.progress, 58 | keybindings.KeyPlayerHide: v.hideplayer, 59 | keybindings.KeyQuit: v.quit, 60 | }, 61 | actionInitializer: { 62 | keybindings.KeyAdapterTogglePower: v.initPower, 63 | keybindings.KeyAdapterToggleDiscoverable: v.initDiscoverable, 64 | keybindings.KeyAdapterTogglePairable: v.initPairable, 65 | keybindings.KeyDeviceConnect: v.initConnect, 66 | keybindings.KeyDeviceTrust: v.initTrust, 67 | keybindings.KeyDeviceBlock: v.initBlock, 68 | }, 69 | actionVisibility: { 70 | keybindings.KeyDeviceSendFiles: v.visibleSend, 71 | keybindings.KeyDeviceNetwork: v.visibleNetwork, 72 | keybindings.KeyDeviceAudioProfiles: v.visibleProfile, 73 | keybindings.KeyPlayerShow: v.visiblePlayer, 74 | }, 75 | } 76 | 77 | return v 78 | } 79 | 80 | // handler executes the handler assigned to the key type based on 81 | // the action context. 82 | func (v *viewActions) handler(key keybindings.Key, actionContext viewActionContext) func() bool { 83 | handler := v.fnmap[actionContext][key] 84 | 85 | if actionContext == actionInvoke { 86 | return func() bool { 87 | go handler() 88 | return false 89 | } 90 | } 91 | 92 | return func() bool { 93 | return handler() 94 | } 95 | } 96 | 97 | // power checks and toggles the adapter's powered state. 98 | func (v *viewActions) power(set ...string) bool { 99 | var poweredText string 100 | 101 | props, err := v.rv.adapter.currentSession().Properties() 102 | if err != nil { 103 | v.rv.status.ErrorMessage(err) 104 | return false 105 | } 106 | 107 | powered := props.Powered 108 | 109 | if set != nil { 110 | state := set[0] == "yes" 111 | if state == powered { 112 | return false 113 | } 114 | 115 | powered = !state 116 | } 117 | 118 | if err := v.rv.adapter.currentSession().SetPoweredState(!powered); err != nil { 119 | v.rv.status.ErrorMessage(errors.New("cannot set adapter power state")) 120 | return false 121 | } 122 | 123 | if powered { 124 | poweredText = "off" 125 | } else { 126 | poweredText = "on" 127 | } 128 | 129 | v.rv.status.InfoMessage(props.UniqueName+" is powered "+poweredText, false) 130 | 131 | v.rv.menu.toggleItemByKey(keybindings.KeyAdapterTogglePower, !powered) 132 | 133 | return true 134 | } 135 | 136 | // discoverable checks and toggles the adapter's discoverable state. 137 | func (v *viewActions) discoverable(set ...string) bool { 138 | var discoverableText string 139 | 140 | props, err := v.rv.adapter.currentSession().Properties() 141 | if err != nil { 142 | v.rv.status.ErrorMessage(err) 143 | return false 144 | } 145 | 146 | discoverable := props.Discoverable 147 | 148 | if set != nil { 149 | state := set[0] == "yes" 150 | if state == discoverable { 151 | return false 152 | } 153 | 154 | discoverable = !state 155 | } 156 | 157 | if err := v.rv.adapter.currentSession().SetDiscoverableState(!discoverable); err != nil { 158 | v.rv.status.ErrorMessage(err) 159 | return false 160 | } 161 | 162 | if !discoverable { 163 | discoverableText = "discoverable" 164 | } else { 165 | discoverableText = "not discoverable" 166 | } 167 | 168 | v.rv.status.InfoMessage(props.UniqueName+" is "+discoverableText, false) 169 | 170 | v.rv.menu.toggleItemByKey(keybindings.KeyAdapterToggleDiscoverable, !discoverable) 171 | 172 | return true 173 | } 174 | 175 | // pairable checks and toggles the adapter's pairable state. 176 | func (v *viewActions) pairable(set ...string) bool { 177 | var pairableText string 178 | 179 | props, err := v.rv.adapter.currentSession().Properties() 180 | if err != nil { 181 | v.rv.status.ErrorMessage(err) 182 | return false 183 | } 184 | 185 | pairable := props.Pairable 186 | 187 | if set != nil { 188 | state := set[0] == "yes" 189 | if state == pairable { 190 | return false 191 | } 192 | 193 | pairable = !state 194 | } 195 | 196 | if err := v.rv.adapter.currentSession().SetPairableState(!pairable); err != nil { 197 | v.rv.status.ErrorMessage(err) 198 | return false 199 | } 200 | 201 | if !pairable { 202 | pairableText = "pairable" 203 | } else { 204 | pairableText = "not pairable" 205 | } 206 | 207 | v.rv.status.InfoMessage(props.UniqueName+" is "+pairableText, false) 208 | 209 | v.rv.menu.toggleItemByKey(keybindings.KeyAdapterTogglePairable, !pairable) 210 | 211 | return true 212 | } 213 | 214 | // scan checks the current adapter's state and starts/stops discovery. 215 | func (v *viewActions) scan(set ...string) bool { 216 | props, err := v.rv.adapter.currentSession().Properties() 217 | if err != nil { 218 | v.rv.status.ErrorMessage(err) 219 | return false 220 | } 221 | 222 | discover := props.Discovering 223 | 224 | if set != nil { 225 | state := set[0] == "yes" 226 | if state == discover { 227 | return false 228 | } 229 | 230 | discover = !state 231 | } 232 | 233 | if !discover { 234 | if err := v.rv.app.Session().Adapter(props.Address).StartDiscovery(); err != nil { 235 | v.rv.status.ErrorMessage(err) 236 | return false 237 | } 238 | v.rv.status.InfoMessage("Scanning for devices...", true) 239 | } else { 240 | if err := v.rv.app.Session().Adapter(props.Address).StopDiscovery(); err != nil { 241 | v.rv.status.ErrorMessage(err) 242 | return false 243 | } 244 | v.rv.status.InfoMessage("Scanning stopped", false) 245 | } 246 | 247 | v.rv.menu.toggleItemByKey(keybindings.KeyAdapterToggleScan, !discover) 248 | 249 | return true 250 | } 251 | 252 | // changeAdapter launches a popup with the adapters list. 253 | func (v *viewActions) changeAdapter(_ ...string) bool { 254 | v.rv.app.QueueDraw(func() { 255 | v.rv.adapter.change() 256 | }) 257 | 258 | return true 259 | } 260 | 261 | // progress displays the progress view. 262 | func (v *viewActions) progress(_ ...string) bool { 263 | v.rv.app.QueueDraw(func() { 264 | v.rv.progress.show() 265 | }) 266 | 267 | return true 268 | } 269 | 270 | // quit stops discovery mode for all existing adapters, closes the bluetooth connection 271 | // and exits the application. 272 | func (v *viewActions) quit(_ ...string) bool { 273 | if v.rv.cfg.Values.ConfirmOnQuit && v.rv.status.SetInput("Quit (y/n)?") != "y" { 274 | return false 275 | } 276 | 277 | if adapters, err := v.rv.app.Session().Adapters(); err != nil { 278 | for _, adapter := range adapters { 279 | v.rv.app.Session().Adapter(adapter.Address).StopDiscovery() 280 | } 281 | } 282 | 283 | v.rv.app.Close() 284 | 285 | return true 286 | } 287 | 288 | // initPower _s the oncreate handler for the power submenu option. 289 | func (v *viewActions) initPower(_ ...string) bool { 290 | props, err := v.rv.adapter.currentSession().Properties() 291 | 292 | return err == nil && props.Powered 293 | } 294 | 295 | // initDiscoverable _s the oncreate handler for the discoverable submenu option 296 | func (v *viewActions) initDiscoverable(_ ...string) bool { 297 | props, err := v.rv.adapter.currentSession().Properties() 298 | 299 | return err == nil && props.Discoverable 300 | } 301 | 302 | // initPairable _s the oncreate handler for the pairable submenu option. 303 | func (v *viewActions) initPairable(_ ...string) bool { 304 | props, err := v.rv.adapter.currentSession().Properties() 305 | 306 | return err == nil && props.Pairable 307 | } 308 | 309 | // initConnect _s the oncreate handler for the connect submenu option. 310 | func (v *viewActions) initConnect(_ ...string) bool { 311 | device := v.rv.device.getSelection(false) 312 | if device.Address.IsNil() { 313 | return false 314 | } 315 | 316 | return device.Connected 317 | } 318 | 319 | // initTrust _s the oncreate handler for the trust submenu option. 320 | func (v *viewActions) initTrust(_ ...string) bool { 321 | device := v.rv.device.getSelection(false) 322 | if device.Address.IsNil() { 323 | return false 324 | } 325 | 326 | return device.Trusted 327 | } 328 | 329 | // initBlock _s the oncreate handler for the block submenu option. 330 | func (v *viewActions) initBlock(_ ...string) bool { 331 | device := v.rv.device.getSelection(false) 332 | if device.Address.IsNil() { 333 | return false 334 | } 335 | 336 | return device.Blocked 337 | } 338 | 339 | // visibleSend _s the visible handler for the send submenu option. 340 | func (v *viewActions) visibleSend(_ ...string) bool { 341 | device := v.rv.device.getSelection(false) 342 | if device.Address.IsNil() { 343 | return false 344 | } 345 | 346 | return v.rv.app.Features().Has(appfeatures.FeatureSendFile, appfeatures.FeatureReceiveFile) && 347 | device.HaveService(bluetooth.ObexObjpushServiceClass) 348 | } 349 | 350 | // visibleNetwork _s the visible handler for the network submenu option. 351 | func (v *viewActions) visibleNetwork(_ ...string) bool { 352 | device := v.rv.device.getSelection(false) 353 | if device.Address.IsNil() { 354 | return false 355 | } 356 | 357 | return v.rv.app.Features().Has(appfeatures.FeatureNetwork) && 358 | device.HaveService(bluetooth.NapServiceClass) && 359 | (device.HaveService(bluetooth.PanuServiceClass) || 360 | device.HaveService(bluetooth.DialupNetServiceClass)) 361 | } 362 | 363 | // visibleProfile _s the visible handler for the audio profiles submenu option. 364 | func (v *viewActions) visibleProfile(_ ...string) bool { 365 | device := v.rv.device.getSelection(false) 366 | if device.Address.IsNil() { 367 | return false 368 | } 369 | 370 | return device.HaveService(bluetooth.AudioSourceServiceClass) || 371 | device.HaveService(bluetooth.AudioSinkServiceClass) 372 | } 373 | 374 | // visiblePlayer _s the visible handler for the media player submenu option. 375 | func (v *viewActions) visiblePlayer(_ ...string) bool { 376 | device := v.rv.device.getSelection(false) 377 | if device.Address.IsNil() { 378 | return false 379 | } 380 | 381 | return device.HaveService(bluetooth.AudioSourceServiceClass) && 382 | device.HaveService(bluetooth.AvRemoteServiceClass) && 383 | device.HaveService(bluetooth.AvRemoteTargetServiceClass) 384 | } 385 | 386 | // connect retrieves the selected device, and toggles its connection state. 387 | func (v *viewActions) connect(set ...string) bool { 388 | var device bluetooth.DeviceData 389 | 390 | if set != nil { 391 | devices, err := v.rv.adapter.currentSession().Devices() 392 | if err != nil { 393 | v.rv.status.ErrorMessage(err) 394 | return false 395 | } 396 | 397 | for _, d := range devices { 398 | if d.Address.String() == set[0] { 399 | device = d 400 | break 401 | } 402 | } 403 | } else { 404 | device = v.rv.device.getSelection(true) 405 | if device.Address.IsNil() { 406 | return false 407 | } 408 | } 409 | 410 | disconnectFunc := func() { 411 | if err := v.rv.app.Session().Device(device.Address).Disconnect(); err != nil { 412 | v.rv.status.ErrorMessage(err) 413 | return 414 | } 415 | 416 | v.rv.player.closeForDevice(device.Address) 417 | } 418 | 419 | connectFunc := func() { 420 | v.rv.status.InfoMessage("Connecting to "+device.Name, true) 421 | if err := v.rv.app.Session().Device(device.Address).Connect(); err != nil { 422 | v.rv.status.ErrorMessage(err) 423 | return 424 | } 425 | v.rv.status.InfoMessage("Connected to "+device.Name, false) 426 | } 427 | 428 | if !device.Connected { 429 | v.rv.op.startOperation( 430 | connectFunc, 431 | func() { 432 | disconnectFunc() 433 | v.rv.status.InfoMessage("Cancelled connection to "+device.Name, false) 434 | }, 435 | ) 436 | } else { 437 | v.rv.status.InfoMessage("Disconnecting from "+device.Name, true) 438 | disconnectFunc() 439 | v.rv.status.InfoMessage("Disconnected from "+device.Name, false) 440 | } 441 | 442 | v.rv.menu.toggleItemByKey(keybindings.KeyDeviceConnect, !device.Connected) 443 | 444 | return true 445 | } 446 | 447 | // pair retrieves the selected device, and attempts to pair with it. 448 | func (v *viewActions) pair(_ ...string) bool { 449 | device := v.rv.device.getSelection(true) 450 | if device.Address.IsNil() { 451 | return false 452 | } 453 | if device.Paired { 454 | v.rv.status.InfoMessage(device.Name+" is already paired", false) 455 | return false 456 | } 457 | 458 | v.rv.op.startOperation( 459 | func() { 460 | v.rv.status.InfoMessage("Pairing with "+device.Name, true) 461 | if err := v.rv.app.Session().Device(device.Address).Pair(); err != nil { 462 | v.rv.status.ErrorMessage(err) 463 | return 464 | } 465 | v.rv.status.InfoMessage("Paired with "+device.Name, false) 466 | }, 467 | func() { 468 | if err := v.rv.app.Session().Device(device.Address).CancelPairing(); err != nil { 469 | v.rv.status.ErrorMessage(err) 470 | return 471 | } 472 | v.rv.status.InfoMessage("Cancelled pairing with "+device.Name, false) 473 | }, 474 | ) 475 | 476 | return true 477 | } 478 | 479 | // trust retrieves the selected device, and toggles its trust property. 480 | func (v *viewActions) trust(_ ...string) bool { 481 | device := v.rv.device.getSelection(true) 482 | if device.Address.IsNil() { 483 | return false 484 | } 485 | 486 | if err := v.rv.app.Session().Device(device.Address).SetTrusted(!device.Trusted); err != nil { 487 | v.rv.status.ErrorMessage(errors.New("cannot set trusted property for " + device.Name)) 488 | return false 489 | } 490 | 491 | v.rv.menu.toggleItemByKey(keybindings.KeyDeviceTrust, !device.Trusted) 492 | 493 | return true 494 | } 495 | 496 | // block retrieves the selected device, and toggles its block property. 497 | func (v *viewActions) block(_ ...string) bool { 498 | device := v.rv.device.getSelection(true) 499 | if device.Address.IsNil() { 500 | return false 501 | } 502 | 503 | if err := v.rv.app.Session().Device(device.Address).SetBlocked(!device.Blocked); err != nil { 504 | v.rv.status.ErrorMessage(errors.New("cannot set blocked property for " + device.Name)) 505 | return false 506 | } 507 | 508 | v.rv.menu.toggleItemByKey(keybindings.KeyDeviceBlock, !device.Blocked) 509 | 510 | return true 511 | } 512 | 513 | // send gets a file list from the file picker and sends all selected files 514 | // to the target device. 515 | func (v *viewActions) send(_ ...string) bool { 516 | device := v.rv.device.getSelection(true) 517 | if !device.Paired || !device.Connected { 518 | v.rv.status.ErrorMessage(errors.New(device.Name + " is not paired and/or connected")) 519 | return false 520 | } 521 | 522 | ctx, cancel := context.WithCancel(context.Background()) 523 | 524 | v.rv.op.startOperation( 525 | func() { 526 | v.rv.status.InfoMessage("Initializing OBEX Session()..", true) 527 | oppSession := v.rv.app.Session().Obex(device.Address).ObjectPush() 528 | 529 | err := oppSession.CreateSession(ctx) 530 | if err != nil { 531 | v.rv.status.ErrorMessage(err) 532 | return 533 | } 534 | 535 | v.rv.op.cancelOperation(false) 536 | 537 | v.rv.status.InfoMessage("Created OBEX session", false) 538 | 539 | fileList, err := v.rv.filepicker.Show() 540 | if err != nil { 541 | v.rv.status.ErrorMessage(err) 542 | oppSession.RemoveSession() 543 | return 544 | } 545 | if len(fileList) == 0 { 546 | oppSession.RemoveSession() 547 | return 548 | } 549 | 550 | proplist := make([]bluetooth.ObjectPushData, 0, len(fileList)) 551 | for _, file := range fileList { 552 | props, err := oppSession.SendFile(file) 553 | if err != nil || props.Status == bluetooth.TransferError { 554 | oppSession.RemoveSession() 555 | v.rv.status.ErrorMessage(err) 556 | return 557 | } 558 | 559 | proplist = append(proplist, props) 560 | } 561 | 562 | v.rv.progress.startTransfer(device.Address, oppSession, proplist) 563 | }, 564 | func() { 565 | cancel() 566 | v.rv.status.InfoMessage("Cancelled OBEX session creation", false) 567 | }, 568 | ) 569 | 570 | return true 571 | } 572 | 573 | // networkAP launches a popup with the available networks. 574 | func (v *viewActions) networkAP(_ ...string) bool { 575 | v.rv.app.QueueDraw(func() { 576 | v.rv.network.networkSelect() 577 | }) 578 | 579 | return true 580 | } 581 | 582 | // profiles launches a popup with the available audio profiles. 583 | func (v *viewActions) profiles(_ ...string) bool { 584 | v.rv.app.QueueDraw(func() { 585 | v.rv.audioProfiles.audioProfiles() 586 | }) 587 | 588 | return true 589 | } 590 | 591 | // showplayer starts the media player. 592 | func (v *viewActions) showplayer(_ ...string) bool { 593 | v.rv.player.show() 594 | 595 | return true 596 | } 597 | 598 | // hideplayer hides the media player. 599 | func (v *viewActions) hideplayer(_ ...string) bool { 600 | v.rv.player.close() 601 | 602 | return true 603 | } 604 | 605 | // info retrieves the selected device, and shows the device information. 606 | func (v *viewActions) info(_ ...string) bool { 607 | v.rv.app.QueueDraw(func() { 608 | v.rv.device.showDetailedInfo() 609 | }) 610 | 611 | return true 612 | } 613 | 614 | // remove retrieves the selected device, and removes it from the adapter. 615 | func (v *viewActions) remove(_ ...string) bool { 616 | device := v.rv.device.getSelection(true) 617 | if device.Address.IsNil() { 618 | return false 619 | } 620 | 621 | if txt := v.rv.status.SetInput("Remove " + device.Name + " (y/n)?"); txt != "y" { 622 | return false 623 | } 624 | 625 | if err := v.rv.app.Session().Device(device.Address).Remove(); err != nil { 626 | v.rv.status.ErrorMessage(err) 627 | return false 628 | } 629 | 630 | v.rv.status.InfoMessage("Removed "+device.Name, false) 631 | 632 | return true 633 | } 634 | --------------------------------------------------------------------------------