├── .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 | [](https://goreportcard.com/report/github.com/darkhz/bluetuith) [](https://repology.org/project/bluetuith/versions)
2 |
3 | 
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 | [
](https://nlnet.nl)
16 | [
](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 | [](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 |
--------------------------------------------------------------------------------