Compare commits
134 Commits
2020-08-30
...
master
Author | SHA1 | Date |
---|---|---|
Marcel Schramm | 17efaa7880 | |
Marcel Schramm | a517dc2cb3 | |
Marcel Schramm | 4e8f514a24 | |
Marcel Schramm | 8bdb018434 | |
Marcel Schramm | 54865be813 | |
iAmir | a4129c885e | |
Marcel Schramm | 499d5a23c7 | |
Marcel Schramm | 42a4104c45 | |
Marcel Schramm | 041a8423a7 | |
Marcel Schramm | 66883c6d61 | |
Marcel Schramm | feca3d8de3 | |
Marcel Schramm | 61d36aebba | |
Marcel Schramm | 4c83b1448c | |
Marcel Schramm | e886913ccc | |
Marcel Schramm | f6eca3f302 | |
Marcel Schramm | cf17e677f3 | |
Marcel Schramm | d45ceb32ab | |
Marcel Schramm | e839f4bc23 | |
Marcel Schramm | 5fe1add9b9 | |
Marcel Schramm | 02eafc8e19 | |
Marcel Schramm | 2dd57fb965 | |
Marcel Schramm | 0c823dc695 | |
Marcel Schramm | a46f58d610 | |
Marcel Schramm | bac98222a4 | |
Marcel Schramm | 4147f60b0c | |
Marcel Schramm | 59627d95d5 | |
Marcel Schramm | 8cb0f71c97 | |
Marcel Schramm | 9e0e2be9e8 | |
Marcel Schramm | 405a0301f3 | |
Marcel Schramm | 7ed2c8e6ba | |
Marcel Schramm | 57c05c571a | |
Marcel Schramm | c2de2873ff | |
Marcel Schramm | 84e549dca0 | |
Marcel Schramm | ecdbc5b9f2 | |
Marcel Schramm | e6a4741398 | |
Marcel Schramm | 5b7cd99899 | |
Marcel Schramm | a69272ec76 | |
Marcel Schramm | c8868f0d20 | |
Marcel Schramm | f721c4aeb2 | |
Marcel Schramm | 7a0b89af31 | |
Marcel Schramm | acb148b1e3 | |
Marcel Schramm | a4c7af0c04 | |
Marcel Schramm | e05ac946ca | |
Marcel Schramm | 8e361fd31c | |
Marcel Schramm | a512146cc6 | |
Marcel Schramm | dbe352c168 | |
Marcel Schramm | 7502b76f55 | |
Marcel Schramm | 52ee681981 | |
Marcel Schramm | 62946ab52f | |
Marcel Schramm | f05eccf270 | |
Marcel Schramm | 743b358917 | |
Marcel Schramm | 0ff0f3eb00 | |
Marcel Schramm | 315eda9869 | |
Marcel Schramm | 78e4d6c9a3 | |
Marcel Schramm | 7c46594181 | |
Marcel Schramm | 601c232a8b | |
Marcel Schramm | 835aaa6aa3 | |
Marcel Schramm | 49ef544bcd | |
Marcel Schramm | 5a22b33744 | |
Marcel Schramm | d9d8cffca8 | |
Marcel Schramm | 113fb9e77e | |
Marcel Schramm | 767d19a5f8 | |
Marcel Schramm | 83c0ee7a0b | |
Marcel Schramm | 6bef24a182 | |
Marcel Schramm | 562a7aeda4 | |
Marcel Schramm | 88629aa4ae | |
Marcel Schramm | 7be90c4ae7 | |
Marcel Schramm | 1485a8849d | |
Marcel Schramm | 1bd0717ad9 | |
Marcel Schramm | 852b98cb8e | |
Marcel Schramm | 1ac2aef556 | |
Marcel Schramm | 2a3d78301e | |
Marcel Schramm | 6eab4ff504 | |
Marcel Schramm | 4310b8ec7a | |
Marcel Schramm | f1d56c0f9e | |
Marcel Schramm | 8cfac956f9 | |
Marcel Schramm | a4980f0d49 | |
Marcel Schramm | 265c001ba1 | |
Marcel Schramm | e1276b06d6 | |
Marcel Schramm | dcab2d1faf | |
Marcel Schramm | 7e60fe672c | |
Marcel Schramm | 34783d3823 | |
Marcel Schramm | 0f4e1b1332 | |
Marcel Schramm | 2bfe3447d7 | |
Marcel Schramm | 159d9d1062 | |
Marcel Schramm | 75a9dea28f | |
Marcel Schramm | 9bffea00fe | |
Marcel Schramm | 7b8bd775ca | |
Marcel Schramm | fa7f634846 | |
Marcel Schramm | 5247123a93 | |
Marcel Schramm | 6c4ee307a5 | |
Marcel Schramm | d8830f9ead | |
Marcel Schramm | 47f3bf84e1 | |
Marcel Schramm | 0e0052fd8c | |
Marcel Schramm | 6927e3603f | |
Marcel Schramm | 939d35f1ea | |
Marcel Schramm | 538a78656a | |
Marcel Schramm | 8e750b6345 | |
Marcel Schramm | e750fc5e8c | |
Marcel Schramm | b1d1f4d68c | |
Marcel Schramm | a299c1edd7 | |
Marcel Schramm | 07249c0d83 | |
Marcel Schramm | ea5a2ceba7 | |
Marcel Schramm | b5c8f4af80 | |
Marcel Schramm | 9cf225209d | |
Marcel Schramm | fe49d6caaf | |
Marcel Schramm | 542973e1f2 | |
Marcel Schramm | 1f58c5f9da | |
Marcel Schramm | 6b0720617a | |
Marcel Schramm | dc6f73f456 | |
Marcel Schramm | 59842d1d7b | |
Marcel Schramm | ea2a040610 | |
Marcel Schramm | 1770f61216 | |
Marcel Schramm | 21a93cb1d1 | |
Marcel Schramm | 77c29d2003 | |
Marcel Schramm | c66351b7ba | |
Marcel Schramm | c84561e5c0 | |
Marcel Schramm | 1a80834ab5 | |
Marcel Schramm | b704c9183b | |
Marcel Schramm | 710475bc0b | |
Marcel Schramm | 2871c803df | |
Marcel Schramm | b2fb05dac9 | |
Marcel Schramm | b527ab8fae | |
Marcel Schramm | e0a10a6715 | |
Marcel Schramm | 7a55188672 | |
Marcel Schramm | ecd18e34f6 | |
Marcel Schramm | 5c3523a5de | |
Marcel Schramm | 2143a56941 | |
Marcel Schramm | 6d20e69105 | |
Marcel Schramm | 52c2beea91 | |
__Dire | 8210a69fdd | |
Marcel Schramm | b44cb95897 | |
Marcel Schramm | e483d9e429 | |
Marcel Schramm | 485759fecc |
|
@ -1,20 +0,0 @@
|
|||
|
||||
version: 2
|
||||
jobs:
|
||||
build:
|
||||
docker:
|
||||
- image: circleci/golang:1.13
|
||||
|
||||
working_directory: /go/src/github.com/Bios-Marcel/cordless
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
- run: go get -v -t -d ./...
|
||||
- run: go vet ./...
|
||||
- run: go test -race -coverprofile=profile.out -covermode=atomic ./...
|
||||
- run: bash <(curl -s https://codecov.io/bash) -f profile.out
|
||||
- run: go build
|
||||
|
||||
- store_artifacts:
|
||||
path: /go/src/github.com/Bios-Marcel/cordless/cordless
|
||||
destination: cordless
|
|
@ -6,7 +6,8 @@ about: Tell us what went wrong
|
|||
## How have you installed cordless
|
||||
|
||||
- [ ] Arch User Repository
|
||||
- [ ] Snap
|
||||
<!--SNAP IS NOT SUPPORTED ANYMORE; VIEW THE README FOR NEW INSTALL INSTRUCTIONS-->
|
||||
<!-- - [ ] Snap-->
|
||||
- [ ] scoop
|
||||
- [ ] brew
|
||||
- [ ] go get
|
||||
|
@ -16,22 +17,19 @@ about: Tell us what went wrong
|
|||
|
||||
<!-- Explain what happened (the problem)-->
|
||||
|
||||
## How do you reproduce this bug
|
||||
|
||||
<!-- Explain how exactly I can produce this bug on my machine -->
|
||||
|
||||
## Error output
|
||||
|
||||
<!-- If there was any output, enter it here please -->
|
||||
|
||||
## Hints on what could've happened
|
||||
## How do you reproduce this bug
|
||||
|
||||
<!-- If you know how to solve this problem, tell others how -->
|
||||
<!-- Explain how exactly I can produce this bug on my machine -->
|
||||
|
||||
## System information
|
||||
|
||||
| Key | Value |
|
||||
| - | - |
|
||||
| OS | **TODO** |
|
||||
| Architecture | **TODO** |
|
||||
| Go version | <!-- Only apply if self-compiled via "go get" or "go build". Retrieve via terminal with "go version". --> |
|
||||
| OS | **TODO** <!-- Your system, so Debian, Ubuntu, Windows 10, Mac OS ... -->|
|
||||
| Architecture | **TODO** <!-- Your CPUs architechture, e.g. amd64, aarch64, ... --> |
|
||||
| Terminal | **TODO** <!-- For example xterm, termite, st ... -->
|
||||
| Go version | **TODO** <!-- Only apply if self-compiled via "go get" or "go build". Retrieve via terminal with "go version". --> |
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
cordless.rb
|
||||
cordless.json
|
||||
cordless_darwin
|
||||
cordless_linux_64
|
||||
cordless
|
||||
cordless_64.exe
|
||||
cordless_32.exe
|
||||
*.snap
|
||||
*.xdelta*
|
||||
stage/
|
||||
prime/
|
||||
snap/.snapcraft
|
||||
.vscode/
|
||||
cordless_debug
|
||||
.idea/
|
||||
*.log
|
||||
theme.json
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
os: osx
|
||||
language: go
|
||||
go:
|
||||
- "1.13"
|
||||
script: go test -race ./...
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch",
|
||||
"type": "go",
|
||||
"request": "attach",
|
||||
"mode": "remote",
|
||||
"remotePath": "${workspaceFolder}",
|
||||
"port": 2345,
|
||||
"host": "127.0.0.1"
|
||||
},
|
||||
]
|
||||
}
|
150
README.md
150
README.md
|
@ -1,42 +1,31 @@
|
|||
# I AM CLOSING DOWN THE CORDLESS PROJECT
|
||||
|
||||
Hey, so I know this is somewhat of a bummer, but I got banned because of ToS violation today. This seemed to be connected to creating a new PM channel via the `/users/@me` endpoint. As that's basically a confirmation for what we've believed would never be enforced, I decided to not work on the cordless project anymore. I'll be taking down cordless in package managers in hope that no new users will install it anymore without knowing the risks. I believe that if you manage to build it yourself, you've probably read the README and are aware of the risks.
|
||||
I'll keep the repository up, but it'll be archived (read-only) and I have vendored the dependencies, meaning that you'll probably always be able to build the project from source as long as you have a compatible go compiler. **And yes, you'll still be able to use existing binaries for as long as discord doesn't introduce any more breaking changes. However, be aware that the risk of getting a ban will only get higher with time!**
|
||||
|
||||
<h1 align="center">Cordless</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/Bios-Marcel/cordless">
|
||||
<img src="https://img.shields.io/circleci/build/gh/Bios-Marcel/cordless?label=linux&logo=linux&logoColor=white">
|
||||
</a>
|
||||
<a href="https://travis-ci.org/Bios-Marcel/cordless">
|
||||
<img src="https://img.shields.io/travis/Bios-Marcel/cordless?label=darwin&logo=apple&logoColor=white">
|
||||
</a>
|
||||
<a href="https://ci.appveyor.com/project/Bios-Marcel/cordless/branch/master">
|
||||
<img src=https://img.shields.io/appveyor/ci/Bios-Marcel/cordless?label=windows&logo=windows&logoColor=white">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/Bios-Marcel/cordless">
|
||||
<img src="https://codecov.io/gh/Bios-Marcel/cordless/branch/master/graph/badge.svg">
|
||||
</a>
|
||||
<a href="https://discord.gg/fxFqszu">
|
||||
<img src="https://img.shields.io/discord/600329866558308373.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2">
|
||||
</a>
|
||||
</p>
|
||||
The discord server still exists and there's still some people talking, so feel free to check it out if you want to:
|
||||
|
||||
https://discord.gg/fxFqszu
|
||||
|
||||
## Overview
|
||||
|
||||
- [Credits](#credits)
|
||||
- [How to install it](#installation)
|
||||
- [Using prebuilt binaries](#using-prebuilt-binaries)
|
||||
- [Building from source](#building-from-source)
|
||||
- [Installing on Linux](#installing-on-linux)
|
||||
- [Installing on Windows](#installing-on-windows)
|
||||
- [Installing on macOS](#installing-on-macos)
|
||||
- [Login](#login)
|
||||
- [Quick overview - Navigation (switching between boxes / containers)](#quick-overview---navigation-switching-between-boxes--containers)
|
||||
- [Extending Cordless via the scripting interface](#extending-cordless-via-the-scripting-interface)
|
||||
- [Contributing](#contributing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [FAQ](#faq)
|
||||
- [This project isn't for you, if](#this-project-isnt-for-you-if)
|
||||
- [Similar projects](#similar-projects)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Credits](#credits)
|
||||
|
||||
**WARNING: Third party clients are discouraged and against the Discord TOS.**
|
||||
**WARNING: Third party clients are discouraged and against the Discord TOS. There have already been cordless users that got banned, including me (Bios-Marcel, the maker and maintainer)**
|
||||
|
||||
Cordless is a custom [Discord](https://discordapp.com) client that aims to
|
||||
Cordless is a custom [Discord](https://discord.com/app) client that aims to
|
||||
have a low memory footprint and be aimed at power-users.
|
||||
|
||||
The application only uses the official Discord API and doesn't send data to
|
||||
|
@ -47,86 +36,47 @@ Discord Inc.
|
|||
|
||||
## Installation
|
||||
|
||||
### Using prebuilt binaries
|
||||
|
||||
If you don't want to build the application yourself or use some kind of
|
||||
package management system, you can get the latest binaries for the three
|
||||
major systems in the release overview:
|
||||
|
||||
https://github.com/Bios-Marcel/cordless/releases/latest
|
||||
|
||||
### Building from source
|
||||
|
||||
In order to execute the following command, you need to install go 1.13 or higher.
|
||||
You can find golang packages at (https://golang.org/doc/install).
|
||||
In order to execute the following commands, you need to install **go 1.13 or**
|
||||
higher. You can find golang packages at https://golang.org/doc/install.
|
||||
On top of that, you need to have **git** installed. It can be fund at
|
||||
https://git-scm.com/downloads.
|
||||
|
||||
**UPDATES HAVE TO BE INSTALLED MANUALLY**
|
||||
|
||||
Make sure `$GOPATH/bin` is in your systems `PATH` variable, since the
|
||||
binary will be put into that folder. Afterwards install or update cordless
|
||||
via the command:
|
||||
Open a command line and execute the following commands:
|
||||
|
||||
```shell
|
||||
go get -u github.com/Bios-Marcel/cordless
|
||||
git clone https://github.com/Bios-Marcel/cordless.git
|
||||
cd cordless
|
||||
go build
|
||||
```
|
||||
|
||||
This will create an executable file called `cordless` or `cordless.exe`
|
||||
depending on whether you are on Windows or not. Move that file anywhere
|
||||
that your terminal can find it. I recommend adding a `bin` folder to your
|
||||
user home and adding it to your systems `PATH` variable. Please search the
|
||||
internet, using your favourite search engine, for
|
||||
`how to set an environment variable in XXX` in order to update your `PATH`
|
||||
variable correctly.
|
||||
|
||||
For updateing you simply have to delete the folder you downloaded last
|
||||
time and repeat the instructions.
|
||||
|
||||
Note:
|
||||
|
||||
* X11 users need `xclip` in order to copy and paste.
|
||||
* Wayland users need `wl-clipboard` in order to copy and paste.
|
||||
* Mac OS users probably want `pngpaste` in order to copy and paste.
|
||||
|
||||
### Installing on Linux
|
||||
|
||||
#### Snap
|
||||
|
||||
**Currently I can't release new snap versions due to a bug!**
|
||||
|
||||
Run (Might require sudo):
|
||||
|
||||
```shell
|
||||
snap install cordless
|
||||
```
|
||||
|
||||
Snap will automatically install updates.
|
||||
|
||||
#### Arch based Linux distributions
|
||||
|
||||
On Arch based distributions, you can use the AUR package to install cordless:
|
||||
|
||||
```shell
|
||||
git clone https://aur.archlinux.org/cordless-git.git
|
||||
cd cordless-git
|
||||
makepkg -sric
|
||||
```
|
||||
|
||||
or use your favourite AUR helper.
|
||||
|
||||
### Installing on Windows
|
||||
|
||||
In order to install the latest version on Windows, you first need to install
|
||||
[scoop](https://scoop.sh/#installs-in-seconds).
|
||||
|
||||
After installing scoop, run the following:
|
||||
|
||||
This adds the bucket (repository) that contains cordless to your local scoop
|
||||
installation, allowing you to install any package it contains. Afterwards
|
||||
it installs cordless for your current windows user.
|
||||
|
||||
```ps1
|
||||
scoop bucket add biosmarcel https://github.com/Bios-Marcel/scoopbucket.git
|
||||
scoop install cordless
|
||||
```
|
||||
|
||||
Updates can be installed via:
|
||||
|
||||
```ps1
|
||||
scoop update cordless
|
||||
```
|
||||
|
||||
### Installing on macOS
|
||||
|
||||
Use [Homebrew](https://brew.sh) to install `cordless` on macOS:
|
||||
|
||||
```shell
|
||||
brew tap Bios-Marcel/cordless
|
||||
brew install cordless
|
||||
```
|
||||
|
||||
If you don't install via cordless via brew, then you should have to get
|
||||
`pngpaste` in order to be able to paste image data.
|
||||
* Mac OS users need `pngpaste` in order to copy and paste images.
|
||||
|
||||
### Login
|
||||
|
||||
|
@ -188,22 +138,6 @@ https://github.com/Bios-Marcel/cordless/wiki/FAQ
|
|||
- You need the voice/video calling features
|
||||
- You need to manage or moderate servers
|
||||
|
||||
## Contributing
|
||||
|
||||
All kinds of contributions are welcome. Whether it's correcting typos, fixing
|
||||
bugs, adding features or whatever else might be good for the project. If you
|
||||
want to contribute code, please create a new branch and commit only changes
|
||||
relevant to your planned pull request onto that branch. This will help
|
||||
to isolate new changes and make merging those into `master` easier.
|
||||
|
||||
If you encounter any issues, whether it's bugs or the lack of certain features,
|
||||
don't hesitate to create a new GitHub issue.
|
||||
|
||||
If there are specific issues you want to be solved quickly, you can set a
|
||||
bounty on those via [IssueHunt](https://issuehunt.io/r/Bios-Marcel/cordless).
|
||||
The full 100% of the bounty goes to whoever solves the issue, no matter
|
||||
whether that's me or someone else.
|
||||
|
||||
## Similar projects
|
||||
|
||||
Here is a list of similar projects:
|
||||
|
|
73
app/app.go
73
app/app.go
|
@ -17,48 +17,31 @@ import (
|
|||
"github.com/Bios-Marcel/cordless/version"
|
||||
)
|
||||
|
||||
// RunWithAccount launches the whole application and might
|
||||
// SetupApplicationWithAccount launches the whole application and might
|
||||
// abort in case it encounters an error. The login will attempt
|
||||
// using the account specified, unless the argument is empty.
|
||||
// If the account can't be found, the login page will be shown.
|
||||
func RunWithAccount(account string) {
|
||||
configDir, configErr := config.GetConfigDirectory()
|
||||
func SetupApplicationWithAccount(app *tview.Application, account string) {
|
||||
configuration := config.Current
|
||||
|
||||
//We do this right after loading the configuration, as this might take
|
||||
//longer than all following calls.
|
||||
updateAvailableChannel := version.CheckForUpdate(configuration.DontShowUpdateNotificationFor)
|
||||
|
||||
configDir, configErr := config.GetConfigDirectory()
|
||||
if configErr != nil {
|
||||
log.Fatalf("Unable to determine configuration directory (%s)\n", configErr.Error())
|
||||
}
|
||||
|
||||
themeLoadingError := config.LoadTheme()
|
||||
if themeLoadingError == nil {
|
||||
tview.Styles = *config.GetTheme().Theme
|
||||
}
|
||||
|
||||
app := tview.NewApplication()
|
||||
loginScreen := ui.NewLogin(app, configDir)
|
||||
app.SetRoot(loginScreen, true)
|
||||
runNext := make(chan bool, 1)
|
||||
|
||||
configuration, configLoadError := config.LoadConfig()
|
||||
if strings.TrimSpace(account) != "" {
|
||||
configuration.Token = configuration.GetAccountToken(account)
|
||||
}
|
||||
|
||||
if configLoadError != nil {
|
||||
log.Fatalf("Error loading configuration file (%s).\n", configLoadError.Error())
|
||||
}
|
||||
|
||||
updateAvailableChannel := make(chan bool, 1)
|
||||
if configuration.ShowUpdateNotifications {
|
||||
go func() {
|
||||
updateAvailableChannel <- version.IsLocalOutdated(configuration.DontShowUpdateNotificationFor)
|
||||
}()
|
||||
} else {
|
||||
updateAvailableChannel <- false
|
||||
}
|
||||
|
||||
app.MouseEnabled = configuration.MouseEnabled
|
||||
|
||||
go func() {
|
||||
if strings.TrimSpace(account) != "" {
|
||||
configuration.Token = configuration.GetAccountToken(account)
|
||||
}
|
||||
|
||||
shortcutsLoadError := shortcuts.Load()
|
||||
if shortcutsLoadError != nil {
|
||||
panic(shortcutsLoadError)
|
||||
|
@ -78,13 +61,11 @@ func RunWithAccount(account string) {
|
|||
|
||||
readstate.Load(discord.State)
|
||||
|
||||
isUpdateAvailable := <-updateAvailableChannel
|
||||
close(updateAvailableChannel)
|
||||
if isUpdateAvailable {
|
||||
if isUpdateAvailable := <-updateAvailableChannel; isUpdateAvailable {
|
||||
waitForUpdateDialogChannel := make(chan bool, 1)
|
||||
|
||||
dialog := tview.NewModal()
|
||||
dialog.SetText(fmt.Sprintf("Version %s of cordless is available!\nYou are currently running version %s.\n\nUpdates have to be installed manually or via your package manager.", version.GetLatestRemoteVersion(), version.Version))
|
||||
dialog.SetText(fmt.Sprintf("Version %s of cordless is available!\nYou are currently running version %s.\n\nUpdates have to be installed manually or via your package manager.\n\nThe snap package manager isn't supported by cordless anymore!", version.GetLatestRemoteVersion(), version.Version))
|
||||
buttonOk := "Thanks for the info"
|
||||
buttonDontRemindAgainForThisVersion := fmt.Sprintf("Skip reminders for %s", version.GetLatestRemoteVersion())
|
||||
buttonNeverRemindMeAgain := "Never remind me again"
|
||||
|
@ -110,7 +91,7 @@ func RunWithAccount(account string) {
|
|||
}
|
||||
|
||||
app.QueueUpdateDraw(func() {
|
||||
window, createError := ui.NewWindow(runNext, app, discord, readyEvent)
|
||||
window, createError := ui.NewWindow(app, discord, readyEvent)
|
||||
|
||||
if createError != nil {
|
||||
app.Stop()
|
||||
|
@ -128,7 +109,7 @@ func RunWithAccount(account string) {
|
|||
window.RegisterCommand(statusSetCustomCmd)
|
||||
window.RegisterCommand(commandimpls.NewStatusCommand(statusGetCmd, statusSetCmd, statusSetCustomCmd))
|
||||
window.RegisterCommand(commandimpls.NewFileSendCommand(discord, window))
|
||||
accountLogout := commandimpls.NewAccountLogout(runNext, window)
|
||||
accountLogout := commandimpls.NewAccountLogout(func() { SetupApplication(app) }, window)
|
||||
window.RegisterCommand(accountLogout)
|
||||
window.RegisterCommand(commandimpls.NewAccount(accountLogout, window))
|
||||
window.RegisterCommand(commandimpls.NewManualCommand(window))
|
||||
|
@ -156,24 +137,15 @@ func RunWithAccount(account string) {
|
|||
window.RegisterCommand(tfaDisableCmd)
|
||||
window.RegisterCommand(tfaBackupGetCmd)
|
||||
window.RegisterCommand(tfaBackupResetCmd)
|
||||
window.RegisterCommand(commandimpls.NewDMOpenCmd(discord, window))
|
||||
})
|
||||
}()
|
||||
|
||||
runError := app.Run()
|
||||
if runError != nil {
|
||||
log.Fatalf("Error launching View (%v).\n", runError)
|
||||
}
|
||||
|
||||
run := <-runNext
|
||||
if run {
|
||||
Run()
|
||||
}
|
||||
}
|
||||
|
||||
// Run launches the whole application and might abort in case
|
||||
// SetupApplication launches the whole application and might abort in case
|
||||
// it encounters an error.
|
||||
func Run() {
|
||||
RunWithAccount("")
|
||||
func SetupApplication(app *tview.Application) {
|
||||
SetupApplicationWithAccount(app, "")
|
||||
}
|
||||
|
||||
func attemptLogin(loginScreen *ui.Login, loginMessage string, configuration *config.Config) (*discordgo.Session, *discordgo.Ready) {
|
||||
|
@ -194,6 +166,11 @@ func attemptLogin(loginScreen *ui.Login, loginMessage string, configuration *con
|
|||
return attemptLogin(loginScreen, fmt.Sprintf("Error during last login attempt:\n\n[red]%s", discordError), configuration)
|
||||
}
|
||||
|
||||
if session == nil {
|
||||
configuration.Token = ""
|
||||
return attemptLogin(loginScreen, "Error during last login attempt:\n\n[red]Received session is nil", configuration)
|
||||
}
|
||||
|
||||
readyChan := make(chan *discordgo.Ready, 1)
|
||||
session.AddHandlerOnce(func(s *discordgo.Session, event *discordgo.Ready) {
|
||||
readyChan <- event
|
||||
|
|
18
appveyor.yml
18
appveyor.yml
|
@ -1,18 +0,0 @@
|
|||
clone_folder: c:\gopath\src\github.com\Bios-Marcel\cordless
|
||||
|
||||
environment:
|
||||
GOPATH: c:\gopath
|
||||
build: off
|
||||
stack: go 1.13
|
||||
|
||||
artifacts:
|
||||
- path: cordless.exe
|
||||
name: cordless.exe
|
||||
|
||||
build_script:
|
||||
- go get -v -d ./...
|
||||
- go build -o cordless.exe
|
||||
|
||||
test_script:
|
||||
- go vet ./...
|
||||
- go test ./...
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
go build -gcflags="all=-N -l" -o cordless_debug
|
|
@ -36,7 +36,7 @@ type Account struct {
|
|||
// in the users folder.
|
||||
type AccountLogout struct {
|
||||
window *ui.Window
|
||||
runNext chan bool
|
||||
restart func()
|
||||
}
|
||||
|
||||
// NewAccount creates a ready-to-use Account command.
|
||||
|
@ -45,8 +45,8 @@ func NewAccount(accountLogout *AccountLogout, window *ui.Window) *Account {
|
|||
}
|
||||
|
||||
// NewAccountLogout creates a ready-to-use Logout command.
|
||||
func NewAccountLogout(runNext chan bool, window *ui.Window) *AccountLogout {
|
||||
return &AccountLogout{window: window, runNext: runNext}
|
||||
func NewAccountLogout(restart func(), window *ui.Window) *AccountLogout {
|
||||
return &AccountLogout{window: window, restart: restart}
|
||||
}
|
||||
|
||||
// Execute runs the command piping its output into the supplied writer.
|
||||
|
@ -252,8 +252,8 @@ func (accountLogout *AccountLogout) saveAndRestart(writer io.Writer) error {
|
|||
}
|
||||
|
||||
//Using a go routine, so this instance doesn't stay alive and pollutes the memory.
|
||||
accountLogout.runNext <- true
|
||||
accountLogout.window.Shutdown()
|
||||
accountLogout.restart()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
package commandimpls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/discordutil"
|
||||
"github.com/Bios-Marcel/cordless/ui"
|
||||
"github.com/Bios-Marcel/discordgo"
|
||||
)
|
||||
|
||||
const (
|
||||
dmOpenHelpPage = `[::b]NAME
|
||||
dm-open - open or create a dm channel
|
||||
|
||||
[::b]SYNOPSIS
|
||||
[::b]dm-open <Username|Username#NNNN|User-ID>
|
||||
|
||||
[::b]DESCRIPTION
|
||||
If the user can be found in your local cache, a new dm channel is created
|
||||
or an existing one loaded.`
|
||||
)
|
||||
|
||||
// DMOpenCmd allows to open / create DM channels.
|
||||
type DMOpenCmd struct {
|
||||
session *discordgo.Session
|
||||
window *ui.Window
|
||||
}
|
||||
|
||||
// NewDMOpenCmd creates a ready to use command to open / create DM channels.
|
||||
func NewDMOpenCmd(session *discordgo.Session, window *ui.Window) *DMOpenCmd {
|
||||
return &DMOpenCmd{session, window}
|
||||
}
|
||||
|
||||
// Execute runs the command piping its output into the supplied writer.
|
||||
func (cmd *DMOpenCmd) Execute(writer io.Writer, parameters []string) {
|
||||
//We expect exactly one user
|
||||
if len(parameters) != 1 {
|
||||
cmd.PrintHelp(writer)
|
||||
return
|
||||
}
|
||||
|
||||
//FIXME Pretty much copied from friends.go. Can i somehow abstract this away?
|
||||
|
||||
users, err := cmd.session.State.Users()
|
||||
if err != nil {
|
||||
fmt.Fprintf(writer, "An error occured during commandexecution (%s).\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
input := parameters[0]
|
||||
var matches []*discordgo.User
|
||||
for _, user := range users {
|
||||
if user.ID == input || user.Username == input || user.String() == input {
|
||||
matches = append(matches, user)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
fmt.Fprintln(writer, "No user was found.")
|
||||
} else if len(matches) == 1 {
|
||||
user := matches[0]
|
||||
|
||||
//Can't message yourself, goon!
|
||||
if user.ID == cmd.session.State.User.ID {
|
||||
fmt.Fprintln(writer, "You can't message yourself.")
|
||||
return
|
||||
}
|
||||
|
||||
//If there's an existing channel, we use that and avoid unnecessary traffic.
|
||||
existingChannel := discordutil.FindDMChannelWithUser(cmd.session.State, user.ID)
|
||||
if existingChannel != nil {
|
||||
cmd.window.SwitchToPrivateChannel(existingChannel)
|
||||
return
|
||||
}
|
||||
|
||||
newChannel, createError := cmd.session.UserChannelCreate(user.ID)
|
||||
if createError != nil {
|
||||
fmt.Fprintf(writer, "Error opening DM (%s).\n", createError.Error())
|
||||
} else {
|
||||
cmd.window.SwitchToPrivateChannel(newChannel)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(writer, "Multiple matches were found for '%s'. Please be more precise.\n", input)
|
||||
fmt.Fprintln(writer, "The following matches were found:")
|
||||
for _, match := range matches {
|
||||
fmt.Fprintln(writer, " "+match.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PrintHelp prints a static help page for this command
|
||||
func (cmd *DMOpenCmd) PrintHelp(writer io.Writer) {
|
||||
fmt.Fprintln(writer, dmOpenHelpPage)
|
||||
}
|
||||
|
||||
// Name returns the primary name for this command. This name will also be
|
||||
// used for listing the command in the commandlist.
|
||||
func (cmd *DMOpenCmd) Name() string {
|
||||
return "dm-open"
|
||||
}
|
||||
|
||||
// Aliases are a list of aliases for this command. There might be none.
|
||||
func (cmd *DMOpenCmd) Aliases() []string {
|
||||
return []string{"dm-start", "dm-new", "dm-show"}
|
||||
}
|
|
@ -1,14 +1,19 @@
|
|||
package commandimpls
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Bios-Marcel/discordgo"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/commands"
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
"github.com/Bios-Marcel/cordless/ui"
|
||||
"github.com/Bios-Marcel/cordless/ui/tviewutil"
|
||||
|
@ -19,15 +24,26 @@ const fileSendDocumentation = `[::b]NAME
|
|||
file-send - send files from your local machine
|
||||
|
||||
[::b]SYNOPSIS
|
||||
[::b]file-send <FILE_PATH>...
|
||||
|
||||
[::b]file-send [OPTION[]... <FILE_PATH>...
|
||||
|
||||
[::b]DESCRIPTION
|
||||
The file-send command allows you to send multiple files to your current channel.
|
||||
|
||||
[::b]OPTIONS
|
||||
[::b]-b, --bulk
|
||||
Zips all files and sends them as a single file.
|
||||
Without this option, the folder structure won't be preserved.
|
||||
[::b]-r, --recursive
|
||||
Allow sending folders as well
|
||||
|
||||
[::b]EXAMPLES
|
||||
[gray]$ file-send ~/file.txt
|
||||
[gray]$ file-send -r ~/folder
|
||||
[gray]$ file-send -r -b ~/folder
|
||||
[gray]$ file-send ~/file1.txt ~/file2.txt
|
||||
[gray]$ file-send "~/file one.txt" ~/file2.txt`
|
||||
[gray]$ file-send "~/file one.txt" ~/file2.txt
|
||||
[gray]$ file-send -b "~/file one.txt" ~/file2.txt
|
||||
[gray]$ file-send -b -r "~/file one.txt" ~/folder ~/file2.txt`
|
||||
|
||||
// FileSend represents the command used to send multiple files to a channel.
|
||||
type FileSend struct {
|
||||
|
@ -56,33 +72,111 @@ func (cmd *FileSend) Execute(writer io.Writer, parameters []string) {
|
|||
return
|
||||
}
|
||||
|
||||
var filteredParameters []string
|
||||
//Will cause all files in folders to be upload. This counts for subfolders as well.
|
||||
var recursive bool
|
||||
//Puts all files into one zip.
|
||||
var bulk bool
|
||||
|
||||
//Parse flags and sort them out for further processing.
|
||||
for _, parameter := range parameters {
|
||||
if parameter == "-r" || parameter == "--recursive" {
|
||||
recursive = true
|
||||
} else if parameter == "-b" || parameter == "--bulk" {
|
||||
bulk = true
|
||||
} else {
|
||||
filteredParameters = append(filteredParameters, parameter)
|
||||
}
|
||||
}
|
||||
|
||||
//Assume that all leftofer parameters are paths and convert them to absolute paths.
|
||||
var consumablePaths []string
|
||||
for _, parameter := range filteredParameters {
|
||||
resolvedPath, resolveError := files.ToAbsolutePath(parameter)
|
||||
if resolveError != nil {
|
||||
fmt.Fprintf(writer, "["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]Error reading file:\n\t["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]%s\n", resolveError.Error())
|
||||
continue
|
||||
commands.PrintError(writer, "Error reading file", resolveError.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data, readError := ioutil.ReadFile(resolvedPath)
|
||||
if readError != nil {
|
||||
fmt.Fprintf(writer, "["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]Error reading file:\n\t["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]%s\n", readError.Error())
|
||||
continue
|
||||
}
|
||||
consumablePaths = append(consumablePaths, resolvedPath)
|
||||
}
|
||||
|
||||
dataChannel := bytes.NewReader(data)
|
||||
_, sendError := cmd.discord.ChannelFileSend(channel.ID, path.Base(resolvedPath), dataChannel)
|
||||
if sendError != nil {
|
||||
fmt.Fprintf(writer, "["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]Error sending file:\n\t["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]%s\n", sendError.Error())
|
||||
//If folders are not to be included, we error if any folder is found.
|
||||
if !recursive {
|
||||
for _, path := range consumablePaths {
|
||||
stats, statError := os.Stat(path)
|
||||
if statError != nil {
|
||||
if os.IsNotExist(statError) {
|
||||
commands.PrintError(writer, "Invalid input", fmt.Sprintf("'%s' doesn't exist", path))
|
||||
} else {
|
||||
commands.PrintError(writer, "Invalid input", statError.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if stats.IsDir() {
|
||||
commands.PrintError(writer, "Invalid input", "Directories can only be uploaded if the '-r' flag is set")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bulk {
|
||||
//We read and write at the same time to save performance and memory.
|
||||
zipOutput, zipInput := io.Pipe()
|
||||
|
||||
//While we write, we read in a background thread. We stay in
|
||||
//memory, instead of going over the filesystem.
|
||||
go func() {
|
||||
defer zipOutput.Close()
|
||||
_, sendError := cmd.discord.ChannelFileSend(channel.ID, "files.zip", zipOutput)
|
||||
if sendError != nil {
|
||||
fmt.Fprintf(writer, "["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]Error sending file:\n\t["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]%s\n", sendError.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
zipWriter := zip.NewWriter(zipInput)
|
||||
defer zipInput.Close()
|
||||
defer zipWriter.Close()
|
||||
for _, parameter := range consumablePaths {
|
||||
zipError := files.AddToZip(zipWriter, parameter)
|
||||
if zipError != nil {
|
||||
log.Println(zipError.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//We skip directories and flatten the folder structure.
|
||||
for _, filePath := range consumablePaths {
|
||||
filepath.Walk(filePath, func(file string, info os.FileInfo, err error) error {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, readError := ioutil.ReadFile(file)
|
||||
if readError != nil {
|
||||
fmt.Fprintf(writer, "["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]Error reading file:\n\t["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]%s\n", readError.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
dataChannel := bytes.NewReader(data)
|
||||
_, sendError := cmd.discord.ChannelFileSend(channel.ID, path.Base(file), dataChannel)
|
||||
if sendError != nil {
|
||||
fmt.Fprintf(writer, "["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]Error sending file:\n\t["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]%s\n", sendError.Error())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Name represents the main-name of the command.
|
||||
func (cmd *FileSend) Name() string {
|
||||
return "file-send"
|
||||
}
|
||||
|
||||
// Aliases represents all available aliases this command can be called with.
|
||||
func (cmd *FileSend) Aliases() []string {
|
||||
return []string{"filesend"}
|
||||
return []string{"filesend", "sendfile", "send-file", "file-upload", "upload-file"}
|
||||
}
|
||||
|
||||
// PrintHelp prints the help for the FileSend command.
|
||||
|
|
|
@ -45,7 +45,7 @@ func (fixLayout *FixLayout) Execute(writer io.Writer, parameters []string) {
|
|||
}
|
||||
|
||||
config.Current.UseFixedLayout = choice
|
||||
fixLayout.window.RefreshLayout()
|
||||
fixLayout.window.ApplyFixedLayoutSettings()
|
||||
|
||||
persistError := config.PersistConfig()
|
||||
if persistError != nil {
|
||||
|
@ -85,7 +85,7 @@ func (fixLayout *FixLayout) Execute(writer io.Writer, parameters []string) {
|
|||
return
|
||||
}
|
||||
|
||||
fixLayout.window.RefreshLayout()
|
||||
fixLayout.window.ApplyFixedLayoutSettings()
|
||||
|
||||
persistError := config.PersistConfig()
|
||||
if persistError != nil {
|
||||
|
|
|
@ -145,6 +145,9 @@ type Config struct {
|
|||
// application, as there are some childish goons that deem it funny
|
||||
// to impersonate people or change their name every 5 minutes.
|
||||
ShowNicknames bool
|
||||
// ShowReactionsInline decides whether reactions are displayed below a
|
||||
// message.
|
||||
ShowReactionsInline bool
|
||||
|
||||
// FileHandlers allow registering specific file-handers for certain
|
||||
FileOpenHandlers map[string]string
|
||||
|
@ -208,6 +211,7 @@ func createDefaultConfig() *Config {
|
|||
IndicateChannelAccessRestriction: false,
|
||||
ShowBottomBar: true,
|
||||
ShowNicknames: true,
|
||||
ShowReactionsInline: true,
|
||||
FileOpenHandlers: make(map[string]string),
|
||||
FileOpenSaveFilesPermanently: false,
|
||||
FileDownloadSaveLocation: "~/Downloads",
|
||||
|
@ -330,21 +334,22 @@ func getAbsolutePath(directoryPath string) (string, error) {
|
|||
return absolutePath, resolveError
|
||||
}
|
||||
|
||||
//LoadConfig loads the configuration initially and returns it.
|
||||
func LoadConfig() (*Config, error) {
|
||||
// LoadConfig loads the configuration. After loading the configuration, it can
|
||||
// be accessed via config.Current.
|
||||
func LoadConfig() error {
|
||||
configFilePath, configError := GetConfigFile()
|
||||
if configError != nil {
|
||||
return nil, configError
|
||||
return configError
|
||||
}
|
||||
|
||||
configFile, openError := os.Open(configFilePath)
|
||||
|
||||
if os.IsNotExist(openError) {
|
||||
return Current, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
if openError != nil {
|
||||
return nil, openError
|
||||
return openError
|
||||
}
|
||||
|
||||
defer configFile.Close()
|
||||
|
@ -353,10 +358,10 @@ func LoadConfig() (*Config, error) {
|
|||
|
||||
//io.EOF would mean empty, therefore we use defaults.
|
||||
if configLoadError != nil && configLoadError != io.EOF {
|
||||
return nil, configLoadError
|
||||
return configLoadError
|
||||
}
|
||||
|
||||
return Current, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCurrentToken updates the current token and all accounts where the
|
||||
|
|
|
@ -1,5 +1,15 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DisableUTF8 set to true will cause cordless to replace characters with a
|
||||
// codepoint higher than 65536 or a runewidth of more than one character.
|
||||
var DisableUTF8 = true
|
||||
var DisableUTF8 bool
|
||||
|
||||
func init() {
|
||||
wtSessionValue, avail := os.LookupEnv("WT_SESSION")
|
||||
DisableUTF8 = !avail || strings.TrimSpace(wtSessionValue) == ""
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"path/filepath"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Theme is a wrapper around the tview.Theme. This wrapper can be extended with
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"homepage": "https://github.com/Bios-Marcel/cordless",
|
||||
"description": "A third party discord client alternative.",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "2020-08-30",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/Bios-Marcel/cordless/releases/download/2020-08-30/cordless_64.exe",
|
||||
"bin": [ ["cordless_64.exe", "cordless"] ],
|
||||
"hash": "199b9733acdf990d2472b789d7bf868ab6cdd653f3745fc691caaab870fc384d"
|
||||
},
|
||||
"32bit": {
|
||||
"url": "https://github.com/Bios-Marcel/cordless/releases/download/2020-08-30/cordless_32.exe",
|
||||
"bin": [ ["cordless_32.exe", "cordless"] ],
|
||||
"hash": "644f623f807cd431d42fdd9821938a64904945fdf59288990ecb8b7eb3aeef74"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
dlv --listen=:2345 --headless=true --api-version=2 --accept-multiclient exec ./cordless_debug
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/Bios-Marcel/discordgo"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/readstate"
|
||||
"github.com/Bios-Marcel/cordless/ui/tviewutil"
|
||||
)
|
||||
|
||||
|
@ -28,15 +29,23 @@ func SortMessagesByTimestamp(messages []*discordgo.Message) {
|
|||
})
|
||||
}
|
||||
|
||||
// GetPrivateChannelName generates a name for a private channel.
|
||||
func GetPrivateChannelName(channel *discordgo.Channel) string {
|
||||
// GetPrivateChannelNameUnescaped generates a name for a private channel.
|
||||
// The name won't be escaped view tviewutil and therefore shouldn't be used
|
||||
// for displaying it in tview components.
|
||||
func GetPrivateChannelNameUnescaped(channel *discordgo.Channel) string {
|
||||
var channelName string
|
||||
if channel.Type == discordgo.ChannelTypeDM {
|
||||
//The first recipient should always be us!
|
||||
channelName = channel.Recipients[0].Username
|
||||
//Since the official client doesn't seem to allow creating nicks for
|
||||
//simple DMs, we assume it isn't possible.
|
||||
} else if channel.Type == discordgo.ChannelTypeGroupDM {
|
||||
//Groups can have custom names.
|
||||
if channel.Name != "" {
|
||||
channelName = channel.Name
|
||||
} else {
|
||||
//Channels can have nicknames, but if they don't the default discord
|
||||
//client just displays the recipients names sticked together.
|
||||
for index, recipient := range channel.Recipients {
|
||||
if index == 0 {
|
||||
channelName = recipient.Username
|
||||
|
@ -47,11 +56,34 @@ func GetPrivateChannelName(channel *discordgo.Channel) string {
|
|||
}
|
||||
}
|
||||
|
||||
//This is a fallback, so we don't have an empty string.
|
||||
//This happens sometimes, I am unsure when though.
|
||||
if channelName == "" {
|
||||
channelName = "Unnamed"
|
||||
}
|
||||
|
||||
return tviewutil.Escape(channelName)
|
||||
return channelName
|
||||
}
|
||||
|
||||
// GetPrivateChannelName generates a name for a private channel.
|
||||
func GetPrivateChannelName(channel *discordgo.Channel) string {
|
||||
return tviewutil.Escape(GetPrivateChannelNameUnescaped(channel))
|
||||
}
|
||||
|
||||
// FindDMChannelWithUser tries to find a DM channel with the specified user as
|
||||
// one of its two recipients. If no channel is found, nil is returned.
|
||||
func FindDMChannelWithUser(state *discordgo.State, userID string) *discordgo.Channel {
|
||||
for _, privateChannel := range state.PrivateChannels {
|
||||
if privateChannel.Type == discordgo.ChannelTypeDM {
|
||||
for _, recipient := range privateChannel.Recipients {
|
||||
if recipient.ID == userID {
|
||||
return privateChannel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompareChannels checks which channel is smaller. Smaller meaning it is the
|
||||
|
@ -87,3 +119,50 @@ func HasReadMessagesPermission(channelID string, state *discordgo.State) bool {
|
|||
}
|
||||
return (userPermissions & discordgo.PermissionViewChannel) > 0
|
||||
}
|
||||
|
||||
// AcknowledgeChannel acknowledges all messages in the given channel. If the
|
||||
// channel is a category, all children will be acknowledged.
|
||||
func AcknowledgeChannel(session *discordgo.Session, channelID string) error {
|
||||
channel, stateError := session.State.Channel(channelID)
|
||||
if stateError != nil {
|
||||
return stateError
|
||||
}
|
||||
|
||||
//Bulk-Acknowledge of categories
|
||||
if channel.Type == discordgo.ChannelTypeGuildCategory {
|
||||
guild, stateError := session.State.Guild(channel.GuildID)
|
||||
if stateError != nil {
|
||||
return stateError
|
||||
}
|
||||
|
||||
var channelsToAck []*discordgo.Channel
|
||||
for _, guildChannel := range guild.Channels {
|
||||
if guildChannel.ParentID != channel.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
//These can't have messages. Store is dead anyways, so we needn't handle it.
|
||||
if guildChannel.Type == discordgo.ChannelTypeGuildVoice {
|
||||
continue
|
||||
}
|
||||
|
||||
if guildChannel.LastMessageID == "" || readstate.HasBeenRead(guildChannel, guildChannel.LastMessageID) {
|
||||
continue
|
||||
}
|
||||
|
||||
channelsToAck = append(channelsToAck, guildChannel)
|
||||
}
|
||||
|
||||
if len(channelsToAck) > 0 {
|
||||
ackError := session.BulkChannelMessageAck(channelsToAck)
|
||||
return ackError
|
||||
}
|
||||
} else {
|
||||
if channel.LastMessageID != "" && !readstate.HasBeenRead(channel, channel.LastMessageID) {
|
||||
_, ackError := session.ChannelMessageAck(channel.ID, channel.LastMessageID, "")
|
||||
return ackError
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -3,11 +3,14 @@ package discordutil
|
|||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Bios-Marcel/discordgo"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/times"
|
||||
"github.com/Bios-Marcel/cordless/util/files"
|
||||
)
|
||||
|
||||
// MentionsCurrentUserExplicitly checks whether the message contains any
|
||||
|
@ -26,7 +29,12 @@ func MentionsCurrentUserExplicitly(state *discordgo.State, message *discordgo.Me
|
|||
// channels. This is satisfied by the discordgo.Session struct and can be
|
||||
// used in order to make testing easier.
|
||||
type MessageDataSupplier interface {
|
||||
ChannelMessages(string, int, string, string, string) ([]*discordgo.Message, error)
|
||||
// ChannelMessages fetches up to 100 messages for a channel.
|
||||
// The parameter beforeID defines whether message only older than
|
||||
// a specific message should be returned. The parameter afterID does
|
||||
// the same but for newer messages. The parameter aroundID is a mix of
|
||||
// both.
|
||||
ChannelMessages(channelID string, limit int, beforeID string, afterID string, aroundID string) ([]*discordgo.Message, error)
|
||||
}
|
||||
|
||||
// MessageLoader represents a util object that remember which channels have
|
||||
|
@ -43,6 +51,8 @@ func (l *MessageLoader) IsCached(channelID string) bool {
|
|||
return cached && value
|
||||
}
|
||||
|
||||
// CreateMessageLoader creates a MessageLoader using the given
|
||||
// MessageDataSupplier. It is empty and can be used right away.
|
||||
func CreateMessageLoader(messageDataSupplier MessageDataSupplier) *MessageLoader {
|
||||
loader := &MessageLoader{
|
||||
requestedChannels: make(map[string]bool),
|
||||
|
@ -63,44 +73,53 @@ func (l *MessageLoader) DeleteFromCache(channelID string) {
|
|||
// were sent, less will be returned. As soon as a channel has been loaded once
|
||||
// it won't ever be loaded again, instead a global cache will be accessed.
|
||||
func (l *MessageLoader) LoadMessages(channel *discordgo.Channel) ([]*discordgo.Message, error) {
|
||||
var messages []*discordgo.Message
|
||||
|
||||
if channel.LastMessageID != "" {
|
||||
if !l.IsCached(channel.ID) {
|
||||
l.requestedChannels[channel.ID] = true
|
||||
|
||||
var beforeID string
|
||||
localMessageCount := len(channel.Messages)
|
||||
if localMessageCount > 0 {
|
||||
beforeID = channel.Messages[0].ID
|
||||
}
|
||||
|
||||
messagesToGet := 100 - localMessageCount
|
||||
if messagesToGet > 0 {
|
||||
var discordError error
|
||||
messages, discordError = l.messageDateSupplier.ChannelMessages(channel.ID, messagesToGet, beforeID, "", "")
|
||||
if discordError != nil {
|
||||
return nil, discordError
|
||||
}
|
||||
|
||||
if channel.GuildID != "" {
|
||||
for _, message := range messages {
|
||||
message.GuildID = channel.GuildID
|
||||
}
|
||||
}
|
||||
if localMessageCount == 0 {
|
||||
channel.Messages = messages
|
||||
} else {
|
||||
//There are already messages in cache; However, those came from updates events.
|
||||
//Therefore those have to be newer than the newly retrieved ones.
|
||||
channel.Messages = append(messages, channel.Messages...)
|
||||
}
|
||||
}
|
||||
}
|
||||
messages = channel.Messages
|
||||
//Empty channels are never marked as cached and needn't be loaded.
|
||||
if channel.LastMessageID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
//If it's already cached, we assume that it contains all existing messages.
|
||||
if l.IsCached(channel.ID) {
|
||||
return channel.Messages, nil
|
||||
}
|
||||
|
||||
var beforeID string
|
||||
localMessageCount := len(channel.Messages)
|
||||
if localMessageCount > 0 {
|
||||
beforeID = channel.Messages[0].ID
|
||||
}
|
||||
|
||||
//We might not have all messages, as we might have received message due to
|
||||
//update events, which doesn't include the previously sent messages. This
|
||||
//however only matters if we haven't already reached 100 or more messages
|
||||
//via update events.
|
||||
messagesToGet := 100 - localMessageCount
|
||||
if messagesToGet > 0 {
|
||||
messages, discordError := l.messageDateSupplier.ChannelMessages(channel.ID, messagesToGet, beforeID, "", "")
|
||||
if discordError != nil {
|
||||
return nil, discordError
|
||||
}
|
||||
|
||||
//Workaround for a bug where messages were lacking the GuildID.
|
||||
if channel.GuildID != "" {
|
||||
for _, message := range messages {
|
||||
message.GuildID = channel.GuildID
|
||||
}
|
||||
}
|
||||
|
||||
if localMessageCount == 0 {
|
||||
channel.Messages = messages
|
||||
} else {
|
||||
//There are already messages in cache; However, those came from
|
||||
//updates events, meaning those have to be newer than the
|
||||
//requested ones.
|
||||
channel.Messages = append(messages, channel.Messages...)
|
||||
}
|
||||
}
|
||||
|
||||
l.requestedChannels[channel.ID] = true
|
||||
|
||||
return channel.Messages, nil
|
||||
}
|
||||
|
||||
// SendMessageAsFile sends the given message into the given channel using the
|
||||
|
@ -125,18 +144,19 @@ func SendMessageAsFile(session *discordgo.Session, message string, channel strin
|
|||
}
|
||||
}
|
||||
|
||||
// Generates a Quote using the given Input. The `messageAfterQuote` will be
|
||||
// appended after the quote in case it is not empty.
|
||||
// GenerateQuote formats a message quote using the given Input. The
|
||||
// `messageAfterQuote` will be appended after the quote in case it is not
|
||||
// empty.
|
||||
func GenerateQuote(message, author string, time discordgo.Timestamp, attachments []*discordgo.MessageAttachment, messageAfterQuote string) (string, error) {
|
||||
messageTime, parseError := time.Parse()
|
||||
if parseError != nil {
|
||||
return "", parseError
|
||||
}
|
||||
|
||||
// All quotes should be UTC.
|
||||
// All quotes should be UTC in order to not confuse quote-readers.
|
||||
messageTimeUTC := messageTime.UTC()
|
||||
|
||||
quotedMessage := strings.ReplaceAll(message, "\n", "\n> ")
|
||||
|
||||
if len(attachments) > 0 {
|
||||
var attachmentsAsText string
|
||||
for index, attachment := range attachments {
|
||||
|
@ -147,17 +167,129 @@ func GenerateQuote(message, author string, time discordgo.Timestamp, attachments
|
|||
}
|
||||
}
|
||||
|
||||
//If the quoted message ends with a "useless" quote-line-prefix
|
||||
//we simply "reuse" that line to not add unnecessary newlines.
|
||||
if strings.HasSuffix(quotedMessage, "> ") {
|
||||
quotedMessage = quotedMessage + attachmentsAsText
|
||||
} else {
|
||||
quotedMessage = quotedMessage + "\n> " + attachmentsAsText
|
||||
}
|
||||
}
|
||||
quotedMessage = fmt.Sprintf("> **%s** %s UTC:\n> %s\n", author, times.TimeToString(&messageTimeUTC), quotedMessage)
|
||||
currentContent := strings.TrimSpace(messageAfterQuote)
|
||||
if currentContent != "" {
|
||||
quotedMessage = quotedMessage + currentContent
|
||||
|
||||
return fmt.Sprintf("> **%s** %s UTC:\n> %s\n%s", author,
|
||||
times.TimeToString(&messageTimeUTC), quotedMessage,
|
||||
strings.TrimSpace(messageAfterQuote)),
|
||||
nil
|
||||
}
|
||||
|
||||
// MessageToPlainText converts a discord message to a human readable text.
|
||||
// Markdown characters are reserved and file attachments are added as URLs.
|
||||
// Embeds are currently not being handled, nor are other special elements.
|
||||
func MessageToPlainText(message *discordgo.Message) string {
|
||||
content := message.ContentWithMentionsReplaced()
|
||||
builder := &strings.Builder{}
|
||||
|
||||
if content != "" {
|
||||
builder.Grow(len(content))
|
||||
builder.WriteString(content)
|
||||
}
|
||||
|
||||
return quotedMessage, nil
|
||||
if len(message.Attachments) > 0 {
|
||||
builder.Grow(1)
|
||||
builder.WriteRune('\n')
|
||||
|
||||
if len(message.Attachments) == 1 {
|
||||
builder.Grow(len(message.Attachments[0].URL))
|
||||
builder.WriteString(message.Attachments[0].URL)
|
||||
} else if len(message.Attachments) > 1 {
|
||||
links := make([]string, 0, len(message.Attachments))
|
||||
for _, file := range message.Attachments {
|
||||
links = append(links, file.URL)
|
||||
}
|
||||
|
||||
linksAsText := strings.Join(links, "\n")
|
||||
builder.Grow(len(linksAsText))
|
||||
builder.WriteString(linksAsText)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// ResolveFilePathAndSendFile will attempt to resolve the message and see if
|
||||
// it points to a file on the users harddrive. If so, it's sent to the given
|
||||
// channel using it's basename as the discord filename.
|
||||
func ResolveFilePathAndSendFile(session *discordgo.Session, message, targetChannelID string) error {
|
||||
path, pathError := files.ToAbsolutePath(message)
|
||||
if pathError != nil {
|
||||
return pathError
|
||||
}
|
||||
data, readError := ioutil.ReadFile(path)
|
||||
if readError != nil {
|
||||
return readError
|
||||
}
|
||||
reader := bytes.NewBuffer(data)
|
||||
_, sendError := session.ChannelFileSend(targetChannelID, filepath.Base(message), reader)
|
||||
return sendError
|
||||
}
|
||||
|
||||
// ReplaceMentions replaces both user mentions and global mentions like @here
|
||||
// and @everyone.
|
||||
func ReplaceMentions(message *discordgo.Message) string {
|
||||
replaceInstructions := make([]string, 0, len(message.Mentions)+4)
|
||||
replaceInstructions = append(replaceInstructions, "@here", "@\u200Bhere", "@everyone", "@\u200Beveryone")
|
||||
for _, user := range message.Mentions {
|
||||
replaceInstructions = append(replaceInstructions,
|
||||
"<@"+user.ID+">", "@"+user.Username,
|
||||
"<@!"+user.ID+">", "@"+user.Username)
|
||||
}
|
||||
return strings.NewReplacer(replaceInstructions...).Replace(message.Content)
|
||||
}
|
||||
|
||||
// HandleReactionAdd adds a new reaction to a message or updates the count if
|
||||
// that message already has a reaction with that same emoji.
|
||||
func HandleReactionAdd(state *discordgo.State,
|
||||
message *discordgo.Message,
|
||||
newReaction *discordgo.MessageReactionAdd) {
|
||||
for _, reaction := range message.Reactions {
|
||||
//Only custom emojis have IDs and non custom unes have unique names.
|
||||
if reaction.Emoji.ID == newReaction.Emoji.ID && reaction.Emoji.Name == newReaction.Emoji.Name {
|
||||
//Match found, so we can add one to the count.
|
||||
reaction.Count++
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
//FIXME Better look up emoji in cache if possible?
|
||||
message.Reactions = append(message.Reactions, &discordgo.MessageReactions{
|
||||
Count: 1,
|
||||
Emoji: &newReaction.Emoji,
|
||||
Me: newReaction.UserID == state.User.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleReactionRemove removes an existing reaction to a message or updates
|
||||
// the count if the same message still has reactions with the same emoji left.
|
||||
func HandleReactionRemove(state *discordgo.State,
|
||||
message *discordgo.Message,
|
||||
newReaction *discordgo.MessageReactionRemove) {
|
||||
for index, reaction := range message.Reactions {
|
||||
//Only custom emojis have IDs and non custom unes have unique names.
|
||||
if reaction.Emoji.ID == newReaction.Emoji.ID && reaction.Emoji.Name == newReaction.Emoji.Name {
|
||||
if reaction.Count <= 1 {
|
||||
message.Reactions = append(message.Reactions[:index], message.Reactions[index+1:]...)
|
||||
//No more reactions of that emoji would be left, therefore we remove the array entry.
|
||||
} else {
|
||||
//Only a single user removed his reaction, so we keep the array entry.
|
||||
reaction.Count--
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleReactionRemoveAll removes all reactions from all users in a message.
|
||||
func HandleReactionRemoveAll(state *discordgo.State,
|
||||
message *discordgo.Message) {
|
||||
message.Reactions = message.Reactions[0:0]
|
||||
}
|
||||
|
|
|
@ -200,6 +200,7 @@ func TestGenerateQuote(t *testing.T) {
|
|||
message string
|
||||
author string
|
||||
time discordgo.Timestamp
|
||||
attachments []*discordgo.MessageAttachment
|
||||
messageAfterQuote string
|
||||
}
|
||||
tests := []struct {
|
||||
|
@ -318,17 +319,55 @@ func TestGenerateQuote(t *testing.T) {
|
|||
},
|
||||
want: "",
|
||||
wantErr: true,
|
||||
}, {
|
||||
name: "single line plus single attachment, no sender message",
|
||||
args: args{
|
||||
message: "f",
|
||||
author: "author",
|
||||
attachments: []*discordgo.MessageAttachment{
|
||||
{
|
||||
URL: "https://download/this.zip",
|
||||
},
|
||||
},
|
||||
time: discordgo.Timestamp("2019-10-28T21:30:57.003000+00:00"),
|
||||
messageAfterQuote: "",
|
||||
},
|
||||
want: "> **author** 21:30:57 UTC:\n" +
|
||||
"> f\n" +
|
||||
"> https://download/this.zip\n",
|
||||
wantErr: false,
|
||||
}, {
|
||||
name: "single line plus two attachment, no sender message",
|
||||
args: args{
|
||||
message: "f",
|
||||
author: "author",
|
||||
attachments: []*discordgo.MessageAttachment{
|
||||
{
|
||||
URL: "https://download/this.zip",
|
||||
},
|
||||
{
|
||||
URL: "https://download/thistoo.zip",
|
||||
},
|
||||
},
|
||||
time: discordgo.Timestamp("2019-10-28T21:30:57.003000+00:00"),
|
||||
messageAfterQuote: "",
|
||||
},
|
||||
want: "> **author** 21:30:57 UTC:\n" +
|
||||
"> f\n" +
|
||||
"> https://download/this.zip\n" +
|
||||
"> https://download/thistoo.zip\n",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GenerateQuote(tt.args.message, tt.args.author, tt.args.time, nil, tt.args.messageAfterQuote)
|
||||
got, err := GenerateQuote(tt.args.message, tt.args.author, tt.args.time, tt.args.attachments, tt.args.messageAfterQuote)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GenerateQuote() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("GenerateQuote() got = %v, want %v", got, tt.want)
|
||||
t.Errorf("GenerateQuote() got = '%v', want '%v'", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package femto
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Colorscheme is a map from string to style -- it represents a colorscheme
|
||||
|
|
|
@ -2,9 +2,10 @@ package main
|
|||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.BoolVar(&config.DisableUTF8, "disable-UTF8", true, "Replaces certain characters with question marks in order to avoid broken rendering")
|
||||
flag.BoolVar(&config.DisableUTF8, "disable-UTF8", config.DisableUTF8, "Replaces certain characters with question marks in order to avoid broken rendering")
|
||||
}
|
||||
|
|
11
go.mod
11
go.mod
|
@ -4,25 +4,22 @@ go 1.12
|
|||
|
||||
require (
|
||||
github.com/Bios-Marcel/discordemojimap v1.0.1
|
||||
github.com/Bios-Marcel/discordgo v0.21.2-0.20200810175449-28b4ad667cd5
|
||||
github.com/Bios-Marcel/discordgo v0.21.2-0.20201025194456-7046b5509389
|
||||
github.com/Bios-Marcel/goclipimg v0.0.0-20191117180634-d0f7b06fbe82
|
||||
github.com/Bios-Marcel/shortnotforlong v1.1.1
|
||||
github.com/alecthomas/chroma v0.6.6
|
||||
github.com/alecthomas/chroma v0.7.3
|
||||
github.com/atotto/clipboard v0.1.2
|
||||
github.com/dlclark/regexp2 v1.2.0 // indirect
|
||||
github.com/gdamore/tcell v1.4.0
|
||||
github.com/gdamore/tcell/v2 v2.0.0
|
||||
github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28
|
||||
github.com/google/go-github/v29 v29.0.3
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3
|
||||
github.com/mattn/go-isatty v0.0.12 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.9
|
||||
github.com/mdp/qrterminal/v3 v3.0.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rivo/uniseg v0.1.0
|
||||
github.com/robertkrimen/otto v0.0.0-20191219234010-c382bd3c16ff
|
||||
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac
|
||||
github.com/sergi/go-diff v1.1.0
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 // indirect
|
||||
gopkg.in/sourcemap.v1 v1.0.5 // indirect
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
|
61
go.sum
61
go.sum
|
@ -1,40 +1,33 @@
|
|||
github.com/Bios-Marcel/discordemojimap v1.0.1 h1:b3UYPO7+h1+ciStkwU/KQCerOmpUNPHsBf4a7EjMdys=
|
||||
github.com/Bios-Marcel/discordemojimap v1.0.1/go.mod h1:AoHIpUwf3EVCAAUmk+keXjb9khyZcFnW84/rhJd4IkU=
|
||||
github.com/Bios-Marcel/discordgo v0.21.2-0.20200810175449-28b4ad667cd5 h1:9Rp5fAYhne7jA3w5iJYvuMAaG3fq2RkE7cC5h+RCnZY=
|
||||
github.com/Bios-Marcel/discordgo v0.21.2-0.20200810175449-28b4ad667cd5/go.mod h1:bLnfQU0j/SejmPozgW5GepmKvd8CrbMIml2I0IZENVE=
|
||||
github.com/Bios-Marcel/discordgo v0.21.2-0.20201025194456-7046b5509389 h1:Ha9AEp9HwOZElVwWWiA/aYU4g6dOYwJIhnxYf8XsNSo=
|
||||
github.com/Bios-Marcel/discordgo v0.21.2-0.20201025194456-7046b5509389/go.mod h1:tHeTdL2sSGuZcRHa+lp+SteLEy/XdVQisWlsMXa33L4=
|
||||
github.com/Bios-Marcel/goclipimg v0.0.0-20191117180634-d0f7b06fbe82 h1:gspJ6CW9bhboosSISmuX2iq03pUsYHzlJN0s+z4fz4E=
|
||||
github.com/Bios-Marcel/goclipimg v0.0.0-20191117180634-d0f7b06fbe82/go.mod h1:hiFR6fH5+uc/f2yK2syh/UfzaPfGo6F2HJSoiI4ufWU=
|
||||
github.com/Bios-Marcel/shortnotforlong v1.1.1 h1:cCJIp6MODd4rwH5Y+fAMAaIuFxS1FPKfwDbRzsCU9nM=
|
||||
github.com/Bios-Marcel/shortnotforlong v1.1.1/go.mod h1:g6bFiwq0pq7pqENRgHiCZu7uMzeYPIXwANlaBQ47LBw=
|
||||
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
|
||||
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
|
||||
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
||||
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
|
||||
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
|
||||
github.com/alecthomas/chroma v0.6.6 h1:AwWMP1sWgMNgEiptNtV/T5GWOLtZFDdrc2ZfWx1ogmg=
|
||||
github.com/alecthomas/chroma v0.6.6/go.mod h1:zVlgtbRS7BJDrDY9SB238RmpoCBCYFlLmcfZ3durxTk=
|
||||
github.com/alecthomas/chroma v0.7.3 h1:NfdAERMy+esYQs8OXk0I868/qDxxCEo7FMz1WIqMAeI=
|
||||
github.com/alecthomas/chroma v0.7.3/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM=
|
||||
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
|
||||
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
|
||||
github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
|
||||
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
|
||||
github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA=
|
||||
github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
|
||||
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
|
||||
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
|
||||
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
|
||||
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
|
||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
|
||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
|
||||
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU=
|
||||
github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0=
|
||||
github.com/gdamore/tcell/v2 v2.0.0 h1:GRWG8aLfWAlekj9Q6W29bVvkHENc6hp79XOqG4AWDOs=
|
||||
github.com/gdamore/tcell/v2 v2.0.0/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
|
||||
github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28 h1:M2Zt3G2w6Q57GZndOYk42p7RvMeO8izO8yKTfIxGqxA=
|
||||
github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28/go.mod h1:ElSskYZe3oM8kThaHGJ+kiN2yyUMVXMZ7WxF9QqLDS8=
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
|
||||
|
@ -50,14 +43,8 @@ github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c h1:16eHWuMGvCjSf
|
|||
github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherwasm v1.1.0 h1:fA2uLoctU5+T3OhOn2vYP0DVT6pxc7xhTlBB1paATqQ=
|
||||
github.com/gopherjs/gopherwasm v1.1.0/go.mod h1:SkZ8z7CWBz5VXbhJel8TxCmAcsQqzgWGR/8nMhyhZSI=
|
||||
github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
|
||||
github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
|
@ -65,14 +52,11 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
|||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=
|
||||
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
|
@ -80,12 +64,8 @@ github.com/mdp/qrterminal v1.0.1 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c=
|
|||
github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ=
|
||||
github.com/mdp/qrterminal/v3 v3.0.0 h1:ywQqLRBXWTktytQNDKFjhAvoGkLVN3J2tAFZ0kMd9xQ=
|
||||
github.com/mdp/qrterminal/v3 v3.0.0/go.mod h1:NJpfAs7OAm77Dy8EkWrtE4aq+cE6McoLXlBqXQEwvE0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
|
@ -93,45 +73,38 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robertkrimen/otto v0.0.0-20191219234010-c382bd3c16ff h1:+6NUiITWwE5q1KO6SAfUX918c+Tab0+tGAM/mtdlUyA=
|
||||
github.com/robertkrimen/otto v0.0.0-20191219234010-c382bd3c16ff/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=
|
||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac h1:kYPjbEN6YPYWWHI6ky1J813KzIq/8+Wg4TO4xU7A/KU=
|
||||
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
|
||||
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190403202508-8e1b8d32e692 h1:GRhHqDOgeDr6QDTtq9gn2O4iKvm5dsbfqD/TXb0KLX0=
|
||||
golang.org/x/crypto v0.0.0-20190403202508-8e1b8d32e692/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e h1:nFYrTHrdrAOpShe27kaFHjsqYSEQ0KWqdWLu3xuZJts=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY=
|
||||
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultWriter io.Writer
|
||||
additionalWriter io.Writer
|
||||
)
|
||||
|
||||
type doubleLogger struct {
|
||||
defaultLogger io.Writer
|
||||
additionalLogger io.Writer
|
||||
}
|
||||
|
||||
// SetAdditionalOutput defines where we log to. This wraps the writer with another
|
||||
// writer to allow a second logging location.
|
||||
func SetAdditionalOutput(newAdditionalWriter io.Writer) {
|
||||
additionalWriter = newAdditionalWriter
|
||||
updateLogger()
|
||||
}
|
||||
|
||||
// SetDefaultOutput defines the default location for logoutput. This can't
|
||||
// be overriden by calling SetOutput.
|
||||
func SetDefaultOutput(newDefaultWriter io.Writer) {
|
||||
defaultWriter = newDefaultWriter
|
||||
updateLogger()
|
||||
|
||||
}
|
||||
|
||||
func updateLogger() {
|
||||
if defaultWriter != nil && additionalWriter != nil {
|
||||
log.SetOutput(&doubleLogger{
|
||||
defaultLogger: defaultWriter,
|
||||
additionalLogger: additionalWriter,
|
||||
})
|
||||
} else if defaultWriter != nil {
|
||||
log.SetOutput(defaultWriter)
|
||||
} else if additionalWriter != nil {
|
||||
log.SetOutput(additionalWriter)
|
||||
} else {
|
||||
log.SetOutput(os.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// Write redirects the output to both the default logger and the additional
|
||||
// logger. If any is null, it is skipped. If any errors, an error is returned.
|
||||
func (l *doubleLogger) Write(p []byte) (n int, err error) {
|
||||
var (
|
||||
count int
|
||||
writeErrorDefault error
|
||||
writeErrorAdditional error
|
||||
)
|
||||
if l.defaultLogger != nil {
|
||||
count, writeErrorDefault = l.defaultLogger.Write(p)
|
||||
}
|
||||
if l.additionalLogger != nil {
|
||||
count, writeErrorAdditional = l.additionalLogger.Write(p)
|
||||
}
|
||||
|
||||
if writeErrorDefault != nil {
|
||||
return 0, writeErrorDefault
|
||||
}
|
||||
|
||||
if writeErrorAdditional != nil {
|
||||
return 0, writeErrorAdditional
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
38
main.go
38
main.go
|
@ -5,9 +5,13 @@ package main
|
|||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/app"
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
"github.com/Bios-Marcel/cordless/logging"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/Bios-Marcel/cordless/ui/shortcutdialog"
|
||||
"github.com/Bios-Marcel/cordless/version"
|
||||
)
|
||||
|
@ -19,8 +23,18 @@ func main() {
|
|||
setScriptDirectory := flag.String("script-dir", "", "Sets the script directory")
|
||||
setConfigFilePath := flag.String("config-file", "", "Sets exact path of the configuration file")
|
||||
accountToUse := flag.String("account", "", "Defines which account cordless tries to load")
|
||||
logPath := flag.String("log", "", "Defines what file we log to")
|
||||
flag.Parse()
|
||||
|
||||
if logPath != nil && *logPath != "" {
|
||||
logFile, openError := os.OpenFile(*logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||
if openError != nil {
|
||||
panic(openError)
|
||||
}
|
||||
defer logFile.Close()
|
||||
logging.SetDefaultOutput(logFile)
|
||||
}
|
||||
|
||||
if setConfigDirectory != nil {
|
||||
config.SetConfigDirectory(*setConfigDirectory)
|
||||
}
|
||||
|
@ -31,15 +45,35 @@ func main() {
|
|||
config.SetConfigFile(*setConfigFilePath)
|
||||
}
|
||||
|
||||
//Making sure both the main app and the shortcuts dialog have the
|
||||
//correct theme and configuration files.
|
||||
configLoadError := config.LoadConfig()
|
||||
if configLoadError != nil {
|
||||
log.Fatalf("Error loading configuration file (%s).\n", configLoadError.Error())
|
||||
}
|
||||
themeLoadingError := config.LoadTheme()
|
||||
if themeLoadingError != nil {
|
||||
panic(themeLoadingError)
|
||||
}
|
||||
tview.Styles = *config.GetTheme().Theme
|
||||
|
||||
if showShortcutsDialog != nil && *showShortcutsDialog {
|
||||
shortcutdialog.RunShortcutsDialogStandalone()
|
||||
} else if showVersion != nil && *showVersion {
|
||||
fmt.Printf("You are running cordless version %s\nKeep in mind that this version might not be correct for manually built versions, as those can contain additional commits.\n", version.Version)
|
||||
} else {
|
||||
//App that will be reused throughout the process runtime.
|
||||
tviewApp := tview.NewApplication()
|
||||
|
||||
if accountToUse != nil && *accountToUse != "" {
|
||||
app.RunWithAccount(*accountToUse)
|
||||
app.SetupApplicationWithAccount(tviewApp, *accountToUse)
|
||||
} else {
|
||||
app.Run()
|
||||
app.SetupApplication(tviewApp)
|
||||
}
|
||||
|
||||
runError := tviewApp.Run()
|
||||
if runError != nil {
|
||||
log.Fatalf("Error launching View (%v).\n", runError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,12 +6,11 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Bios-Marcel/discordgo"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/discordutil"
|
||||
)
|
||||
|
||||
var (
|
||||
data = make(map[string]uint64)
|
||||
mentions = make(map[string]bool)
|
||||
readStateMutex = &sync.Mutex{}
|
||||
timerMutex = &sync.Mutex{}
|
||||
ackTimers = make(map[string]*time.Timer)
|
||||
|
@ -37,6 +36,10 @@ func Load(sessionState *discordgo.State) {
|
|||
}
|
||||
|
||||
data[channelState.ID] = parsed
|
||||
|
||||
if channelState.MentionCount > 0 {
|
||||
mentions[channelState.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -57,14 +60,16 @@ func ClearReadStateFor(channelID string) {
|
|||
// anything to the Discord API. The update will only be applied if the new
|
||||
// message ID is greater than the old one.
|
||||
func UpdateReadLocal(channelID string, lastMessageID string) bool {
|
||||
readStateMutex.Lock()
|
||||
defer readStateMutex.Unlock()
|
||||
|
||||
delete(mentions, channelID)
|
||||
|
||||
parsed, parseError := strconv.ParseUint(lastMessageID, 10, 64)
|
||||
if parseError != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
readStateMutex.Lock()
|
||||
defer readStateMutex.Unlock()
|
||||
|
||||
old, isPresent := data[channelID]
|
||||
if !isPresent || old < parsed {
|
||||
data[channelID] = parsed
|
||||
|
@ -81,6 +86,8 @@ func UpdateRead(session *discordgo.Session, channel *discordgo.Channel, lastMess
|
|||
readStateMutex.Lock()
|
||||
defer readStateMutex.Unlock()
|
||||
|
||||
delete(mentions, channel.ID)
|
||||
|
||||
// Avoid unnecessary traffic
|
||||
if hasBeenReadWithoutLocking(channel, lastMessageID) {
|
||||
return nil
|
||||
|
@ -122,7 +129,7 @@ func UpdateReadBuffered(session *discordgo.Session, channel *discordgo.Channel,
|
|||
func IsGuildMuted(guildID string) bool {
|
||||
for _, settings := range state.UserGuildSettings {
|
||||
if settings.GuildID == guildID {
|
||||
if settings.Muted {
|
||||
if settings.Muted && isStillMuted(settings.MuteConfig) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -146,7 +153,7 @@ func HasGuildBeenRead(guildID string) bool {
|
|||
defer readStateMutex.Unlock()
|
||||
|
||||
for _, channel := range realGuild.Channels {
|
||||
if !discordutil.HasReadMessagesPermission(channel.ID, state) {
|
||||
if !hasReadMessagesPermission(channel.ID, state) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -159,6 +166,55 @@ func HasGuildBeenRead(guildID string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
//HACK Had to copy this from discordutil/channel.go due to import cycle.
|
||||
func hasReadMessagesPermission(channelID string, state *discordgo.State) bool {
|
||||
userPermissions, err := state.UserChannelPermissions(state.User.ID, channelID)
|
||||
if err != nil {
|
||||
// Unable to access channel permissions.
|
||||
return false
|
||||
}
|
||||
return (userPermissions & discordgo.PermissionViewChannel) > 0
|
||||
}
|
||||
|
||||
// HasGuildBeenMentioned checks whether any channel in the guild mentioned
|
||||
// the currently logged in user.
|
||||
func HasGuildBeenMentioned(guildID string) bool {
|
||||
if IsGuildMuted(guildID) {
|
||||
return false
|
||||
}
|
||||
|
||||
realGuild, cacheError := state.Guild(guildID)
|
||||
if cacheError == nil {
|
||||
readStateMutex.Lock()
|
||||
defer readStateMutex.Unlock()
|
||||
|
||||
for _, channel := range realGuild.Channels {
|
||||
if hasBeenMentionedWithoutLocking(channel.ID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isStillMuted(config *discordgo.MuteConfig) bool {
|
||||
if config == nil || config.EndTime == "" {
|
||||
//This means permanently muted; I think!
|
||||
//We make the assumption that this function is only
|
||||
//called if "Muted" is set to "true". Therefore no timeframe means
|
||||
//we must be permanently muted.
|
||||
return true
|
||||
}
|
||||
|
||||
muteEndTime, parseError := config.EndTime.Parse()
|
||||
if parseError != nil {
|
||||
panic(parseError)
|
||||
}
|
||||
|
||||
return time.Now().UTC().Before(muteEndTime)
|
||||
}
|
||||
|
||||
func isChannelMuted(channel *discordgo.Channel) bool {
|
||||
//optimization for the case of guild channels, as the handling for
|
||||
//private channels will be unnecessarily slower.
|
||||
|
@ -171,11 +227,24 @@ func isChannelMuted(channel *discordgo.Channel) bool {
|
|||
|
||||
// IsGuildChannelMuted checks whether a guild channel has been set to silent.
|
||||
func IsGuildChannelMuted(channel *discordgo.Channel) bool {
|
||||
if isGuildChannelMuted(channel.GuildID, channel.ID) {
|
||||
return true
|
||||
}
|
||||
|
||||
//Check if Parent (CATEGORY) is muted
|
||||
if channel.ParentID != "" && isGuildChannelMuted(channel.GuildID, channel.ParentID) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isGuildChannelMuted(guildID, channelID string) bool {
|
||||
for _, settings := range state.UserGuildSettings {
|
||||
if settings.GetGuildID() == channel.GuildID {
|
||||
if settings.GetGuildID() == guildID {
|
||||
for _, override := range settings.ChannelOverrides {
|
||||
if override.ChannelID == channel.ID {
|
||||
if override.Muted {
|
||||
if override.ChannelID == channelID {
|
||||
if override.Muted && isStillMuted(override.MuteConfig) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -199,7 +268,7 @@ func IsPrivateChannelMuted(channel *discordgo.Channel) bool {
|
|||
if settings.GetGuildID() == "" {
|
||||
for _, override := range settings.ChannelOverrides {
|
||||
if override.ChannelID == channel.ID {
|
||||
if override.Muted {
|
||||
if override.Muted && isStillMuted(override.MuteConfig) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -224,6 +293,28 @@ func HasBeenRead(channel *discordgo.Channel, lastMessageID string) bool {
|
|||
return hasBeenReadWithoutLocking(channel, lastMessageID)
|
||||
}
|
||||
|
||||
// HasBeenMentioned checks whether the currently logged in user has been
|
||||
// mentioned in this channel.
|
||||
func HasBeenMentioned(channelID string) bool {
|
||||
readStateMutex.Lock()
|
||||
defer readStateMutex.Unlock()
|
||||
|
||||
return hasBeenMentionedWithoutLocking(channelID)
|
||||
}
|
||||
|
||||
func hasBeenMentionedWithoutLocking(channelID string) bool {
|
||||
mentioned, ok := mentions[channelID]
|
||||
return ok && mentioned
|
||||
}
|
||||
|
||||
// MarkAsMentioned sets the given channel ID to mentioned.
|
||||
func MarkAsMentioned(channelID string) {
|
||||
readStateMutex.Lock()
|
||||
defer readStateMutex.Unlock()
|
||||
|
||||
mentions[channelID] = true
|
||||
}
|
||||
|
||||
// hasBeenReadWithoutLocking checks whether the passed channel has an unread Message or not.
|
||||
// The difference to HasBeenRead is, that no locking happens. This is inteded to be used
|
||||
// for recursive calls to this method and avoiding lock overhead and deadlocks.
|
||||
|
|
45
release.sh
45
release.sh
|
@ -4,14 +4,17 @@
|
|||
# This tiny script helps me not to mess up the procedure of releasing a new
|
||||
# version of cordless.
|
||||
#
|
||||
# Dependencies:
|
||||
# Build dependencies:
|
||||
# * sha256sum
|
||||
# * envsubst
|
||||
# * snapcraft
|
||||
# * git
|
||||
# * date
|
||||
# * go
|
||||
# * xclip
|
||||
#
|
||||
# While this script runs on Linux, it creates binaries for Linux, Windows and
|
||||
# MacOS. On top of that, new manifests for brew and scoop are created.
|
||||
# The binaries get pushed into a new GitHub release, using the previous commits
|
||||
# as the tag message. The scoop and brew manifests have to be uplaod manually.
|
||||
#
|
||||
|
||||
#
|
||||
|
@ -78,30 +81,11 @@ export EXE_32_HASH
|
|||
|
||||
envsubst < cordless.json_template > cordless.json
|
||||
|
||||
#
|
||||
# Commit and push the new scoop manifest.
|
||||
#
|
||||
|
||||
git commit cordless.json -m "Bump scoop package to version $RELEASE_DATE"
|
||||
git push
|
||||
|
||||
#
|
||||
# Create a new tag and push it.
|
||||
#
|
||||
|
||||
git tag -s "$RELEASE_DATE" -m "Update scoop package to version ${RELEASE_DATE}"
|
||||
git push --tags
|
||||
|
||||
#
|
||||
# Build and push the snap package.
|
||||
#
|
||||
# It is important that this happens after pushing the tag, because otherwise
|
||||
# the version of the built snap package will end up being `DATE_dirty`.
|
||||
#
|
||||
|
||||
snapcraft clean cordless
|
||||
snapcraft
|
||||
snapcraft upload "cordless_${RELEASE_DATE}_amd64.snap" --release stable
|
||||
git tag -s "$RELEASE_DATE"
|
||||
|
||||
#
|
||||
# Copies the changelog for pasting into the github release. The changes will
|
||||
|
@ -110,6 +94,14 @@ snapcraft upload "cordless_${RELEASE_DATE}_amd64.snap" --release stable
|
|||
|
||||
RELEASE_BODY="$(git log --pretty=oneline --abbrev-commit "$(git describe --abbrev=0 "$(git describe --abbrev=0)"^)".."$(git describe --abbrev=0)")"
|
||||
|
||||
#
|
||||
# Push both previously created commits and the tag.
|
||||
# We push as late as possible, to avoid pushing with
|
||||
# errors happening afterwards.
|
||||
#
|
||||
|
||||
git push --follow-tags
|
||||
|
||||
#
|
||||
# Temporarily disable that the script exists on subcommand failure.
|
||||
#
|
||||
|
@ -163,10 +155,3 @@ unset EXE_64_HASH
|
|||
unset EXE_32_HASH
|
||||
unset TAR_HASH
|
||||
|
||||
#
|
||||
# Cleanup snap stuff so I can still run unit tests for cordless only.
|
||||
#
|
||||
|
||||
rm -rf stage/
|
||||
rm -rf parts/
|
||||
rm -rf prime/
|
||||
|
|
|
@ -57,7 +57,8 @@ type ScriptInstance struct {
|
|||
onMessageDelete *otto.Value
|
||||
}
|
||||
|
||||
// New instantiates a new scripting engine
|
||||
// New instantiates a new scripting engine. The resulting object doesn't hold
|
||||
// any data or VMs initially. Only upon loading scripts, VMs are created.
|
||||
func New() *JavaScriptEngine {
|
||||
return &JavaScriptEngine{}
|
||||
}
|
||||
|
@ -176,6 +177,8 @@ func (engine *JavaScriptEngine) readScriptsRecursively(dirname string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// SetErrorOutput sets the writer to which errors can be written from inside
|
||||
// the JavaScript engines.
|
||||
func (engine *JavaScriptEngine) SetErrorOutput(errorOutput io.Writer) {
|
||||
engine.errorOutput = errorOutput
|
||||
}
|
||||
|
|
|
@ -8,15 +8,19 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/Bios-Marcel/cordless/ui/tviewutil"
|
||||
)
|
||||
|
||||
var (
|
||||
globalScope = addScope("global", "Application wide", nil)
|
||||
multilineTextInput = addScope("multiline_text_input", "Multiline text input", globalScope)
|
||||
chatview = addScope("chatview", "Chatview", globalScope)
|
||||
guildlist = addScope("guildlist", "Guildlist", globalScope)
|
||||
channeltree = addScope("channeltree", "Channeltree", globalScope)
|
||||
|
||||
QuoteSelectedMessage = addShortcut("quote_selected_message", "Quote selected message",
|
||||
chatview, tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone))
|
||||
|
@ -26,6 +30,8 @@ var (
|
|||
chatview, tcell.NewEventKey(tcell.KeyRune, 'd', tcell.ModNone))
|
||||
ReplySelectedMessage = addShortcut("reply_selected_message", "Reply to author selected message",
|
||||
chatview, tcell.NewEventKey(tcell.KeyRune, 'r', tcell.ModNone))
|
||||
NewDirectMessage = addShortcut("new_direct_message", "Create a new direct message channel with this user",
|
||||
chatview, tcell.NewEventKey(tcell.KeyRune, 'p', tcell.ModNone))
|
||||
CopySelectedMessageLink = addShortcut("copy_selected_message_link", "Copy link to selected message",
|
||||
chatview, tcell.NewEventKey(tcell.KeyRune, 'l', tcell.ModNone))
|
||||
CopySelectedMessage = addShortcut("copy_selected_message", "Copy content of selected message",
|
||||
|
@ -34,8 +40,16 @@ var (
|
|||
chatview, tcell.NewEventKey(tcell.KeyRune, 's', tcell.ModNone))
|
||||
DeleteSelectedMessage = addShortcut("delete_selected_message", "Delete the selected message",
|
||||
chatview, tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone))
|
||||
ViewSelectedMessageImages = addShortcut("view_selected_message_images", "View selected message's attached images",
|
||||
ViewSelectedMessageImages = addShortcut("view_selected_message_images", "View selected message's attached files",
|
||||
chatview, tcell.NewEventKey(tcell.KeyRune, 'o', tcell.ModNone))
|
||||
ChatViewSelectionUp = addShortcut("selection_up", "Move selection up by one",
|
||||
chatview, tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone))
|
||||
ChatViewSelectionDown = addShortcut("selection_down", "Move selection down by one",
|
||||
chatview, tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone))
|
||||
ChatViewSelectionTop = addShortcut("selection_top", "Move selection to the upmost message",
|
||||
chatview, tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone))
|
||||
ChatViewSelectionBottom = addShortcut("selection_bottom", "Move selection to the downmost message",
|
||||
chatview, tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone))
|
||||
|
||||
ExpandSelectionToLeft = addShortcut("expand_selection_word_to_left", "Expand selection word to left",
|
||||
multilineTextInput, tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModShift))
|
||||
|
@ -73,9 +87,7 @@ var (
|
|||
MoveCursorEndOfText = addShortcut("move_cursor_to_end_of_text", "Move cursor to end of text",
|
||||
multilineTextInput, tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModCtrl))
|
||||
|
||||
// FIXME Gotta add this later, as there is Backspace and Backspace and those differ on linux.
|
||||
// DeleteLeft = addShortcut("delete_left","Delete left",multilineTextInput,tcell.NewEventKey(tcell.KeyBackspace2, rune(tcell.KeyBackspace2), tcell.ModNone))
|
||||
|
||||
DeleteLeft = addDeleteLeftShortcut()
|
||||
DeleteRight = addShortcut("delete_right", "Delete right",
|
||||
multilineTextInput, tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone))
|
||||
DeleteWordLeft = addShortcut("delete_word_left", "Delete word left",
|
||||
|
@ -98,6 +110,15 @@ var (
|
|||
ExitApplication = addShortcut("exit_application", "Exit application",
|
||||
globalScope, tcell.NewEventKey(tcell.KeyCtrlC, rune(tcell.KeyCtrlC), tcell.ModCtrl))
|
||||
|
||||
FocusUp = addShortcut("focus_up", "Focus the next widget above",
|
||||
globalScope, tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModAlt))
|
||||
FocusDown = addShortcut("focus_down", "Focus the next widget below",
|
||||
globalScope, tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModAlt))
|
||||
FocusLeft = addShortcut("focus_left", "Focus the next widget to the left",
|
||||
globalScope, tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModAlt))
|
||||
FocusRight = addShortcut("focus_right", "Focus the next widget to the right",
|
||||
globalScope, tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModAlt))
|
||||
|
||||
FocusChannelContainer = addShortcut("focus_channel_container", "Focus channel container",
|
||||
globalScope, tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModAlt))
|
||||
FocusUserContainer = addShortcut("focus_user_container", "Focus user container",
|
||||
|
@ -124,6 +145,12 @@ var (
|
|||
ToggleBareChat = addShortcut("toggle_bare_chat", "Toggle bare chat",
|
||||
globalScope, tcell.NewEventKey(tcell.KeyCtrlB, rune(tcell.KeyCtrlB), tcell.ModCtrl))
|
||||
|
||||
GuildListMarkRead = addShortcut("guild_mark_read", "Mark server as read",
|
||||
guildlist, tcell.NewEventKey(tcell.KeyCtrlR, rune(tcell.KeyCtrlR), tcell.ModCtrl))
|
||||
|
||||
ChannelTreeMarkRead = addShortcut("channel_mark_read", "Mark channel as read",
|
||||
channeltree, tcell.NewEventKey(tcell.KeyCtrlR, rune(tcell.KeyCtrlR), tcell.ModCtrl))
|
||||
|
||||
scopes []*Scope
|
||||
Shortcuts []*Shortcut
|
||||
)
|
||||
|
@ -345,3 +372,19 @@ func Persist() error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DirectionalFocusHandling(event *tcell.EventKey, app *tview.Application) *tcell.EventKey {
|
||||
focused := app.GetFocus()
|
||||
if FocusUp.Equals(event) {
|
||||
tviewutil.FocusNextIfPossible(tview.Up, app, focused)
|
||||
} else if FocusDown.Equals(event) {
|
||||
tviewutil.FocusNextIfPossible(tview.Down, app, focused)
|
||||
} else if FocusLeft.Equals(event) {
|
||||
tviewutil.FocusNextIfPossible(tview.Left, app, focused)
|
||||
} else if FocusRight.Equals(event) {
|
||||
tviewutil.FocusNextIfPossible(tview.Right, app, focused)
|
||||
} else {
|
||||
return event
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
// +build !windows
|
||||
|
||||
package shortcuts
|
||||
|
||||
import tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
func addDeleteLeftShortcut() *Shortcut {
|
||||
return addShortcut("delete_left", "Delete left", multilineTextInput, tcell.NewEventKey(tcell.KeyBackspace2, rune(tcell.KeyBackspace2), tcell.ModNone))
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
// +build windows
|
||||
|
||||
package shortcuts
|
||||
|
||||
import tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
func addDeleteLeftShortcut() *Shortcut {
|
||||
return addShortcut("delete_left", "Delete left", multilineTextInput, tcell.NewEventKey(tcell.KeyBackspace, rune(tcell.KeyBackspace), tcell.ModNone))
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
name: cordless
|
||||
version: git
|
||||
summary: Third party discord client
|
||||
description: |
|
||||
A third party discord client for your terminal.
|
||||
|
||||
confinement: strict
|
||||
grade: stable
|
||||
architectures:
|
||||
- amd64
|
||||
base: core18
|
||||
|
||||
parts:
|
||||
cordless:
|
||||
plugin: go
|
||||
go-channel: 1.14/stable
|
||||
go-importpath: github.com/Bios-Marcel/cordless
|
||||
source: .
|
||||
source-type: git
|
||||
stage-packages:
|
||||
- xclip
|
||||
- libnotify-bin
|
||||
build-packages:
|
||||
- gcc
|
||||
- libc6-dev
|
||||
|
||||
apps:
|
||||
cordless:
|
||||
command: bin/cordless
|
||||
plugs: [x11, network-bind, desktop]
|
||||
environment:
|
||||
XDG_CONFIG_DIR: $SNAP_USER_DATA/
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
@ -2,7 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
@ -65,7 +65,7 @@ func (c *CoreLayout) SetRect(x, y, width, height int) {
|
|||
c.height = height
|
||||
}
|
||||
|
||||
func (c *CoreLayout) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
func (c *CoreLayout) InputHandler() tview.InputHandlerFunc {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
|
@ -89,6 +89,22 @@ func (c *CoreLayout) GetFocusable() tview.Focusable {
|
|||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c *CoreLayout) NextFocusableComponent(direction tview.FocusDirection) tview.Primitive {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c *CoreLayout) OnPaste(runes []rune) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (c *CoreLayout) SetParent(parent tview.Primitive) {
|
||||
//Do nothing
|
||||
}
|
||||
|
||||
func (c *CoreLayout) GetParent() tview.Primitive {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewDemoComponent(r rune) *demoComponent {
|
||||
return &demoComponent{visible: true, fillWith: r}
|
||||
}
|
||||
|
@ -140,9 +156,9 @@ func (d *demoComponent) SetRect(x, y, width, height int) {
|
|||
d.height = height
|
||||
}
|
||||
|
||||
func (d *demoComponent) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
|
||||
func (d *demoComponent) InputHandler() tview.InputHandlerFunc {
|
||||
return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) *tcell.EventKey {
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -165,3 +181,19 @@ func (d *demoComponent) SetOnBlur(handler func()) {
|
|||
func (d *demoComponent) GetFocusable() tview.Focusable {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *demoComponent) NextFocusableComponent(direction tview.FocusDirection) tview.Primitive {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *demoComponent) OnPaste(runes []rune) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (d *demoComponent) SetParent(parent tview.Primitive) {
|
||||
//Do nothing
|
||||
}
|
||||
|
||||
func (d *demoComponent) GetParent() tview.Primitive {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
)
|
||||
|
|
|
@ -3,7 +3,7 @@ package tview
|
|||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// The size of the event/update/redraw channels.
|
||||
|
@ -66,6 +66,9 @@ type Application struct {
|
|||
// (screen.Init() and draw() will be called implicitly). A value of nil will
|
||||
// stop the application.
|
||||
screenReplacement chan tcell.Screen
|
||||
|
||||
// Defines whether a bracketed paste is currently ongoing.
|
||||
pasteActive bool
|
||||
}
|
||||
|
||||
// NewApplication creates and returns a new application.
|
||||
|
@ -116,6 +119,7 @@ func (a *Application) SetScreen(screen tcell.Screen) *Application {
|
|||
if a.MouseEnabled {
|
||||
a.screen.EnableMouse()
|
||||
}
|
||||
screen.EnablePaste()
|
||||
a.Unlock()
|
||||
return a
|
||||
}
|
||||
|
@ -149,6 +153,7 @@ func (a *Application) Run() error {
|
|||
if a.MouseEnabled {
|
||||
a.screen.EnableMouse()
|
||||
}
|
||||
a.screen.EnablePaste()
|
||||
}
|
||||
|
||||
// We catch panics to clean up because they mess up the terminal.
|
||||
|
@ -209,9 +214,19 @@ func (a *Application) Run() error {
|
|||
}
|
||||
}()
|
||||
|
||||
focusFunc := func(p Primitive) {
|
||||
a.SetFocus(p)
|
||||
}
|
||||
|
||||
// Start event loop.
|
||||
var keyEventHandleHierarchy []Primitive
|
||||
var pastedRunes []rune
|
||||
EventLoop:
|
||||
for {
|
||||
//resize slice to zero without having to allocate a new array.
|
||||
//We want to avoid slowdowns and unnecessary garbage collection during
|
||||
//user input. This could especially effect stuff like pasting.
|
||||
keyEventHandleHierarchy = keyEventHandleHierarchy[0:0]
|
||||
select {
|
||||
case event := <-a.events:
|
||||
if event == nil {
|
||||
|
@ -223,6 +238,14 @@ EventLoop:
|
|||
a.RLock()
|
||||
p := a.focus
|
||||
inputCapture := a.inputCapture
|
||||
if a.pasteActive {
|
||||
if event.Rune() != 0 {
|
||||
pastedRunes = append(pastedRunes, event.Rune())
|
||||
a.RUnlock()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
a.RUnlock()
|
||||
|
||||
// Intercept keys.
|
||||
|
@ -236,11 +259,27 @@ EventLoop:
|
|||
|
||||
// Pass other key events to the currently focused primitive.
|
||||
if p != nil {
|
||||
if handler := p.InputHandler(); handler != nil {
|
||||
handler(event, func(p Primitive) {
|
||||
a.SetFocus(p)
|
||||
})
|
||||
a.draw()
|
||||
lastParent := p
|
||||
for {
|
||||
keyEventHandleHierarchy = append(keyEventHandleHierarchy, lastParent)
|
||||
lastParent = lastParent.GetParent()
|
||||
|
||||
if lastParent == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
//Events are handled from bottom to top, so parents get
|
||||
//handled before the actually focused primitive.
|
||||
for i := len(keyEventHandleHierarchy) - 1; i >= 0; i-- {
|
||||
nextFocusHandlingComponent := keyEventHandleHierarchy[i]
|
||||
if handler := nextFocusHandlingComponent.InputHandler(); handler != nil {
|
||||
event := handler(event, focusFunc)
|
||||
if event == nil {
|
||||
a.draw()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case *tcell.EventResize:
|
||||
|
@ -252,6 +291,25 @@ EventLoop:
|
|||
}
|
||||
screen.Clear()
|
||||
a.draw()
|
||||
case *tcell.EventPaste:
|
||||
a.RLock()
|
||||
p := a.focus
|
||||
|
||||
if event.Start() {
|
||||
|
||||
a.pasteActive = true
|
||||
a.RUnlock()
|
||||
} else if event.End() {
|
||||
a.pasteActive = false
|
||||
a.RUnlock()
|
||||
if p != nil {
|
||||
p.OnPaste(pastedRunes)
|
||||
a.draw()
|
||||
pastedRunes = pastedRunes[0:0]
|
||||
}
|
||||
} else {
|
||||
a.RUnlock()
|
||||
}
|
||||
case *tcell.EventMouse:
|
||||
if event.Buttons() == tcell.ButtonNone {
|
||||
continue
|
||||
|
@ -342,6 +400,11 @@ func getSelfIfCoordinatesMatch(primitive Primitive, x, y int) *Primitive {
|
|||
return nil
|
||||
}
|
||||
|
||||
// IsPasting indicates whether a bracketed paste is ongoing.
|
||||
func (a *Application) IsPasting() bool {
|
||||
return a.pasteActive
|
||||
}
|
||||
|
||||
// Stop stops the application, causing Run() to return.
|
||||
func (a *Application) Stop() {
|
||||
a.Lock()
|
||||
|
@ -386,6 +449,7 @@ func (a *Application) Suspend(f func()) bool {
|
|||
if a.MouseEnabled {
|
||||
screen.EnableMouse()
|
||||
}
|
||||
screen.EnablePaste()
|
||||
|
||||
a.screenReplacement <- screen
|
||||
// One key event will get lost, see https://github.com/gdamore/tcell/issues/194
|
||||
|
|
188
tview/box.go
188
tview/box.go
|
@ -1,7 +1,7 @@
|
|||
package tview
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Box implements Primitive with a background and optional elements such as a
|
||||
|
@ -41,11 +41,7 @@ type Box struct {
|
|||
// The color of the border when the box has focus.
|
||||
borderFocusColor tcell.Color
|
||||
|
||||
// The style attributes of the border.
|
||||
borderAttributes tcell.AttrMask
|
||||
|
||||
// The style attributes of the border when the box has focus.
|
||||
borderFocusAttributes tcell.AttrMask
|
||||
borderBlinking bool
|
||||
|
||||
// If set to true, the text view will show down and up arrows if there is
|
||||
// content out of sight. While box doesn't implement scrolling, this is
|
||||
|
@ -85,29 +81,30 @@ type Box struct {
|
|||
|
||||
// Handler that gets called when this component loses focus.
|
||||
onBlur func()
|
||||
|
||||
nextFocusableComponents map[FocusDirection][]Primitive
|
||||
parent Primitive
|
||||
|
||||
onPaste func([]rune)
|
||||
}
|
||||
|
||||
// NewBox returns a Box without a border.
|
||||
func NewBox() *Box {
|
||||
b := &Box{
|
||||
width: 15,
|
||||
height: 10,
|
||||
innerX: -1, // Mark as uninitialized.
|
||||
backgroundColor: Styles.PrimitiveBackgroundColor,
|
||||
borderColor: Styles.BorderColor,
|
||||
borderFocusColor: Styles.BorderFocusColor,
|
||||
borderFocusAttributes: tcell.AttrNone,
|
||||
titleColor: Styles.TitleColor,
|
||||
titleAlign: AlignCenter,
|
||||
borderTop: true,
|
||||
borderBottom: true,
|
||||
borderLeft: true,
|
||||
borderRight: true,
|
||||
visible: true,
|
||||
}
|
||||
|
||||
if vtxxx {
|
||||
b.borderFocusAttributes = tcell.AttrBold
|
||||
width: 15,
|
||||
height: 10,
|
||||
innerX: -1, // Mark as uninitialized.
|
||||
backgroundColor: Styles.PrimitiveBackgroundColor,
|
||||
borderColor: Styles.BorderColor,
|
||||
borderFocusColor: Styles.BorderFocusColor,
|
||||
titleColor: Styles.TitleColor,
|
||||
titleAlign: AlignCenter,
|
||||
borderTop: true,
|
||||
borderBottom: true,
|
||||
borderLeft: true,
|
||||
borderRight: true,
|
||||
visible: true,
|
||||
nextFocusableComponents: make(map[FocusDirection][]Primitive),
|
||||
}
|
||||
|
||||
b.focus = b
|
||||
|
@ -218,19 +215,21 @@ func (b *Box) GetDrawFunc() func(screen tcell.Screen, x, y, width, height int) (
|
|||
// on to the provided (default) input handler.
|
||||
//
|
||||
// This is only meant to be used by subclassing primitives.
|
||||
func (b *Box) WrapInputHandler(inputHandler func(*tcell.EventKey, func(p Primitive))) func(*tcell.EventKey, func(p Primitive)) {
|
||||
return func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (b *Box) WrapInputHandler(inputHandler InputHandlerFunc) InputHandlerFunc {
|
||||
return func(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
if b.inputCapture != nil {
|
||||
event = b.inputCapture(event)
|
||||
}
|
||||
if event != nil && inputHandler != nil {
|
||||
inputHandler(event, setFocus)
|
||||
event = inputHandler(event, setFocus)
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// InputHandler returns nil.
|
||||
func (b *Box) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (b *Box) InputHandler() InputHandlerFunc {
|
||||
return b.WrapInputHandler(nil)
|
||||
}
|
||||
|
||||
|
@ -340,29 +339,8 @@ func (b *Box) IsBorderLeft() bool {
|
|||
return b.border && b.borderLeft
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// SetBorderAttributes sets the border's style attributes. You can combine
|
||||
// different attributes using bitmask operations:
|
||||
//
|
||||
// box.SetBorderAttributes(tcell.AttrUnderline | tcell.AttrBold)
|
||||
func (b *Box) SetBorderAttributes(attr tcell.AttrMask) *Box {
|
||||
b.borderAttributes = attr
|
||||
return b
|
||||
}
|
||||
|
||||
// SetBorderFocusAttributes sets the border's style attributes when focused. You can combine
|
||||
// different attributes using bitmask operations:
|
||||
//
|
||||
// box.SetBorderFocusAttributes(tcell.AttrUnderline | tcell.AttrBold)
|
||||
func (b *Box) SetBorderFocusAttributes(attr tcell.AttrMask) *Box {
|
||||
b.borderFocusAttributes = attr
|
||||
func (b *Box) SetBorderBlinking(blinking bool) *Box {
|
||||
b.borderBlinking = blinking
|
||||
return b
|
||||
}
|
||||
|
||||
|
@ -404,16 +382,20 @@ func (b *Box) Draw(screen tcell.Screen) bool {
|
|||
|
||||
// Draw border.
|
||||
if b.border && b.width >= 2 && b.height >= 1 {
|
||||
var border tcell.Style
|
||||
var borderStyle tcell.Style
|
||||
if b.hasFocus {
|
||||
if b.borderFocusAttributes != 0 {
|
||||
border = background.Foreground(b.borderFocusColor) | tcell.Style(b.borderFocusAttributes)
|
||||
} else {
|
||||
border = background.Foreground(b.borderFocusColor) | tcell.Style(b.borderAttributes)
|
||||
borderStyle = background.Foreground(b.borderFocusColor)
|
||||
if IsVtxxx {
|
||||
borderStyle = borderStyle.Bold(true)
|
||||
}
|
||||
} else {
|
||||
border = background.Foreground(b.borderColor) | tcell.Style(b.borderAttributes)
|
||||
borderStyle = background.Foreground(b.borderColor)
|
||||
}
|
||||
|
||||
if b.borderBlinking {
|
||||
borderStyle = borderStyle.Blink(true)
|
||||
}
|
||||
|
||||
var vertical, horizontal, topLeft, topRight, bottomLeft, bottomRight rune
|
||||
|
||||
horizontal = Borders.Horizontal
|
||||
|
@ -426,19 +408,19 @@ func (b *Box) Draw(screen tcell.Screen) bool {
|
|||
//Special case in order to render only the title-line of something properly.
|
||||
if b.borderTop {
|
||||
for x := b.x + 1; x < b.x+b.width-1; x++ {
|
||||
screen.SetContent(x, b.y, horizontal, nil, border)
|
||||
screen.SetContent(x, b.y, horizontal, nil, borderStyle)
|
||||
}
|
||||
|
||||
if b.borderLeft {
|
||||
screen.SetContent(b.x, b.y, topLeft, nil, border)
|
||||
screen.SetContent(b.x, b.y, topLeft, nil, borderStyle)
|
||||
} else {
|
||||
screen.SetContent(b.x, b.y, horizontal, nil, border)
|
||||
screen.SetContent(b.x, b.y, horizontal, nil, borderStyle)
|
||||
}
|
||||
|
||||
if b.borderRight {
|
||||
screen.SetContent(b.x+b.width-1, b.y, topRight, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, b.y, topRight, nil, borderStyle)
|
||||
} else {
|
||||
screen.SetContent(b.x+b.width-1, b.y, horizontal, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, b.y, horizontal, nil, borderStyle)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -446,62 +428,62 @@ func (b *Box) Draw(screen tcell.Screen) bool {
|
|||
if b.height > 1 {
|
||||
if b.borderBottom {
|
||||
for x := b.x + 1; x < b.x+b.width-1; x++ {
|
||||
screen.SetContent(x, b.y+b.height-1, horizontal, nil, border)
|
||||
screen.SetContent(x, b.y+b.height-1, horizontal, nil, borderStyle)
|
||||
}
|
||||
|
||||
if b.borderLeft {
|
||||
screen.SetContent(b.x, b.y+b.height-1, bottomLeft, nil, border)
|
||||
screen.SetContent(b.x, b.y+b.height-1, bottomLeft, nil, borderStyle)
|
||||
} else {
|
||||
screen.SetContent(b.x, b.y+b.height-1, horizontal, nil, border)
|
||||
screen.SetContent(b.x, b.y+b.height-1, horizontal, nil, borderStyle)
|
||||
}
|
||||
if b.borderRight {
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, bottomRight, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, bottomRight, nil, borderStyle)
|
||||
} else {
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, horizontal, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, horizontal, nil, borderStyle)
|
||||
}
|
||||
}
|
||||
|
||||
if b.borderLeft {
|
||||
for y := b.y + 1; y < b.y+b.height-1; y++ {
|
||||
screen.SetContent(b.x, y, vertical, nil, border)
|
||||
screen.SetContent(b.x, y, vertical, nil, borderStyle)
|
||||
}
|
||||
|
||||
if b.borderTop {
|
||||
screen.SetContent(b.x, b.y, topLeft, nil, border)
|
||||
screen.SetContent(b.x, b.y, topLeft, nil, borderStyle)
|
||||
} else {
|
||||
screen.SetContent(b.x, b.y, vertical, nil, border)
|
||||
screen.SetContent(b.x, b.y, vertical, nil, borderStyle)
|
||||
}
|
||||
|
||||
if b.borderBottom {
|
||||
screen.SetContent(b.x, b.y+b.height-1, bottomLeft, nil, border)
|
||||
screen.SetContent(b.x, b.y+b.height-1, bottomLeft, nil, borderStyle)
|
||||
} else {
|
||||
screen.SetContent(b.x, b.y+b.height-1, vertical, nil, border)
|
||||
screen.SetContent(b.x, b.y+b.height-1, vertical, nil, borderStyle)
|
||||
}
|
||||
}
|
||||
|
||||
if b.borderRight {
|
||||
for y := b.y + 1; y < b.y+b.height-1; y++ {
|
||||
screen.SetContent(b.x+b.width-1, y, vertical, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, y, vertical, nil, borderStyle)
|
||||
}
|
||||
|
||||
if b.borderTop {
|
||||
screen.SetContent(b.x+b.width-1, b.y, topRight, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, b.y, topRight, nil, borderStyle)
|
||||
} else {
|
||||
screen.SetContent(b.x+b.width-1, b.y, vertical, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, b.y, vertical, nil, borderStyle)
|
||||
}
|
||||
|
||||
if b.borderBottom {
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, bottomRight, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, bottomRight, nil, borderStyle)
|
||||
} else {
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, vertical, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, vertical, nil, borderStyle)
|
||||
}
|
||||
}
|
||||
} else if b.height == 1 && !b.borderTop && !b.borderBottom {
|
||||
if b.borderLeft {
|
||||
screen.SetContent(b.x, b.y, vertical, nil, border)
|
||||
screen.SetContent(b.x, b.y, vertical, nil, borderStyle)
|
||||
}
|
||||
if b.borderRight {
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, vertical, nil, border)
|
||||
screen.SetContent(b.x+b.width-1, b.y+b.height-1, vertical, nil, borderStyle)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -568,6 +550,29 @@ func (b *Box) Blur() {
|
|||
}
|
||||
}
|
||||
|
||||
// SetNextFocusableComponents decides which components are to be focused using
|
||||
// a certain focus direction. If more than one component is passed, the
|
||||
// priority goes from left-most to right-most. A component will be skipped if
|
||||
// it is not visible.
|
||||
func (b *Box) SetNextFocusableComponents(direction FocusDirection, components ...Primitive) {
|
||||
b.nextFocusableComponents[direction] = components
|
||||
}
|
||||
|
||||
// NextFocusableComponent decides which component should receive focus next.
|
||||
// If nil is returned, the focus is retained.
|
||||
func (b *Box) NextFocusableComponent(direction FocusDirection) Primitive {
|
||||
components, avail := b.nextFocusableComponents[direction]
|
||||
if avail {
|
||||
for _, comp := range components {
|
||||
if comp.IsVisible() {
|
||||
return comp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasFocus returns whether or not this primitive has focus.
|
||||
func (b *Box) HasFocus() bool {
|
||||
return b.hasFocus
|
||||
|
@ -585,6 +590,19 @@ func (b *Box) SetIndicateOverflow(indicateOverflow bool) *Box {
|
|||
return b
|
||||
}
|
||||
|
||||
// SetParent defines which component this primitive is currently being
|
||||
// treated as a child of. This should never be called manually.
|
||||
func (b *Box) SetParent(parent Primitive) {
|
||||
//Reparenting is possible!
|
||||
b.parent = parent
|
||||
}
|
||||
|
||||
// GetParent returns the current parent or nil if the parent hasn't been
|
||||
// set yet.
|
||||
func (b *Box) GetParent() Primitive {
|
||||
return b.parent
|
||||
}
|
||||
|
||||
func (b *Box) drawOverflow(screen tcell.Screen, showTop, showBottom bool) {
|
||||
if b.indicateOverflow && b.border && b.borderTop && b.borderBottom && b.height > 1 {
|
||||
overflowIndicatorX := b.innerX + b.innerWidth + b.paddingRight - 1
|
||||
|
@ -597,3 +615,15 @@ func (b *Box) drawOverflow(screen tcell.Screen, showTop, showBottom bool) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnPaste defines the function that's called in OnPaste.
|
||||
func (b *Box) SetOnPaste(onPaste func([]rune)) {
|
||||
b.onPaste = onPaste
|
||||
}
|
||||
|
||||
// OnPaste is called when a bracketed paste is finished.
|
||||
func (b *Box) OnPaste(runes []rune) {
|
||||
if b.onPaste != nil {
|
||||
b.onPaste(runes)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
package tview
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Button is labeled box that triggers an action when selected.
|
||||
|
@ -33,16 +30,6 @@ type Button struct {
|
|||
blur func(tcell.Key)
|
||||
}
|
||||
|
||||
func checkVT() bool {
|
||||
VTxxx, err := regexp.MatchString("(vt)[0-9]+", os.Getenv("TERM"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return VTxxx
|
||||
}
|
||||
|
||||
var vtxxx bool = checkVT()
|
||||
|
||||
// NewButton returns a new input field.
|
||||
func NewButton(label string) *Button {
|
||||
box := NewBox().SetBackgroundColor(Styles.ContrastBackgroundColor)
|
||||
|
@ -119,13 +106,13 @@ func (b *Button) Draw(screen tcell.Screen) bool {
|
|||
// Draw the box.
|
||||
borderColor := b.borderColor
|
||||
backgroundColor := b.backgroundColor
|
||||
if vtxxx {
|
||||
if IsVtxxx {
|
||||
b.reverse = false
|
||||
}
|
||||
if b.focus.HasFocus() {
|
||||
b.backgroundColor = b.backgroundColorActivated
|
||||
b.borderColor = b.labelColorActivated
|
||||
if vtxxx {
|
||||
if IsVtxxx {
|
||||
b.reverse = true
|
||||
}
|
||||
defer func() {
|
||||
|
@ -155,18 +142,22 @@ func (b *Button) Draw(screen tcell.Screen) bool {
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (b *Button) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
return b.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (b *Button) InputHandler() InputHandlerFunc {
|
||||
return b.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
// Process key event.
|
||||
switch key := event.Key(); key {
|
||||
case tcell.KeyEnter: // Selected.
|
||||
if b.selected != nil {
|
||||
b.selected()
|
||||
}
|
||||
return nil
|
||||
case tcell.KeyBacktab, tcell.KeyTab, tcell.KeyEscape: // Leave. No action.
|
||||
if b.blur != nil {
|
||||
b.blur(key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package tview
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Checkbox implements a simple box for boolean values which can be checked and
|
||||
|
@ -183,8 +183,8 @@ func (c *Checkbox) Draw(screen tcell.Screen) bool {
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (c *Checkbox) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
return c.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (c *Checkbox) InputHandler() InputHandlerFunc {
|
||||
return c.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
// Process key event.
|
||||
switch key := event.Key(); key {
|
||||
case tcell.KeyRune, tcell.KeyEnter: // Check.
|
||||
|
@ -203,5 +203,7 @@ func (c *Checkbox) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr
|
|||
c.finished(key)
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
|
|
@ -2,14 +2,12 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
func main() {
|
||||
box := tview.NewBox().
|
||||
SetBorder(true).
|
||||
SetBorderAttributes(tcell.AttrBold).
|
||||
SetTitle("A [red]c[yellow]o[green]l[darkcyan]o[blue]r[darkmagenta]f[red]u[yellow]l[white] [black:red]c[:yellow]o[:green]l[:darkcyan]o[:blue]r[:darkmagenta]f[:red]u[:yellow]l[white:] [::bu]title")
|
||||
if err := tview.NewApplication().SetRoot(box, true).Run(); err != nil {
|
||||
panic(err)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ package main
|
|||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
|
|
@ -3,7 +3,7 @@ package main
|
|||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
@ -10,7 +10,7 @@ const inputField = `[green]package[white] main
|
|||
[green]import[white] (
|
||||
[red]"strconv"[white]
|
||||
|
||||
[red]"github.com/gdamore/tcell"[white]
|
||||
[red]tcell "github.com/gdamore/tcell/v2"[white]
|
||||
[red]"github.com/Bios-Marcel/cordless/tview"[white]
|
||||
)
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import (
|
|||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
@ -59,7 +59,7 @@ const textView2 = `[green]package[white] main
|
|||
[green]import[white] (
|
||||
[red]"strconv"[white]
|
||||
|
||||
[red]"github.com/gdamore/tcell"[white]
|
||||
[red]tcell "github.com/gdamore/tcell/v2"[white]
|
||||
[red]"github.com/Bios-Marcel/cordless/tview"[white]
|
||||
)
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ package main
|
|||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
@ -74,7 +74,7 @@ var rootNode = &node{
|
|||
{text: "Tree list starts one level down"},
|
||||
{text: "Works better for lists where no top node is needed"},
|
||||
{text: "Switch to this layout", selected: func() {
|
||||
tree.SetAlign(false).SetTopLevel(1).SetGraphics(true).SetPrefixes(nil)
|
||||
tree.SetAlign(false).SetTopLevel(1).SetGraphics(true).SetBulletCharacters(nil)
|
||||
treeCode.SetText(strings.Replace(treeAllCode, "$$$", treeTopLevelCode, -1))
|
||||
}},
|
||||
}},
|
||||
|
@ -82,7 +82,7 @@ var rootNode = &node{
|
|||
{text: "For trees that are similar to lists"},
|
||||
{text: "Hierarchy shown only in line drawings"},
|
||||
{text: "Switch to this layout", selected: func() {
|
||||
tree.SetAlign(true).SetTopLevel(0).SetGraphics(true).SetPrefixes(nil)
|
||||
tree.SetAlign(true).SetTopLevel(0).SetGraphics(true).SetBulletCharacters(nil)
|
||||
treeCode.SetText(strings.Replace(treeAllCode, "$$$", treeAlignCode, -1))
|
||||
}},
|
||||
}},
|
||||
|
@ -90,7 +90,7 @@ var rootNode = &node{
|
|||
{text: "Best for hierarchical bullet point lists"},
|
||||
{text: "You can define your own prefixes per level"},
|
||||
{text: "Switch to this layout", selected: func() {
|
||||
tree.SetAlign(false).SetTopLevel(1).SetGraphics(false).SetPrefixes([]string{"[red]* ", "[darkcyan]- ", "[darkmagenta]- "})
|
||||
tree.SetAlign(false).SetTopLevel(1).SetGraphics(false).SetBulletCharacters([]string{"[red]* ", "[darkcyan]- ", "[darkmagenta]- "})
|
||||
treeCode.SetText(strings.Replace(treeAllCode, "$$$", treePrefixCode, -1))
|
||||
}},
|
||||
}},
|
||||
|
@ -98,7 +98,7 @@ var rootNode = &node{
|
|||
{text: "Lines illustrate hierarchy"},
|
||||
{text: "Basic indentation"},
|
||||
{text: "Switch to this layout", selected: func() {
|
||||
tree.SetAlign(false).SetTopLevel(0).SetGraphics(true).SetPrefixes(nil)
|
||||
tree.SetAlign(false).SetTopLevel(0).SetGraphics(true).SetBulletCharacters(nil)
|
||||
treeCode.SetText(strings.Replace(treeAllCode, "$$$", treeBasicCode, -1))
|
||||
}},
|
||||
}},
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// RadioButtons implements a simple primitive for radio button selections.
|
||||
|
@ -48,20 +48,24 @@ func (r *RadioButtons) Draw(screen tcell.Screen) bool {
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (r *RadioButtons) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
return r.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
func (r *RadioButtons) InputHandler() tview.InputHandlerFunc {
|
||||
return r.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyUp:
|
||||
r.currentOption--
|
||||
if r.currentOption < 0 {
|
||||
r.currentOption = 0
|
||||
}
|
||||
return nil
|
||||
case tcell.KeyDown:
|
||||
r.currentOption++
|
||||
if r.currentOption >= len(r.options) {
|
||||
r.currentOption = len(r.options) - 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ package main
|
|||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
"io/ioutil"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
)
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ package tview
|
|||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// dropDownOption is one option that can be selected in a drop-down primitive.
|
||||
|
@ -396,8 +396,8 @@ func (d *DropDown) Draw(screen tcell.Screen) bool {
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (d *DropDown) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
return d.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (d *DropDown) InputHandler() InputHandlerFunc {
|
||||
return d.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
// A helper function which selects an item in the drop-down list based on
|
||||
// the current prefix.
|
||||
evalPrefix := func() {
|
||||
|
@ -461,6 +461,7 @@ func (d *DropDown) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr
|
|||
return event
|
||||
})
|
||||
setFocus(d.list)
|
||||
return nil
|
||||
case tcell.KeyEscape, tcell.KeyTab, tcell.KeyBacktab:
|
||||
if d.done != nil {
|
||||
d.done(key)
|
||||
|
@ -468,7 +469,10 @@ func (d *DropDown) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr
|
|||
if d.finished != nil {
|
||||
d.finished(key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package tview
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Configuration values.
|
||||
|
@ -86,6 +86,7 @@ func (f *Flex) SetFullScreen(fullScreen bool) *Flex {
|
|||
// You can provide a nil value for the primitive. This will still consume screen
|
||||
// space but nothing will be drawn.
|
||||
func (f *Flex) AddItem(item Primitive, fixedSize, proportion int, focus bool) *Flex {
|
||||
item.SetParent(f)
|
||||
f.items = append(f.items, &flexItem{Item: item, FixedSize: fixedSize, Proportion: proportion, Focus: focus})
|
||||
return f
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package tview
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// DefaultFormFieldWidth is the default field screen width of form elements
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package tview
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// frameText holds information about a line of text shown in the frame.
|
||||
|
|
|
@ -3,7 +3,7 @@ package tview
|
|||
import (
|
||||
"math"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// gridItem represents one primitive and its possible position on a grid.
|
||||
|
@ -196,6 +196,7 @@ func (g *Grid) SetBordersColor(color tcell.Color) *Grid {
|
|||
// receives focus. If there are multiple items with a true focus flag, the last
|
||||
// visible one that was added will receive focus.
|
||||
func (g *Grid) AddItem(p Primitive, row, column, rowSpan, colSpan, minGridHeight, minGridWidth int, focus bool) *Grid {
|
||||
p.SetParent(g)
|
||||
g.items = append(g.items, &gridItem{
|
||||
Item: p,
|
||||
Row: row,
|
||||
|
@ -269,8 +270,8 @@ func (g *Grid) HasFocus() bool {
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (g *Grid) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
return g.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (g *Grid) InputHandler() InputHandlerFunc {
|
||||
return g.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyRune:
|
||||
switch event.Rune() {
|
||||
|
@ -286,7 +287,10 @@ func (g *Grid) InputHandler() func(event *tcell.EventKey, setFocus func(p Primit
|
|||
g.columnOffset--
|
||||
case 'l':
|
||||
g.columnOffset++
|
||||
default:
|
||||
return event
|
||||
}
|
||||
return nil
|
||||
case tcell.KeyHome:
|
||||
g.rowOffset, g.columnOffset = 0, 0
|
||||
case tcell.KeyEnd:
|
||||
|
@ -299,7 +303,11 @@ func (g *Grid) InputHandler() func(event *tcell.EventKey, setFocus func(p Primit
|
|||
g.columnOffset--
|
||||
case tcell.KeyRight:
|
||||
g.columnOffset++
|
||||
default:
|
||||
return event
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// InputField is a one-line box (three lines if there is a title) where the
|
||||
|
@ -343,8 +343,8 @@ func (i *InputField) Insert(text string) {
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
return i.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (i *InputField) InputHandler() InputHandlerFunc {
|
||||
return i.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
// Trigger changed events.
|
||||
currentText := i.text
|
||||
defer func() {
|
||||
|
@ -455,6 +455,10 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p
|
|||
if i.finished != nil {
|
||||
i.finished(key)
|
||||
}
|
||||
default:
|
||||
return event
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// listItem represents one item in a List.
|
||||
|
@ -543,8 +543,8 @@ func (l *List) Draw(screen tcell.Screen) bool {
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (l *List) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
return l.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (l *List) InputHandler() InputHandlerFunc {
|
||||
return l.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
previousItem := l.currentItem
|
||||
|
||||
switch key := event.Key(); key {
|
||||
|
@ -598,6 +598,8 @@ func (l *List) InputHandler() func(event *tcell.EventKey, setFocus func(p Primit
|
|||
if l.selected != nil {
|
||||
l.selected(l.currentItem, item.MainText, item.SecondaryText, item.Shortcut)
|
||||
}
|
||||
default:
|
||||
return event
|
||||
}
|
||||
|
||||
if l.currentItem < 0 {
|
||||
|
@ -610,5 +612,7 @@ func (l *List) InputHandler() func(event *tcell.EventKey, setFocus func(p Primit
|
|||
item := l.items[l.currentItem]
|
||||
l.changed(l.currentItem, item.MainText, item.SecondaryText, item.Shortcut)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package tview
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Modal is a centered message window used to inform the user or prompt them
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package tview
|
||||
|
||||
import "github.com/gdamore/tcell"
|
||||
import tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
// MouseSupport defines wether a component supports accepting mouse events
|
||||
type MouseSupport interface {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package tview
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// page represents one page of a Pages object.
|
||||
|
@ -63,6 +63,7 @@ func (p *Pages) GetPageCount() int {
|
|||
// primitive will be set to the size available to the Pages primitive whenever
|
||||
// the pages are drawn.
|
||||
func (p *Pages) AddPage(name string, item Primitive, resize, visible bool) *Pages {
|
||||
item.SetParent(p)
|
||||
hasFocus := p.HasFocus()
|
||||
for index, pg := range p.pages {
|
||||
if pg.Name == name {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package tview
|
||||
|
||||
import "github.com/gdamore/tcell"
|
||||
import tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
// Primitive is the top-most interface for all graphical primitives.
|
||||
type Primitive interface {
|
||||
|
@ -22,6 +22,14 @@ type Primitive interface {
|
|||
// SetRect sets a new position of the primitive.
|
||||
SetRect(x, y, width, height int)
|
||||
|
||||
// SetParent defines which component this primitive is currently being
|
||||
// treated as a child of. This should never be called manually.
|
||||
SetParent(Primitive)
|
||||
|
||||
// GetParent returns the current parent or nil if the parent hasn't been
|
||||
// set yet.
|
||||
GetParent() Primitive
|
||||
|
||||
// InputHandler returns a handler which receives key events when it has focus.
|
||||
// It is called by the Application class.
|
||||
//
|
||||
|
@ -38,7 +46,10 @@ type Primitive interface {
|
|||
// The Box class provides functionality to intercept keyboard input. If you
|
||||
// subclass from Box, it is recommended that you wrap your handler using
|
||||
// Box.WrapInputHandler() so you inherit that functionality.
|
||||
InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive))
|
||||
InputHandler() InputHandlerFunc
|
||||
|
||||
// OnPaste is called when a bracketed paste is finished.
|
||||
OnPaste([]rune)
|
||||
|
||||
// Focus is called by the application when the primitive receives focus.
|
||||
// Implementers may call delegate() to pass the focus on to another primitive.
|
||||
|
@ -55,4 +66,21 @@ type Primitive interface {
|
|||
|
||||
// GetFocusable returns the item's Focusable.
|
||||
GetFocusable() Focusable
|
||||
|
||||
// NextFocusableComponent decides which component should receive focus next.
|
||||
// If nil is returned, the focus is retained.
|
||||
NextFocusableComponent(FocusDirection) Primitive
|
||||
}
|
||||
|
||||
type InputHandlerFunc func(*tcell.EventKey, func(p Primitive)) *tcell.EventKey
|
||||
|
||||
// FocusDirection decides in what direction the focus should travel relative
|
||||
// to the currently focused component.
|
||||
type FocusDirection int
|
||||
|
||||
const (
|
||||
Up FocusDirection = iota
|
||||
Down
|
||||
Left
|
||||
Right
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package tview
|
||||
|
||||
import "github.com/gdamore/tcell"
|
||||
import tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
// Semigraphics provides an easy way to access unicode characters for drawing.
|
||||
//
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package tview
|
||||
|
||||
import "github.com/gdamore/tcell"
|
||||
import tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
// Theme defines the colors used when primitives are initialized.
|
||||
type Theme struct {
|
||||
|
|
|
@ -3,7 +3,7 @@ package tview
|
|||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
colorful "github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
|
@ -247,10 +247,6 @@ type Table struct {
|
|||
// The number of visible rows the last time the table was drawn.
|
||||
visibleRows int
|
||||
|
||||
// The style of the selected rows. If this value is 0, selected rows are
|
||||
// simply inverted.
|
||||
selectedStyle tcell.Style
|
||||
|
||||
// An optional function which gets called when the user presses Enter on a
|
||||
// selected cell. If entire rows selected, the column value is undefined.
|
||||
// Likewise for entire columns.
|
||||
|
@ -296,18 +292,6 @@ func (t *Table) SetBordersColor(color tcell.Color) *Table {
|
|||
return t
|
||||
}
|
||||
|
||||
// SetSelectedStyle sets a specific style for selected cells. If no such style
|
||||
// is set, per default, selected cells are inverted (i.e. their foreground and
|
||||
// background colors are swapped).
|
||||
//
|
||||
// To reset a previous setting to its default, make the following call:
|
||||
//
|
||||
// table.SetSelectedStyle(tcell.ColorDefault, tcell.ColorDefault, 0)
|
||||
func (t *Table) SetSelectedStyle(foregroundColor, backgroundColor tcell.Color, attributes tcell.AttrMask) *Table {
|
||||
t.selectedStyle = tcell.StyleDefault.Foreground(foregroundColor).Background(backgroundColor) | tcell.Style(attributes)
|
||||
return t
|
||||
}
|
||||
|
||||
// SetSeparator sets the character used to fill the space between two
|
||||
// neighboring cells. This is a space character ' ' per default but you may
|
||||
// want to set it to Borders.Vertical (or any other rune) if the column
|
||||
|
@ -804,7 +788,7 @@ ColumnLoop:
|
|||
finalWidth = width - columnX - 1
|
||||
}
|
||||
cell.x, cell.y, cell.width = x+columnX+1, y+rowY, finalWidth
|
||||
_, printed := printWithStyle(screen, cell.Text, x+columnX+1, y+rowY, finalWidth, cell.Align, tcell.StyleDefault.Foreground(cell.Color)|tcell.Style(cell.Attributes))
|
||||
_, printed := printWithStyle(screen, cell.Text, x+columnX+1, y+rowY, finalWidth, cell.Align, tcell.StyleDefault.Foreground(cell.Color))
|
||||
if TaggedStringWidth(cell.Text)-printed > 0 && printed > 0 {
|
||||
_, _, style, _ := screen.GetContent(x+columnX+1+finalWidth-1, y+rowY)
|
||||
printWithStyle(screen, string(SemigraphicsHorizontalEllipsis), x+columnX+1+finalWidth-1, y+rowY, 1, AlignLeft, style)
|
||||
|
@ -854,27 +838,14 @@ ColumnLoop:
|
|||
for by := 0; by < h && fromY+by < y+height; by++ {
|
||||
for bx := 0; bx < w && fromX+bx < x+width; bx++ {
|
||||
m, c, style, _ := screen.GetContent(fromX+bx, fromY+by)
|
||||
fg, bg, a := style.Decompose()
|
||||
if invert {
|
||||
if fg == textColor || fg == t.bordersColor {
|
||||
fg = backgroundColor
|
||||
}
|
||||
if fg == tcell.ColorDefault {
|
||||
fg = t.backgroundColor
|
||||
}
|
||||
style = style.Background(textColor).Foreground(fg)
|
||||
} else {
|
||||
if backgroundColor != tcell.ColorDefault {
|
||||
bg = backgroundColor
|
||||
}
|
||||
if textColor != tcell.ColorDefault {
|
||||
fg = textColor
|
||||
}
|
||||
if attr != 0 {
|
||||
a = attr
|
||||
}
|
||||
style = style.Background(bg).Foreground(fg) | tcell.Style(a)
|
||||
fg, bg, _ := style.Decompose()
|
||||
if backgroundColor != tcell.ColorDefault {
|
||||
bg = backgroundColor
|
||||
}
|
||||
if textColor != tcell.ColorDefault {
|
||||
fg = textColor
|
||||
}
|
||||
style = style.Background(bg).Foreground(fg).Reverse(invert)
|
||||
screen.SetContent(fromX+bx, fromY+by, m, c, style)
|
||||
}
|
||||
}
|
||||
|
@ -931,16 +902,11 @@ ColumnLoop:
|
|||
_, _, lj := c.Hcl()
|
||||
return li < lj
|
||||
})
|
||||
selFg, selBg, selAttr := t.selectedStyle.Decompose()
|
||||
for _, bgColor := range backgroundColors {
|
||||
entries := cellsByBackgroundColor[bgColor]
|
||||
for _, cell := range entries {
|
||||
if cell.selected {
|
||||
if t.selectedStyle != 0 {
|
||||
defer colorBackground(cell.x, cell.y, cell.w, cell.h, selBg, selFg, selAttr, false)
|
||||
} else {
|
||||
defer colorBackground(cell.x, cell.y, cell.w, cell.h, bgColor, cell.text, 0, true)
|
||||
}
|
||||
defer colorBackground(cell.x, cell.y, cell.w, cell.h, bgColor, cell.text, 0, true)
|
||||
} else {
|
||||
colorBackground(cell.x, cell.y, cell.w, cell.h, bgColor, tcell.ColorDefault, 0, false)
|
||||
}
|
||||
|
@ -951,8 +917,8 @@ ColumnLoop:
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (t *Table) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
return t.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (t *Table) InputHandler() InputHandlerFunc {
|
||||
return t.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
key := event.Key()
|
||||
|
||||
if (!t.rowsSelectable && !t.columnsSelectable && key == tcell.KeyEnter) ||
|
||||
|
@ -961,8 +927,8 @@ func (t *Table) InputHandler() func(event *tcell.EventKey, setFocus func(p Primi
|
|||
key == tcell.KeyBacktab {
|
||||
if t.done != nil {
|
||||
t.done(key)
|
||||
return nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Movement functions.
|
||||
|
@ -1126,6 +1092,8 @@ func (t *Table) InputHandler() func(event *tcell.EventKey, setFocus func(p Primi
|
|||
left()
|
||||
case 'l':
|
||||
right()
|
||||
default:
|
||||
return event
|
||||
}
|
||||
case tcell.KeyHome:
|
||||
home()
|
||||
|
@ -1147,6 +1115,8 @@ func (t *Table) InputHandler() func(event *tcell.EventKey, setFocus func(p Primi
|
|||
if (t.rowsSelectable || t.columnsSelectable) && t.selected != nil {
|
||||
t.selected(t.selectedRow, t.selectedColumn)
|
||||
}
|
||||
default:
|
||||
return event
|
||||
}
|
||||
|
||||
// If the selection has changed, notify the handler.
|
||||
|
@ -1155,5 +1125,7 @@ func (t *Table) InputHandler() func(event *tcell.EventKey, setFocus func(p Primi
|
|||
t.columnsSelectable && previouslySelectedColumn != t.selectedColumn) {
|
||||
t.selectionChanged(t.selectedRow, t.selectedColumn)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
colorful "github.com/lucasb-eyer/go-colorful"
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
@ -990,19 +990,19 @@ func (t *TextView) Draw(screen tcell.Screen) bool {
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (t *TextView) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
return t.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
func (t *TextView) InputHandler() InputHandlerFunc {
|
||||
return t.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
key := event.Key()
|
||||
|
||||
if key == tcell.KeyEscape || key == tcell.KeyEnter || key == tcell.KeyTab || key == tcell.KeyBacktab {
|
||||
if t.done != nil {
|
||||
t.done(key)
|
||||
return nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !t.scrollable {
|
||||
return
|
||||
return event
|
||||
}
|
||||
|
||||
switch key {
|
||||
|
@ -1025,6 +1025,8 @@ func (t *TextView) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr
|
|||
t.columnOffset--
|
||||
case 'l': // Right.
|
||||
t.columnOffset++
|
||||
default:
|
||||
return event
|
||||
}
|
||||
}
|
||||
case tcell.KeyHome:
|
||||
|
@ -1048,6 +1050,10 @@ func (t *TextView) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr
|
|||
case tcell.KeyPgUp:
|
||||
t.trackEnd = false
|
||||
t.lineOffset -= t.pageSize
|
||||
default:
|
||||
return event
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
package tview
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Tree navigation events.
|
||||
|
@ -31,14 +30,15 @@ type TreeNode struct {
|
|||
// The item's text.
|
||||
text string
|
||||
|
||||
// This text is a prefix in front of the normal text.
|
||||
prefix string
|
||||
// This text is a prefixes in front of the normal text.
|
||||
prefixes []string
|
||||
|
||||
// The text color.
|
||||
color tcell.Color
|
||||
|
||||
// The text attributes.
|
||||
attr tcell.AttrMask
|
||||
blinking bool
|
||||
|
||||
underline bool
|
||||
|
||||
// Whether or not this node can be selected.
|
||||
selectable bool
|
||||
|
@ -121,9 +121,9 @@ func (n *TreeNode) GetText() string {
|
|||
return n.text
|
||||
}
|
||||
|
||||
// GetPrefix returns this node's prefix text.
|
||||
func (n *TreeNode) GetPrefix() string {
|
||||
return n.prefix
|
||||
// GetPrefixes returns this node's prefix text.
|
||||
func (n *TreeNode) GetPrefixes() []string {
|
||||
return n.prefixes
|
||||
}
|
||||
|
||||
// GetParent returns a refrence to this nodes parent node or nil if this is a
|
||||
|
@ -215,12 +215,44 @@ func (n *TreeNode) SetText(text string) *TreeNode {
|
|||
return n
|
||||
}
|
||||
|
||||
// SetPrefix sets the node's prefix text which is displayed.
|
||||
func (n *TreeNode) SetPrefix(prefix string) *TreeNode {
|
||||
n.prefix = prefix
|
||||
// AddPrefix sets the node's prefix text which is displayed. Duplicates are
|
||||
// ignored.
|
||||
func (n *TreeNode) AddPrefix(newPrefix string) *TreeNode {
|
||||
for _, prefix := range n.prefixes {
|
||||
if prefix == newPrefix {
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
n.prefixes = append(n.prefixes, newPrefix)
|
||||
return n
|
||||
}
|
||||
|
||||
// RemovePrefix removes the given prefix, maintaining the order of the items.
|
||||
func (n *TreeNode) RemovePrefix(prefix string) {
|
||||
for removeIndex, oldPrefix := range n.prefixes {
|
||||
if oldPrefix == prefix {
|
||||
n.prefixes = append(n.prefixes[:removeIndex], n.prefixes[removeIndex+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearPrefixes removes all prefixes by nulling the underlying array.
|
||||
func (n *TreeNode) ClearPrefixes() {
|
||||
n.prefixes = nil
|
||||
}
|
||||
|
||||
// SortPrefixes sorts all currently set prefixes. This function sin't executed
|
||||
// once new prefixes are added.
|
||||
func (n *TreeNode) SortPrefixes(lessFunction func(a, b string) bool) {
|
||||
sort.Slice(n.prefixes, func(a, b int) bool {
|
||||
aItem := n.prefixes[a]
|
||||
bItem := n.prefixes[b]
|
||||
return lessFunction(aItem, bItem)
|
||||
})
|
||||
}
|
||||
|
||||
// GetColor returns the node's color.
|
||||
func (n *TreeNode) GetColor() tcell.Color {
|
||||
return n.color
|
||||
|
@ -232,15 +264,12 @@ func (n *TreeNode) SetColor(color tcell.Color) *TreeNode {
|
|||
return n
|
||||
}
|
||||
|
||||
// GetAttributes gets the node's attributes.
|
||||
func (n *TreeNode) GetAttributes() tcell.AttrMask {
|
||||
return n.attr
|
||||
func (n *TreeNode) SetUnderline(underline bool) {
|
||||
n.underline = underline
|
||||
}
|
||||
|
||||
// SetAttributes sets the node's attributes.
|
||||
func (n *TreeNode) SetAttributes(attr tcell.AttrMask) *TreeNode {
|
||||
n.attr = attr
|
||||
return n
|
||||
func (n *TreeNode) SetBlinking(blinking bool) {
|
||||
n.blinking = blinking
|
||||
}
|
||||
|
||||
// SetIndent sets an additional indentation for this node's text. A value of 0
|
||||
|
@ -300,7 +329,7 @@ type TreeView struct {
|
|||
topLevel int
|
||||
|
||||
// Strings drawn before the nodes, based on their level.
|
||||
prefixes []string
|
||||
bulletCharacters []string
|
||||
|
||||
// This decides whether the selection will cycle when reaching the end
|
||||
// or the beginning of the tree.
|
||||
|
@ -385,17 +414,17 @@ func (t *TreeView) SetTopLevel(topLevel int) *TreeView {
|
|||
return t
|
||||
}
|
||||
|
||||
// SetPrefixes defines the strings drawn before the nodes' texts. This is a
|
||||
// slice of strings where each element corresponds to a node's hierarchy level,
|
||||
// i.e. 0 for the root, 1 for the root's children, and so on (levels will
|
||||
// cycle).
|
||||
// SetBulletCharacters defines the strings drawn before the nodes' texts.
|
||||
// This is a slice of strings where each element corresponds to a node's
|
||||
// hierarchy level, i.e. 0 for the root, 1 for the root's children, and
|
||||
// so on (levels will cycle).
|
||||
//
|
||||
// For example, to display a hierarchical list with bullet points:
|
||||
//
|
||||
// treeView.SetGraphics(false).
|
||||
// SetPrefixes([]string{"* ", "- ", "x "})
|
||||
func (t *TreeView) SetPrefixes(prefixes []string) *TreeView {
|
||||
t.prefixes = prefixes
|
||||
// SetBulletCharacters([]string{"* ", "- ", "x "})
|
||||
func (t *TreeView) SetBulletCharacters(prefixes []string) *TreeView {
|
||||
t.bulletCharacters = prefixes
|
||||
return t
|
||||
}
|
||||
|
||||
|
@ -763,26 +792,31 @@ func (t *TreeView) Draw(screen tcell.Screen) bool {
|
|||
// Draw the prefix and the text.
|
||||
if node.textX < width && posY < y+height {
|
||||
// Prefix.
|
||||
var prefixWidth int
|
||||
if len(t.prefixes) > 0 {
|
||||
_, prefixWidth = Print(screen, t.prefixes[(node.level-t.topLevel)%len(t.prefixes)], x+node.textX, posY, width-node.textX, AlignLeft, node.color)
|
||||
var bulletCharacterWidth int
|
||||
if len(t.bulletCharacters) > 0 {
|
||||
_, bulletCharacterWidth = Print(screen, t.bulletCharacters[(node.level-t.topLevel)%len(t.bulletCharacters)], x+node.textX, posY, width-node.textX, AlignLeft, node.color)
|
||||
}
|
||||
|
||||
// Text.
|
||||
VTxxx, err := regexp.MatchString("(vt)[0-9]+", os.Getenv("TERM"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if node.textX+prefixWidth < width {
|
||||
style := tcell.StyleDefault.Foreground(node.color) | tcell.Style(node.attr)
|
||||
if node == t.currentNode {
|
||||
if VTxxx {
|
||||
style = tcell.StyleDefault.Reverse(true)
|
||||
} else {
|
||||
style = tcell.StyleDefault.Background(node.color).Foreground(t.backgroundColor) | tcell.Style(node.attr)
|
||||
}
|
||||
if node.textX+bulletCharacterWidth < width {
|
||||
style := tcell.StyleDefault
|
||||
if !IsVtxxx {
|
||||
style = style.Foreground(node.color)
|
||||
}
|
||||
printWithStyle(screen, node.prefix+node.text, x+node.textX+prefixWidth, posY, width-node.textX-prefixWidth, AlignLeft, style)
|
||||
if node == t.currentNode {
|
||||
style = style.Reverse(true)
|
||||
}
|
||||
if node.blinking {
|
||||
style = style.Blink(true)
|
||||
}
|
||||
if node.underline {
|
||||
style = style.Underline(true)
|
||||
}
|
||||
var fullPrefix string
|
||||
for _, prefix := range node.prefixes {
|
||||
fullPrefix += prefix
|
||||
}
|
||||
printWithStyle(screen, fullPrefix+node.text, x+node.textX+bulletCharacterWidth, posY, width-node.textX-bulletCharacterWidth, AlignLeft, style)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -796,67 +830,72 @@ func (t *TreeView) Draw(screen tcell.Screen) bool {
|
|||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (t *TreeView) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
return t.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) {
|
||||
selectNode := func() {
|
||||
if t.currentNode != nil {
|
||||
if t.selected != nil {
|
||||
t.selected(t.currentNode)
|
||||
}
|
||||
if t.currentNode.selected != nil {
|
||||
t.currentNode.selected()
|
||||
func (t *TreeView) InputHandler() InputHandlerFunc {
|
||||
return t.WrapInputHandler(t.DefaultInputHandler)
|
||||
}
|
||||
|
||||
// DefaultInputHandler handles basic navigation and interaction with the
|
||||
// TreeView.
|
||||
func (t *TreeView) DefaultInputHandler(event *tcell.EventKey, setFocus func(p Primitive)) *tcell.EventKey {
|
||||
// Because the tree is flattened into a list only at drawing time, we also
|
||||
// postpone the (selection) movement to drawing time.
|
||||
switch key := event.Key(); key {
|
||||
case tcell.KeyTab, tcell.KeyDown, tcell.KeyRight:
|
||||
t.movement = treeDown
|
||||
case tcell.KeyBacktab, tcell.KeyUp, tcell.KeyLeft:
|
||||
t.movement = treeUp
|
||||
case tcell.KeyHome:
|
||||
t.movement = treeHome
|
||||
case tcell.KeyEnd:
|
||||
t.movement = treeEnd
|
||||
case tcell.KeyPgDn, tcell.KeyCtrlF:
|
||||
t.movement = treePageDown
|
||||
case tcell.KeyPgUp, tcell.KeyCtrlB:
|
||||
t.movement = treePageUp
|
||||
case tcell.KeyRune:
|
||||
if t.vimBindings {
|
||||
switch event.Rune() {
|
||||
case 'g':
|
||||
t.movement = treeHome
|
||||
case 'G':
|
||||
t.movement = treeEnd
|
||||
case 'j':
|
||||
t.movement = treeDown
|
||||
case 'k':
|
||||
t.movement = treeUp
|
||||
default:
|
||||
return event
|
||||
}
|
||||
} else if t.searchOnType {
|
||||
if time.Since(t.jumpTime) > (500 * time.Millisecond) {
|
||||
t.jumpBuffer = ""
|
||||
}
|
||||
|
||||
if event.Key() == tcell.KeyRune {
|
||||
t.jumpTime = time.Now()
|
||||
t.jumpBuffer += strings.ToLower(string(event.Rune()))
|
||||
|
||||
node := t.FindFirstSelectableNode(t.GetRoot(), t.jumpBuffer)
|
||||
if node != nil {
|
||||
t.SetCurrentNode(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Because the tree is flattened into a list only at drawing time, we also
|
||||
// postpone the (selection) movement to drawing time.
|
||||
switch key := event.Key(); key {
|
||||
case tcell.KeyTab, tcell.KeyDown, tcell.KeyRight:
|
||||
t.movement = treeDown
|
||||
case tcell.KeyBacktab, tcell.KeyUp, tcell.KeyLeft:
|
||||
t.movement = treeUp
|
||||
case tcell.KeyHome:
|
||||
t.movement = treeHome
|
||||
case tcell.KeyEnd:
|
||||
t.movement = treeEnd
|
||||
case tcell.KeyPgDn, tcell.KeyCtrlF:
|
||||
t.movement = treePageDown
|
||||
case tcell.KeyPgUp, tcell.KeyCtrlB:
|
||||
t.movement = treePageUp
|
||||
case tcell.KeyRune:
|
||||
if t.vimBindings {
|
||||
switch event.Rune() {
|
||||
case 'g':
|
||||
t.movement = treeHome
|
||||
case 'G':
|
||||
t.movement = treeEnd
|
||||
case 'j':
|
||||
t.movement = treeDown
|
||||
case 'k':
|
||||
t.movement = treeUp
|
||||
}
|
||||
} else if t.searchOnType {
|
||||
if time.Since(t.jumpTime) > (500 * time.Millisecond) {
|
||||
t.jumpBuffer = ""
|
||||
}
|
||||
|
||||
if event.Key() == tcell.KeyRune {
|
||||
t.jumpTime = time.Now()
|
||||
t.jumpBuffer += strings.ToLower(string(event.Rune()))
|
||||
|
||||
node := t.FindFirstSelectableNode(t.GetRoot(), t.jumpBuffer)
|
||||
if node != nil {
|
||||
t.SetCurrentNode(node)
|
||||
}
|
||||
}
|
||||
case tcell.KeyEnter:
|
||||
if t.currentNode != nil {
|
||||
if t.selected != nil {
|
||||
t.selected(t.currentNode)
|
||||
}
|
||||
if t.currentNode.selected != nil {
|
||||
t.currentNode.selected()
|
||||
}
|
||||
case tcell.KeyEnter:
|
||||
selectNode()
|
||||
}
|
||||
default:
|
||||
return event
|
||||
}
|
||||
|
||||
t.process()
|
||||
})
|
||||
t.process()
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindFirstSelectableNode iterates through the tree from top to bottom, trying
|
||||
|
|
|
@ -2,11 +2,12 @@ package tview
|
|||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/rivo/uniseg"
|
||||
)
|
||||
|
@ -18,6 +19,18 @@ const (
|
|||
AlignRight
|
||||
)
|
||||
|
||||
// IsVtxxx indicates whether the TERM environment variable matches that of a
|
||||
// VTxxx terminal, for example VT320.
|
||||
var IsVtxxx = checkVT()
|
||||
|
||||
func checkVT() bool {
|
||||
isVtxxx, err := regexp.MatchString("(vt)[0-9]+", os.Getenv("TERM"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return isVtxxx
|
||||
}
|
||||
|
||||
// Common regular expressions.
|
||||
var (
|
||||
colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([lbdru]+|\-)?)?)?\]`)
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/ui/tviewutil"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
|
@ -27,28 +23,24 @@ const (
|
|||
channelRead
|
||||
)
|
||||
|
||||
func checkVT() bool {
|
||||
VTxxx, err := regexp.MatchString("(vt)[0-9]+", os.Getenv("TERM"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return VTxxx
|
||||
}
|
||||
|
||||
var vtxxx = checkVT()
|
||||
var (
|
||||
mentionedIndicator = "(@)"
|
||||
nsfwIndicator = tviewutil.Escape("🔞")
|
||||
lockedIndicator = tviewutil.Escape("\U0001F512")
|
||||
)
|
||||
|
||||
// ChannelTree is the component that displays the channel hierarchy of the
|
||||
// currently loaded guild and allows interactions with those channels.
|
||||
type ChannelTree struct {
|
||||
*tview.TreeView
|
||||
*sync.Mutex
|
||||
|
||||
state *discordgo.State
|
||||
|
||||
onChannelSelect func(channelID string)
|
||||
channelStates map[*tview.TreeNode]channelState
|
||||
channelPosition map[string]int
|
||||
|
||||
mutex *sync.Mutex
|
||||
prefixes map[string][]string
|
||||
}
|
||||
|
||||
// NewChannelTree creates a new ready-to-be-used ChannelTree
|
||||
|
@ -58,7 +50,8 @@ func NewChannelTree(state *discordgo.State) *ChannelTree {
|
|||
TreeView: tview.NewTreeView(),
|
||||
channelStates: make(map[*tview.TreeNode]channelState),
|
||||
channelPosition: make(map[string]int),
|
||||
mutex: &sync.Mutex{},
|
||||
prefixes: make(map[string][]string),
|
||||
Mutex: &sync.Mutex{},
|
||||
}
|
||||
|
||||
channelTree.
|
||||
|
@ -89,7 +82,8 @@ func (channelTree *ChannelTree) Clear() {
|
|||
// LoadGuild accesses the state in order to load all locally present channels
|
||||
// for the passed guild.
|
||||
func (channelTree *ChannelTree) LoadGuild(guildID string) error {
|
||||
guild, cacheError := channelTree.state.Guild(guildID)
|
||||
state := channelTree.state
|
||||
guild, cacheError := state.Guild(guildID)
|
||||
if cacheError != nil {
|
||||
return cacheError
|
||||
}
|
||||
|
@ -102,15 +96,15 @@ func (channelTree *ChannelTree) LoadGuild(guildID string) error {
|
|||
})
|
||||
|
||||
// Top level channel
|
||||
state := channelTree.state
|
||||
for _, channel := range channels {
|
||||
if (channel.Type != discordgo.ChannelTypeGuildText && channel.Type != discordgo.ChannelTypeGuildNews) ||
|
||||
channel.ParentID != "" || !discordutil.HasReadMessagesPermission(channel.ID, state) {
|
||||
continue
|
||||
}
|
||||
createTopLevelChannelNodes(channelTree, channel)
|
||||
channelTree.createTopLevelChannelNodes(channel)
|
||||
}
|
||||
// Categories
|
||||
// Categories; Must be handled before second level channels, as the
|
||||
// categories serve as parents.
|
||||
CATEGORY_LOOP:
|
||||
for _, channel := range channels {
|
||||
if channel.Type != discordgo.ChannelTypeGuildCategory || channel.ParentID != "" {
|
||||
|
@ -122,92 +116,92 @@ CATEGORY_LOOP:
|
|||
if potentialChild.ParentID == channel.ID {
|
||||
if discordutil.HasReadMessagesPermission(potentialChild.ID, state) {
|
||||
//We have at least one child with read-permissions,
|
||||
// therefore we add the category and jump to the next
|
||||
createChannelCategoryNode(channelTree, channel)
|
||||
//therefore we add the category as the channel will need
|
||||
//a parent.
|
||||
channelTree.createChannelCategoryNode(channel)
|
||||
continue CATEGORY_LOOP
|
||||
}
|
||||
|
||||
//Has at least once child-channel, so we don't need to add a
|
||||
//category later on, if none of the child-channels is
|
||||
//accessible by the currently logged on user.
|
||||
childless = false
|
||||
}
|
||||
}
|
||||
|
||||
//If the category is childless, we want to add it anyway.
|
||||
if childless {
|
||||
createChannelCategoryNode(channelTree, channel)
|
||||
channelTree.createChannelCategoryNode(channel)
|
||||
}
|
||||
}
|
||||
// Second level channel
|
||||
for _, channel := range channels {
|
||||
//Only Text and News are supported. If new channel types are
|
||||
//added, support first needs to be confirmed or implemented. This is
|
||||
//in order to avoid faulty runtime behaviour.
|
||||
if (channel.Type != discordgo.ChannelTypeGuildText && channel.Type != discordgo.ChannelTypeGuildNews) ||
|
||||
channel.ParentID == "" || !discordutil.HasReadMessagesPermission(channel.ID, state) {
|
||||
continue
|
||||
}
|
||||
createSecondLevelChannelNodes(channelTree, channel)
|
||||
channelTree.createSecondLevelChannelNodes(channel)
|
||||
}
|
||||
channelTree.SetCurrentNode(channelTree.GetRoot())
|
||||
return nil
|
||||
}
|
||||
|
||||
func createTopLevelChannelNodes(channelTree *ChannelTree, channel *discordgo.Channel) {
|
||||
channelNode := createChannelNode(channel)
|
||||
if !readstate.HasBeenRead(channel, channel.LastMessageID) {
|
||||
channelTree.channelStates[channelNode] = channelUnread
|
||||
if vtxxx {
|
||||
channelNode.SetAttributes(tcell.AttrBlink)
|
||||
} else {
|
||||
channelNode.SetColor(config.GetTheme().AttentionColor)
|
||||
}
|
||||
}
|
||||
func (channelTree *ChannelTree) createTopLevelChannelNodes(channel *discordgo.Channel) {
|
||||
channelNode := channelTree.createTextChannelNode(channel)
|
||||
channelTree.GetRoot().AddChild(channelNode)
|
||||
}
|
||||
|
||||
func createChannelCategoryNode(channelTree *ChannelTree, channel *discordgo.Channel) {
|
||||
channelNode := createChannelNode(channel)
|
||||
channelNode.SetSelectable(false)
|
||||
func (channelTree *ChannelTree) createChannelCategoryNode(channel *discordgo.Channel) {
|
||||
channelNode := channelTree.createChannelNode(channel)
|
||||
channelTree.GetRoot().AddChild(channelNode)
|
||||
}
|
||||
|
||||
func createSecondLevelChannelNodes(channelTree *ChannelTree, channel *discordgo.Channel) {
|
||||
channelNode := createChannelNode(channel)
|
||||
for _, node := range channelTree.GetRoot().GetChildren() {
|
||||
channelID, ok := node.GetReference().(string)
|
||||
if ok && channelID == channel.ParentID {
|
||||
if !readstate.HasBeenRead(channel, channel.LastMessageID) {
|
||||
channelTree.channelStates[channelNode] = channelUnread
|
||||
if vtxxx {
|
||||
channelNode.SetAttributes(tcell.AttrBlink)
|
||||
} else {
|
||||
channelNode.SetColor(config.GetTheme().AttentionColor)
|
||||
}
|
||||
}
|
||||
|
||||
node.AddChild(channelNode)
|
||||
break
|
||||
}
|
||||
func (channelTree *ChannelTree) createSecondLevelChannelNodes(channel *discordgo.Channel) {
|
||||
parentNode := tviewutil.GetNodeByReference(channel.ParentID, channelTree.TreeView)
|
||||
if parentNode != nil {
|
||||
channelNode := channelTree.createTextChannelNode(channel)
|
||||
parentNode.AddChild(channelNode)
|
||||
}
|
||||
}
|
||||
|
||||
func createChannelNode(channel *discordgo.Channel) *tview.TreeNode {
|
||||
channelNode := tview.NewTreeNode(channel.Name)
|
||||
var prefixes string
|
||||
func (channelTree *ChannelTree) createChannelNode(channel *discordgo.Channel) *tview.TreeNode {
|
||||
channelNode := tview.NewTreeNode(tviewutil.Escape(channel.Name))
|
||||
if channel.NSFW {
|
||||
prefixes += tviewutil.Escape("🔞")
|
||||
channelNode.AddPrefix(nsfwIndicator)
|
||||
}
|
||||
|
||||
// Adds a padlock prefix if the channel if not readable by the everyone group
|
||||
if config.Current.IndicateChannelAccessRestriction {
|
||||
for _, permission := range channel.PermissionOverwrites {
|
||||
if permission.Type == "role" && permission.ID == channel.GuildID && permission.Deny&discordgo.PermissionViewChannel == discordgo.PermissionViewChannel {
|
||||
prefixes += tviewutil.Escape("\U0001F512")
|
||||
channelNode.AddPrefix(lockedIndicator)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channelNode.SetPrefix(prefixes)
|
||||
|
||||
channelNode.SetReference(channel.ID)
|
||||
return channelNode
|
||||
}
|
||||
|
||||
func (channelTree *ChannelTree) createTextChannelNode(channel *discordgo.Channel) *tview.TreeNode {
|
||||
channelNode := channelTree.createChannelNode(channel)
|
||||
|
||||
if !readstate.HasBeenRead(channel, channel.LastMessageID) {
|
||||
channelTree.channelStates[channelNode] = channelUnread
|
||||
channelTree.markNodeAsUnread(channelNode)
|
||||
}
|
||||
|
||||
if readstate.HasBeenMentioned(channel.ID) {
|
||||
channelTree.markNodeAsMentioned(channelNode, channel.ID)
|
||||
}
|
||||
|
||||
return channelNode
|
||||
}
|
||||
|
||||
// AddOrUpdateChannel either adds a new node for the given channel or updates
|
||||
// its current node.
|
||||
func (channelTree *ChannelTree) AddOrUpdateChannel(channel *discordgo.Channel) {
|
||||
|
@ -215,7 +209,7 @@ func (channelTree *ChannelTree) AddOrUpdateChannel(channel *discordgo.Channel) {
|
|||
channelTree.GetRoot().Walk(func(node, parent *tview.TreeNode) bool {
|
||||
nodeChannelID, ok := node.GetReference().(string)
|
||||
if ok && nodeChannelID == channel.ID {
|
||||
//TODO Do the moving somehow
|
||||
//TODO Support Re-Parenting
|
||||
/*oldPosition := channelTree.channelPosition[channel.ID]
|
||||
oldParentID, parentOk := parent.GetReference().(string)
|
||||
if (!parentOk && channel.ParentID != "") || (oldPosition != channel.Position) ||
|
||||
|
@ -233,15 +227,13 @@ func (channelTree *ChannelTree) AddOrUpdateChannel(channel *discordgo.Channel) {
|
|||
})
|
||||
|
||||
if !updated {
|
||||
channelNode := createChannelNode(channel)
|
||||
channelNode := channelTree.createChannelNode(channel)
|
||||
if channel.ParentID == "" {
|
||||
channelTree.GetRoot().AddChild(channelNode)
|
||||
} else {
|
||||
for _, node := range channelTree.GetRoot().GetChildren() {
|
||||
channelID, ok := node.GetReference().(string)
|
||||
if ok && channelID == channel.ParentID {
|
||||
node.AddChild(channelNode)
|
||||
}
|
||||
parentNode := tviewutil.GetNodeByReference(channel.ParentID, channelTree.TreeView)
|
||||
if parentNode != parentNode {
|
||||
parentNode.AddChild(channelNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -262,15 +254,12 @@ func (channelTree *ChannelTree) RemoveChannel(channel *discordgo.Channel) {
|
|||
return true
|
||||
})
|
||||
} else if channel.Type == discordgo.ChannelTypeGuildCategory {
|
||||
for _, node := range channelTree.GetRoot().GetChildren() {
|
||||
nodeChannelID, ok := node.GetReference().(string)
|
||||
if ok && nodeChannelID == channelID {
|
||||
oldChildren := node.GetChildren()
|
||||
node.SetChildren(make([]*tview.TreeNode, 0))
|
||||
channelTree.removeNode(node, channelTree.GetRoot(), channelID)
|
||||
channelTree.GetRoot().SetChildren(append(channelTree.GetRoot().GetChildren(), oldChildren...))
|
||||
break
|
||||
}
|
||||
node := tviewutil.GetNodeByReference(channelID, channelTree.TreeView)
|
||||
if node != nil {
|
||||
oldChildren := node.GetChildren()
|
||||
node.SetChildren(make([]*tview.TreeNode, 0))
|
||||
channelTree.removeNode(node, channelTree.GetRoot(), channelID)
|
||||
channelTree.GetRoot().SetChildren(append(channelTree.GetRoot().GetChildren(), oldChildren...))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -293,120 +282,104 @@ func (channelTree *ChannelTree) removeNode(node, parent *tview.TreeNode, channel
|
|||
}
|
||||
}
|
||||
|
||||
// MarkChannelAsUnread marks a channel as unread.
|
||||
func (channelTree *ChannelTree) MarkChannelAsUnread(channelID string) {
|
||||
channelTree.GetRoot().Walk(func(node, parent *tview.TreeNode) bool {
|
||||
referenceChannelID, ok := node.GetReference().(string)
|
||||
if ok && referenceChannelID == channelID {
|
||||
channelTree.channelStates[node] = channelUnread
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrBlink)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().AttentionColor)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
// MarkAsUnread marks a channel as unread.
|
||||
func (channelTree *ChannelTree) MarkAsUnread(channelID string) {
|
||||
node := tviewutil.GetNodeByReference(channelID, channelTree.TreeView)
|
||||
if node != nil {
|
||||
channelTree.channelStates[node] = channelUnread
|
||||
channelTree.markNodeAsUnread(node)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkChannelAsRead marks a channel as read if it's not loaded already.
|
||||
func (channelTree *ChannelTree) MarkChannelAsRead(channelID string) {
|
||||
channelTree.GetRoot().Walk(func(node, parent *tview.TreeNode) bool {
|
||||
referenceChannelID, ok := node.GetReference().(string)
|
||||
if ok && referenceChannelID == channelID {
|
||||
channel, stateError := channelTree.state.Channel(channelID)
|
||||
if stateError == nil {
|
||||
node.SetText(tviewutil.Escape(channel.Name))
|
||||
}
|
||||
|
||||
if channelTree.channelStates[node] != channelLoaded {
|
||||
channelTree.channelStates[node] = channelRead
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrNone)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().PrimaryTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
func (channelTree *ChannelTree) markNodeAsUnread(node *tview.TreeNode) {
|
||||
if tview.IsVtxxx {
|
||||
node.SetBlinking(true)
|
||||
node.SetUnderline(false)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().AttentionColor)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkChannelAsMentioned marks a channel as mentioned.
|
||||
func (channelTree *ChannelTree) MarkChannelAsMentioned(channelID string) {
|
||||
channelTree.GetRoot().Walk(func(node, parent *tview.TreeNode) bool {
|
||||
referenceChannelID, ok := node.GetReference().(string)
|
||||
if ok && referenceChannelID == channelID {
|
||||
channelTree.channelStates[node] = channelMentioned
|
||||
channel, stateError := channelTree.state.Channel(channelID)
|
||||
if stateError == nil {
|
||||
node.SetText("(@You) " + tviewutil.Escape(channel.Name))
|
||||
}
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrBlink)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().AttentionColor)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
// MarkAsRead marks a channel as read.
|
||||
func (channelTree *ChannelTree) MarkAsRead(channelID string) {
|
||||
node := tviewutil.GetNodeByReference(channelID, channelTree.TreeView)
|
||||
if node != nil {
|
||||
channelTree.channelStates[node] = channelRead
|
||||
channelTree.markNodeAsRead(node)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkChannelAsLoaded marks a channel as loaded and therefore marks all other
|
||||
func (channelTree *ChannelTree) markNodeAsRead(node *tview.TreeNode) {
|
||||
if tview.IsVtxxx {
|
||||
node.SetBlinking(false)
|
||||
node.SetUnderline(false)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().PrimaryTextColor)
|
||||
}
|
||||
node.RemovePrefix(mentionedIndicator)
|
||||
}
|
||||
|
||||
// MarkAsMentioned marks a channel as mentioned.
|
||||
func (channelTree *ChannelTree) MarkAsMentioned(channelID string) {
|
||||
node := tviewutil.GetNodeByReference(channelID, channelTree.TreeView)
|
||||
if node != nil {
|
||||
channelTree.channelStates[node] = channelMentioned
|
||||
channelTree.markNodeAsMentioned(node, channelID)
|
||||
}
|
||||
}
|
||||
|
||||
func (channelTree *ChannelTree) markNodeAsMentioned(node *tview.TreeNode, channelID string) {
|
||||
channelTree.markNodeAsUnread(node)
|
||||
node.AddPrefix(mentionedIndicator)
|
||||
node.SortPrefixes(channelTree.prefixSorter)
|
||||
}
|
||||
|
||||
func (channelTree *ChannelTree) prefixSorter(a, b string) bool {
|
||||
if a == mentionedIndicator {
|
||||
return true
|
||||
} else if b == mentionedIndicator {
|
||||
return false
|
||||
} else if a == nsfwIndicator {
|
||||
return true
|
||||
} else if b == nsfwIndicator {
|
||||
return false
|
||||
} else if a == lockedIndicator {
|
||||
return true
|
||||
} else if b == lockedIndicator {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MarkAsLoaded marks a channel as loaded and therefore marks all other
|
||||
// channels as either unread, read or mentioned.
|
||||
func (channelTree *ChannelTree) MarkChannelAsLoaded(channelID string) {
|
||||
func (channelTree *ChannelTree) MarkAsLoaded(channelID string) {
|
||||
for node, state := range channelTree.channelStates {
|
||||
if state == channelLoaded {
|
||||
channelTree.channelStates[node] = channelRead
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrNone)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().PrimaryTextColor)
|
||||
}
|
||||
channelTree.markNodeAsRead(node)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
channelTree.GetRoot().Walk(func(node, parent *tview.TreeNode) bool {
|
||||
referenceChannelID, ok := node.GetReference().(string)
|
||||
if ok && referenceChannelID == channelID {
|
||||
channelTree.channelStates[node] = channelLoaded
|
||||
channel, stateError := channelTree.state.Channel(channelID)
|
||||
if stateError == nil {
|
||||
node.SetText(tviewutil.Escape(channel.Name))
|
||||
}
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrUnderline)
|
||||
} else {
|
||||
node.SetColor(tview.Styles.ContrastBackgroundColor)
|
||||
}
|
||||
return false
|
||||
}
|
||||
node := tviewutil.GetNodeByReference(channelID, channelTree.TreeView)
|
||||
if node != nil {
|
||||
channelTree.channelStates[node] = channelLoaded
|
||||
channelTree.markNodeAsLoaded(node)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
func (channelTree *ChannelTree) markNodeAsLoaded(node *tview.TreeNode) {
|
||||
if tview.IsVtxxx {
|
||||
node.SetUnderline(true)
|
||||
node.SetBlinking(false)
|
||||
} else {
|
||||
node.SetColor(tview.Styles.ContrastBackgroundColor)
|
||||
}
|
||||
node.RemovePrefix(mentionedIndicator)
|
||||
}
|
||||
|
||||
// SetOnChannelSelect sets the handler that reacts to channel selection events.
|
||||
func (channelTree *ChannelTree) SetOnChannelSelect(handler func(channelID string)) {
|
||||
channelTree.onChannelSelect = handler
|
||||
}
|
||||
|
||||
// Lock will lock the ChannelTree, allowing other callers to prevent race
|
||||
// conditions.
|
||||
func (channelTree *ChannelTree) Lock() {
|
||||
channelTree.mutex.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the previously locked ChannelTree.
|
||||
func (channelTree *ChannelTree) Unlock() {
|
||||
channelTree.mutex.Unlock()
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/Bios-Marcel/discordgo"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestChannelTree(t *testing.T) {
|
||||
|
|
|
@ -11,7 +11,7 @@ import (
|
|||
"sync"
|
||||
|
||||
linkshortener "github.com/Bios-Marcel/shortnotforlong"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
"github.com/Bios-Marcel/cordless/discordutil"
|
||||
|
@ -32,7 +32,10 @@ import (
|
|||
)
|
||||
|
||||
const dashCharacter = "\u2500"
|
||||
const EMBED_TIMESTAMP_FORMAT = "2006-01-02 15:04"
|
||||
|
||||
// embedTimestampFormat represents the format for times used when
|
||||
// rendering embeds.
|
||||
const embedTimestampFormat = "2006-01-02 15:04"
|
||||
|
||||
var (
|
||||
successiveCustomEmojiRegex = regexp.MustCompile("<a?:.+?:\\d+(><)a?:.+?:\\d+>")
|
||||
|
@ -49,6 +52,8 @@ var (
|
|||
// in a simple way. It supports highlighting specific element types and it
|
||||
// also supports multiline.
|
||||
type ChatView struct {
|
||||
*sync.Mutex
|
||||
|
||||
internalTextView *tview.TextView
|
||||
|
||||
shortener *linkshortener.Shortener
|
||||
|
@ -69,13 +74,12 @@ type ChatView struct {
|
|||
formattedMessages map[string]string
|
||||
|
||||
onMessageAction func(message *discordgo.Message, event *tcell.EventKey) *tcell.EventKey
|
||||
|
||||
mutex *sync.Mutex
|
||||
}
|
||||
|
||||
// NewChatView constructs a new ready to use ChatView.
|
||||
func NewChatView(state *discordgo.State, ownUserID string) *ChatView {
|
||||
chatView := ChatView{
|
||||
data: make([]*discordgo.Message, 0, 100),
|
||||
internalTextView: tview.NewTextView(),
|
||||
state: state,
|
||||
ownUserID: ownUserID,
|
||||
|
@ -90,7 +94,7 @@ func NewChatView(state *discordgo.State, ownUserID string) *ChatView {
|
|||
shortenLinks: config.Current.ShortenLinks,
|
||||
shortenWithExtension: config.Current.ShortenWithExtension,
|
||||
formattedMessages: make(map[string]string),
|
||||
mutex: &sync.Mutex{},
|
||||
Mutex: &sync.Mutex{},
|
||||
}
|
||||
|
||||
if chatView.shortenLinks {
|
||||
|
@ -115,7 +119,7 @@ func NewChatView(state *discordgo.State, ownUserID string) *ChatView {
|
|||
|
||||
chatView.internalTextView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if chatView.selectionMode && event.Modifiers() == tcell.ModNone {
|
||||
if event.Key() == tcell.KeyUp {
|
||||
if shortcuts.ChatViewSelectionUp.Equals(event) {
|
||||
if chatView.selection == -1 {
|
||||
chatView.selection = len(chatView.data) - 1
|
||||
} else if chatView.selection >= 1 {
|
||||
|
@ -129,7 +133,7 @@ func NewChatView(state *discordgo.State, ownUserID string) *ChatView {
|
|||
return nil
|
||||
}
|
||||
|
||||
if event.Key() == tcell.KeyDown {
|
||||
if shortcuts.ChatViewSelectionDown.Equals(event) {
|
||||
if chatView.selection == -1 {
|
||||
chatView.selection = 0
|
||||
} else if chatView.selection <= len(chatView.data)-2 {
|
||||
|
@ -143,7 +147,7 @@ func NewChatView(state *discordgo.State, ownUserID string) *ChatView {
|
|||
return nil
|
||||
}
|
||||
|
||||
if event.Key() == tcell.KeyHome {
|
||||
if shortcuts.ChatViewSelectionTop.Equals(event) {
|
||||
if chatView.selection != 0 {
|
||||
chatView.selection = 0
|
||||
|
||||
|
@ -153,7 +157,7 @@ func NewChatView(state *discordgo.State, ownUserID string) *ChatView {
|
|||
return nil
|
||||
}
|
||||
|
||||
if event.Key() == tcell.KeyEnd {
|
||||
if shortcuts.ChatViewSelectionBottom.Equals(event) {
|
||||
if chatView.selection != len(chatView.data)-1 {
|
||||
chatView.selection = len(chatView.data) - 1
|
||||
|
||||
|
@ -282,7 +286,9 @@ OUTER_LOOP:
|
|||
// ClearViewAndCache clears the TextView buffer and removes all data for
|
||||
// all messages.
|
||||
func (chatView *ChatView) ClearViewAndCache() {
|
||||
chatView.data = make([]*discordgo.Message, 0)
|
||||
//100 as default size, as we usually have message. Even if not, this
|
||||
//is worth the memory overhead.
|
||||
chatView.data = make([]*discordgo.Message, 0, 100)
|
||||
chatView.showSpoilerContent = make(map[string]bool)
|
||||
chatView.formattedMessages = make(map[string]string)
|
||||
chatView.selection = -1
|
||||
|
@ -442,7 +448,7 @@ func (chatView *ChatView) formatMessageAuthor(message *discordgo.Message) string
|
|||
userColor = discordutil.GetUserColor(message.Author)
|
||||
}
|
||||
|
||||
return "[::b][" + userColor + "]" + messageAuthor + "[::-]"
|
||||
return "[::b][" + userColor + "]" + messageAuthor + ":[::-]"
|
||||
}
|
||||
|
||||
func (chatView *ChatView) formatMessageText(message *discordgo.Message) string {
|
||||
|
@ -461,7 +467,12 @@ func (chatView *ChatView) formatMessageText(message *discordgo.Message) string {
|
|||
} else if message.Type == discordgo.MessageTypeRecipientAdd {
|
||||
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]added " + message.Mentions[0].Username + " to the group."
|
||||
} else if message.Type == discordgo.MessageTypeRecipientRemove {
|
||||
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]removed " + message.Mentions[0].Username + " from the group."
|
||||
removedUser := message.Mentions[0]
|
||||
if removedUser.ID == message.Author.ID {
|
||||
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]has left the group."
|
||||
}
|
||||
|
||||
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]removed " + removedUser.Username + " from the group."
|
||||
} else if message.Type == discordgo.MessageTypeChannelFollowAdd {
|
||||
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]has added '" + message.Content + "' to this channel."
|
||||
} else if message.Type == discordgo.MessageTypeUserPremiumGuildSubscription ||
|
||||
|
@ -512,7 +523,7 @@ func (chatView *ChatView) formatDefaultMessageText(message *discordgo.Message) s
|
|||
}
|
||||
|
||||
var color string
|
||||
if vtxxx {
|
||||
if tview.IsVtxxx {
|
||||
if chatView.state.User.ID == user.ID {
|
||||
color = "[::r]"
|
||||
} else {
|
||||
|
@ -527,7 +538,7 @@ func (chatView *ChatView) formatDefaultMessageText(message *discordgo.Message) s
|
|||
}
|
||||
|
||||
var replacement string
|
||||
if vtxxx {
|
||||
if tview.IsVtxxx {
|
||||
replacement = color + "@" + userName + "[::-]"
|
||||
} else {
|
||||
replacement = color + "@" + userName + "[" + tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor) + "]"
|
||||
|
@ -703,8 +714,32 @@ func (chatView *ChatView) formatDefaultMessageText(message *discordgo.Message) s
|
|||
}
|
||||
}
|
||||
|
||||
var reactionText string
|
||||
if len(message.Reactions) > 0 {
|
||||
var reactionBuilder strings.Builder
|
||||
reactionBuilder.Grow(10 + len(message.Reactions)*8)
|
||||
reactionBuilder.WriteString("\nReactions: ")
|
||||
for rIndex, reaction := range message.Reactions {
|
||||
if reaction.Emoji.Name != "" {
|
||||
reactionBuilder.WriteString(tviewutil.Escape(reaction.Emoji.Name))
|
||||
if reaction.Me {
|
||||
reactionBuilder.WriteString("[::r]")
|
||||
}
|
||||
reactionBuilder.WriteRune('-')
|
||||
reactionBuilder.WriteString(strconv.FormatInt(int64(reaction.Count), 10))
|
||||
if reaction.Me {
|
||||
reactionBuilder.WriteString("[::-]")
|
||||
}
|
||||
if rIndex != len(message.Reactions)-1 {
|
||||
reactionBuilder.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
reactionText = reactionBuilder.String()
|
||||
}
|
||||
|
||||
if !hasRichEmbed {
|
||||
return messageText
|
||||
return messageText + reactionText
|
||||
}
|
||||
|
||||
var messageBuffer strings.Builder
|
||||
|
@ -787,7 +822,7 @@ func (chatView *ChatView) formatDefaultMessageText(message *discordgo.Message) s
|
|||
embedBuffer.WriteString(" - ")
|
||||
}
|
||||
localTime := parsedTimestamp.Local()
|
||||
embedBuffer.WriteString(localTime.Format(EMBED_TIMESTAMP_FORMAT))
|
||||
embedBuffer.WriteString(localTime.Format(embedTimestampFormat))
|
||||
} else {
|
||||
log.Println("Error parsing time: " + err.Error())
|
||||
}
|
||||
|
@ -795,11 +830,9 @@ func (chatView *ChatView) formatDefaultMessageText(message *discordgo.Message) s
|
|||
|
||||
messageBuffer.WriteString(strings.Replace(parseBoldAndUnderline(embedBuffer.String()), "\n", "\n"+color+"▐["+defaultColor+"] ", -1))
|
||||
embedBuffer.WriteRune('\n')
|
||||
|
||||
//TODO embed.Timestamp
|
||||
}
|
||||
|
||||
return messageBuffer.String()
|
||||
return messageBuffer.String() + reactionText
|
||||
}
|
||||
|
||||
func parseCustomEmojis(text string) string {
|
||||
|
@ -1044,14 +1077,3 @@ func (chatView *ChatView) SetMessages(messages []*discordgo.Message) {
|
|||
chatView.internalTextView.ScrollToEnd()
|
||||
}
|
||||
}
|
||||
|
||||
// Lock will lock the ChatView, allowing other callers to prevent race
|
||||
// conditions.
|
||||
func (chatView *ChatView) Lock() {
|
||||
chatView.mutex.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the previously locked ChatView.
|
||||
func (chatView *ChatView) Unlock() {
|
||||
chatView.mutex.Unlock()
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
const noHistoryIndexSelected = -1
|
||||
|
@ -27,7 +27,7 @@ type CommandView struct {
|
|||
|
||||
// NewCommandView creates a new struct containing the components necessary
|
||||
// for a command view. It also contains the state for those components.
|
||||
func NewCommandView(onExecuteCommand func(command string)) *CommandView {
|
||||
func NewCommandView(app *tview.Application, onExecuteCommand func(command string)) *CommandView {
|
||||
commandOutput := tview.NewTextView()
|
||||
commandOutput.SetDynamicColors(true).
|
||||
SetWordWrap(true).
|
||||
|
@ -36,7 +36,7 @@ func NewCommandView(onExecuteCommand func(command string)) *CommandView {
|
|||
SetBorder(true).
|
||||
SetIndicateOverflow(true)
|
||||
|
||||
commandInput := NewEditor()
|
||||
commandInput := NewEditor(app)
|
||||
commandInput.internalTextView.
|
||||
SetWrap(false).
|
||||
SetWordWrap(false)
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// AutocompleteView is a simple treeview meant for displaying autocomplete
|
||||
// choices. The speccial part about this component is, that it can redirect
|
||||
// certain events to a different component, as only certain events are
|
||||
// treated directly.
|
||||
type AutocompleteView struct {
|
||||
*tview.TreeView
|
||||
}
|
||||
|
||||
// NewAutocompleteView creates a ready-to-use AutocompleteView.
|
||||
func NewAutocompleteView() *AutocompleteView {
|
||||
treeView := tview.NewTreeView()
|
||||
//Has to be disabled, as we need the events for the related editor.
|
||||
treeView.
|
||||
SetSearchOnTypeEnabled(false).
|
||||
SetVimBindingsEnabled(false).
|
||||
SetTopLevel(1).
|
||||
SetCycleSelection(true)
|
||||
|
||||
return &AutocompleteView{treeView}
|
||||
}
|
||||
|
||||
// InputHandler returns the handler for this primitive.
|
||||
func (a *AutocompleteView) InputHandler() tview.InputHandlerFunc {
|
||||
return a.WrapInputHandler(a.DefaultInputHandler)
|
||||
}
|
||||
|
||||
// WrapInputHandler unlike Box.WrapInputHandler calls the default handler
|
||||
// first, as all other shortcuts are meant to be forwarded. However, not
|
||||
// all events are handled by the TreeView, as some features aren't desired.
|
||||
func (a *AutocompleteView) WrapInputHandler(inputHandler tview.InputHandlerFunc) tview.InputHandlerFunc {
|
||||
return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) *tcell.EventKey {
|
||||
switch key := event.Key(); key {
|
||||
//FIXME Maybe this should be made configurable as well
|
||||
case tcell.KeyDown, tcell.KeyUp, tcell.KeyPgDn, tcell.KeyPgUp,
|
||||
tcell.KeyEnter, tcell.KeyHome, tcell.KeyEnd:
|
||||
if inputHandler != nil {
|
||||
event = inputHandler(event, setFocus)
|
||||
}
|
||||
}
|
||||
|
||||
if event != nil {
|
||||
inputCapture := a.GetInputCapture()
|
||||
if inputCapture != nil {
|
||||
event = inputCapture(event)
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
}
|
|
@ -1,13 +1,11 @@
|
|||
package ui
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/Bios-Marcel/cordless/ui/shortcutdialog"
|
||||
"github.com/Bios-Marcel/cordless/ui/tviewutil"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/rivo/uniseg"
|
||||
)
|
||||
|
@ -15,12 +13,12 @@ import (
|
|||
// BottomBar custom simple component to render static information at the bottom
|
||||
// of the application.
|
||||
type BottomBar struct {
|
||||
*sync.Mutex
|
||||
*tview.Box
|
||||
items []*bottomBarItem
|
||||
}
|
||||
|
||||
type bottomBarItem struct {
|
||||
name string
|
||||
content string
|
||||
}
|
||||
|
||||
|
@ -28,11 +26,18 @@ type bottomBarItem struct {
|
|||
// screen's ShowCursor() function but should only do so when they have focus.
|
||||
// (They will need to keep track of this themselves.)
|
||||
func (b *BottomBar) Draw(screen tcell.Screen) bool {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
hasDrawn := b.Box.Draw(screen)
|
||||
if !hasDrawn {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(b.items) == 0 {
|
||||
//True, as we've already drawn.
|
||||
return true
|
||||
}
|
||||
|
||||
style := tcell.StyleDefault.
|
||||
//Background(config.GetTheme().PrimitiveBackgroundColor).
|
||||
Foreground(config.GetTheme().PrimaryTextColor).
|
||||
|
@ -55,30 +60,24 @@ func (b *BottomBar) Draw(screen tcell.Screen) bool {
|
|||
|
||||
//Spacing between items
|
||||
xPos++
|
||||
screen.SetContent(xPos, yPos, ' ', nil, tcell.StyleDefault)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// AddItem adds a new item to the right side of the already existing items.
|
||||
func (b *BottomBar) AddItem(text string) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
b.items = append(b.items, &bottomBarItem{text})
|
||||
}
|
||||
|
||||
// NewBottomBar creates a new bar to be put at the bottom aplication.
|
||||
// It contains static information and hints.
|
||||
func NewBottomBar(username string) *BottomBar {
|
||||
loggedInAsText := fmt.Sprintf("Logged in as: '%s'", tviewutil.Escape(username))
|
||||
shortcutInfoText := fmt.Sprintf("View / Change shortcuts: %s", shortcutdialog.EventToString(shortcutsDialogShortcut))
|
||||
|
||||
func NewBottomBar() *BottomBar {
|
||||
bottomBar := &BottomBar{
|
||||
Box: tview.NewBox(),
|
||||
items: []*bottomBarItem{
|
||||
{
|
||||
name: "username",
|
||||
content: loggedInAsText,
|
||||
},
|
||||
{
|
||||
name: "shortcut-info",
|
||||
content: shortcutInfoText,
|
||||
},
|
||||
},
|
||||
Mutex: &sync.Mutex{},
|
||||
Box: tview.NewBox(),
|
||||
}
|
||||
bottomBar.SetBorder(false)
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestBottomBar(t *testing.T) {
|
||||
simScreen := tcell.NewSimulationScreen("UTF-8")
|
||||
|
||||
simScreen.Init()
|
||||
simScreen.SetSize(10, 10)
|
||||
width, height := simScreen.Size()
|
||||
|
||||
bottomBar := NewBottomBar()
|
||||
bottomBar.SetRect(0, 0, width, 1)
|
||||
bottomBar.Draw(simScreen)
|
||||
|
||||
//If no items were added, we don't expect anything to be drawn.
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
expectCell(' ', x, y, simScreen, t)
|
||||
}
|
||||
}
|
||||
|
||||
bottomBar.AddItem(strings.Repeat("a", width))
|
||||
bottomBar.Draw(simScreen)
|
||||
|
||||
//The first row should be filed with As as we have only one item with
|
||||
//the width of the screen.
|
||||
for x := 0; x < width; x++ {
|
||||
expectCell('a', x, 0, simScreen, t)
|
||||
}
|
||||
|
||||
//We expect everything except for the first row to be empty
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 1; y < height; y++ {
|
||||
expectCell(' ', x, y, simScreen, t)
|
||||
}
|
||||
}
|
||||
|
||||
//Increase size as we add more items
|
||||
simScreen.SetSize(20, 10)
|
||||
width, height = simScreen.Size()
|
||||
bottomBar.SetRect(0, 0, width, 1)
|
||||
|
||||
//Technically we need many more cells for this, which we don't have.
|
||||
//Testing this makes sure we don't crash.
|
||||
bottomBar.AddItem(strings.Repeat("b", 100))
|
||||
bottomBar.Draw(simScreen)
|
||||
|
||||
//Nor do we intend to crash with a zero height.
|
||||
bottomBar.SetRect(0, 0, width, 0)
|
||||
bottomBar.Draw(simScreen)
|
||||
}
|
||||
|
||||
func expectCell(expected rune, column, row int, screen tcell.SimulationScreen, t *testing.T) {
|
||||
cell, _, _, _ := screen.GetContent(column, row)
|
||||
if cell != expected {
|
||||
t.Errorf("Cell missmatch. Was '%c' instead of '%c'.", cell, expected)
|
||||
}
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
package tviewutil
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/Bios-Marcel/cordless/shortcuts"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// PrompSecretSingleLineInput shows a fullscreen input dialog that masks the
|
132
ui/editor.go
132
ui/editor.go
|
@ -5,7 +5,7 @@ import (
|
|||
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
"github.com/Bios-Marcel/cordless/femto"
|
||||
|
@ -27,6 +27,10 @@ type Editor struct {
|
|||
heightRequestHandler func(requestHeight int)
|
||||
requestedHeight int
|
||||
autocompleteFrom *femto.Loc
|
||||
|
||||
// App is the tview Application this editor is used in. The reference is
|
||||
// required to query the current bracketed paste state.
|
||||
App *tview.Application
|
||||
}
|
||||
|
||||
func (editor *Editor) applyBuffer() {
|
||||
|
@ -109,6 +113,7 @@ func (editor *Editor) checkForAutocompletion() {
|
|||
}
|
||||
}
|
||||
|
||||
// MoveCursorLeft moves the cursor left by one cell.
|
||||
func (editor *Editor) MoveCursorLeft() {
|
||||
if editor.buffer.Cursor.HasSelection() {
|
||||
editor.buffer.Cursor.GotoLoc(editor.buffer.Cursor.CurSelection[0])
|
||||
|
@ -120,6 +125,7 @@ func (editor *Editor) MoveCursorLeft() {
|
|||
editor.applyBuffer()
|
||||
}
|
||||
|
||||
// MoveCursorRight moves the cursor right by one cell.
|
||||
func (editor *Editor) MoveCursorRight() {
|
||||
if editor.buffer.Cursor.HasSelection() {
|
||||
editor.buffer.Cursor.GotoLoc(editor.buffer.Cursor.CurSelection[1])
|
||||
|
@ -131,10 +137,14 @@ func (editor *Editor) MoveCursorRight() {
|
|||
editor.applyBuffer()
|
||||
}
|
||||
|
||||
// SelectionToLeft extends the selection one cell to the left.
|
||||
func (editor *Editor) SelectionToLeft() {
|
||||
editor.selectLeft(false)
|
||||
}
|
||||
|
||||
// SelectWordLeft extends the selection one word to the left. A word may
|
||||
// span multiple words. A word however can be one cell and mustn't be a word
|
||||
// in terms of human language definition.
|
||||
func (editor *Editor) SelectWordLeft() {
|
||||
editor.selectLeft(true)
|
||||
}
|
||||
|
@ -169,10 +179,14 @@ func (editor *Editor) selectLeft(word bool) {
|
|||
editor.applyBuffer()
|
||||
}
|
||||
|
||||
// SelectionToRight extends the selection one cell to the right.
|
||||
func (editor *Editor) SelectionToRight() {
|
||||
editor.selectRight(false)
|
||||
}
|
||||
|
||||
// SelectWordRight extends the selection one word to the right. A word may
|
||||
// span multiple words. A word however can be one cell and mustn't be a word
|
||||
// in terms of human language definition.
|
||||
func (editor *Editor) SelectWordRight() {
|
||||
editor.selectRight(true)
|
||||
}
|
||||
|
@ -202,6 +216,8 @@ func (editor *Editor) selectRight(word bool) {
|
|||
editor.applyBuffer()
|
||||
}
|
||||
|
||||
// SelectAll selects all text (cells) currently filled. If no text is
|
||||
// available, nothing will change.
|
||||
func (editor *Editor) SelectAll() {
|
||||
start := editor.buffer.Start()
|
||||
editor.buffer.Cursor.SetSelectionStart(start)
|
||||
|
@ -211,6 +227,9 @@ func (editor *Editor) SelectAll() {
|
|||
editor.applyBuffer()
|
||||
}
|
||||
|
||||
// SelectToStartOfLine will select all text to the left til the next newline
|
||||
// is found. Lines doesn't mean "editor line" in this context, as the editor
|
||||
// doesn't currently support vertical navigation.
|
||||
func (editor *Editor) SelectToStartOfLine() {
|
||||
oldCursor := editor.buffer.Cursor.Loc
|
||||
editor.buffer.Cursor.StartOfText()
|
||||
|
@ -224,6 +243,9 @@ func (editor *Editor) SelectToStartOfLine() {
|
|||
editor.applyBuffer()
|
||||
}
|
||||
|
||||
// SelectToEndOfLine will select all text to the right til the next newline
|
||||
// is found. Lines doesn't mean "editor line" in this context, as the editor
|
||||
// doesn't currently support vertical navigation.
|
||||
func (editor *Editor) SelectToEndOfLine() {
|
||||
oldCursor := editor.buffer.Cursor.Loc
|
||||
editor.buffer.Cursor.End()
|
||||
|
@ -232,6 +254,8 @@ func (editor *Editor) SelectToEndOfLine() {
|
|||
editor.applyBuffer()
|
||||
}
|
||||
|
||||
// SelectToStartOfText will select all text to the start of the editor.
|
||||
// Meaning the top-left most cell.
|
||||
func (editor *Editor) SelectToStartOfText() {
|
||||
oldCursor := editor.buffer.Cursor.Loc
|
||||
textStart := editor.buffer.Start()
|
||||
|
@ -241,6 +265,8 @@ func (editor *Editor) SelectToStartOfText() {
|
|||
editor.applyBuffer()
|
||||
}
|
||||
|
||||
// SelectToEndOfText will select all text to the end of the editor.
|
||||
// Meaning the bottom-right most cell.
|
||||
func (editor *Editor) SelectToEndOfText() {
|
||||
oldCursor := editor.buffer.Cursor.Loc
|
||||
textEnd := editor.buffer.End()
|
||||
|
@ -352,6 +378,17 @@ func (editor *Editor) Paste(event *tcell.EventKey) {
|
|||
}
|
||||
}
|
||||
|
||||
func (editor *Editor) insertCharacterWithoutApply(character rune) {
|
||||
selectionEnd := editor.buffer.Cursor.CurSelection[1]
|
||||
selectionStart := editor.buffer.Cursor.CurSelection[0]
|
||||
if editor.buffer.Cursor.HasSelection() {
|
||||
editor.buffer.Replace(selectionStart, selectionEnd, string(character))
|
||||
} else {
|
||||
editor.buffer.Insert(editor.buffer.Cursor.Loc, string(character))
|
||||
}
|
||||
editor.buffer.Cursor.ResetSelection()
|
||||
}
|
||||
|
||||
func (editor *Editor) InsertCharacter(character rune) {
|
||||
selectionEnd := editor.buffer.Cursor.CurSelection[1]
|
||||
selectionStart := editor.buffer.Cursor.CurSelection[0]
|
||||
|
@ -365,12 +402,13 @@ func (editor *Editor) InsertCharacter(character rune) {
|
|||
}
|
||||
|
||||
// NewEditor instantiates a ready to use text editor.
|
||||
func NewEditor() *Editor {
|
||||
func NewEditor(app *tview.Application) *Editor {
|
||||
editor := Editor{
|
||||
internalTextView: tview.NewTextView(),
|
||||
requestedHeight: 3,
|
||||
buffer: femto.NewBufferFromString("", ""),
|
||||
tempBuffer: femto.NewBufferFromString("", ""),
|
||||
App: app,
|
||||
}
|
||||
|
||||
editor.internalTextView.SetWrap(true)
|
||||
|
@ -384,9 +422,37 @@ func NewEditor() *Editor {
|
|||
editor.buffer.Cursor.SetSelectionStart(editor.buffer.Start())
|
||||
editor.buffer.Cursor.SetSelectionEnd(editor.buffer.End())
|
||||
|
||||
if editor.App != nil {
|
||||
editor.internalTextView.SetOnPaste(func(pastedRunes []rune) {
|
||||
var wasLastCharacterSlashR bool
|
||||
for _, r := range pastedRunes {
|
||||
//Workaround! Sometimes no \n is received, but only \r. Why? Unsure.
|
||||
if r == '\r' {
|
||||
wasLastCharacterSlashR = true
|
||||
editor.insertCharacterWithoutApply('\n')
|
||||
} else {
|
||||
if wasLastCharacterSlashR {
|
||||
wasLastCharacterSlashR = false
|
||||
}
|
||||
if r != '\n' {
|
||||
editor.insertCharacterWithoutApply(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
editor.applyBuffer()
|
||||
editor.TriggerHeightRequestIfNecessary()
|
||||
editor.internalTextView.ScrollToHighlight()
|
||||
})
|
||||
}
|
||||
editor.internalTextView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
// TODO: This entire chunk could be cleaned up by assigning handlers to each event type,
|
||||
// e.g. event.trigger()
|
||||
inputCapture := editor.inputCapture
|
||||
if inputCapture != nil {
|
||||
event = inputCapture(event)
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if shortcuts.MoveCursorLeft.Equals(event) {
|
||||
editor.MoveCursorLeft()
|
||||
} else if shortcuts.ExpandSelectionToLeft.Equals(event) {
|
||||
|
@ -423,9 +489,7 @@ func NewEditor() *Editor {
|
|||
editor.MoveCursorEndOfText()
|
||||
} else if shortcuts.DeleteRight.Equals(event) {
|
||||
editor.DeleteRight()
|
||||
} else if event.Key() == tcell.KeyBackspace2 ||
|
||||
event.Key() == tcell.KeyBackspace {
|
||||
// FIXME Legacy, has to be replaced when there is N-1 Keybind-Mapping.
|
||||
} else if shortcuts.DeleteLeft.Equals(event) {
|
||||
editor.Backspace()
|
||||
} else if shortcuts.DeleteWordLeft.Equals(event) {
|
||||
editor.DeleteWordLeft()
|
||||
|
@ -440,12 +504,11 @@ func NewEditor() *Editor {
|
|||
return nil
|
||||
} else if shortcuts.InputNewLine.Equals(event) {
|
||||
editor.InsertCharacter('\n')
|
||||
} else if shortcuts.SendMessage.Equals(event) && editor.inputCapture != nil {
|
||||
return editor.inputCapture(event)
|
||||
} else if (editor.inputCapture == nil || editor.inputCapture(event) != nil) && event.Rune() != 0 {
|
||||
editor.InsertCharacter(event.Rune())
|
||||
} else {
|
||||
return event
|
||||
mappedRune := mapInputToRune(event)
|
||||
if mappedRune != 0 {
|
||||
editor.InsertCharacter(mappedRune)
|
||||
}
|
||||
}
|
||||
|
||||
editor.TriggerHeightRequestIfNecessary()
|
||||
|
@ -455,6 +518,34 @@ func NewEditor() *Editor {
|
|||
return &editor
|
||||
}
|
||||
|
||||
// mapInputToRune makes sure no invalid characters get inserted into the
|
||||
// editor. All exceptions that aren't specifically declared as
|
||||
// Key type 'tcell.KeyRune' have to be defined here. A prominent example
|
||||
// is the newline character. This was initially added to avoid inserting
|
||||
// the characters produces by Ctrl+Backspace, Alt+Backspace, Ctrl+W and so
|
||||
// on and so forth. These are all control characters which we don't always
|
||||
// react to, since the shortcuts are configurably from within the app.
|
||||
// A return value of 0 is to be treated as 'no rune'.
|
||||
func mapInputToRune(event *tcell.EventKey) rune {
|
||||
//If something is specifically defined as a rune, we won't question whether
|
||||
//it's valid input, as it's generally not deemed a control character.
|
||||
if event.Key() == tcell.KeyRune {
|
||||
return event.Rune()
|
||||
}
|
||||
|
||||
//While '\n' is treated as newline, we'll ignore '\r', as it's useless.
|
||||
if event.Rune() == '\n' {
|
||||
return '\n'
|
||||
}
|
||||
|
||||
//Handles Tab + Ctrl+H
|
||||
if event.Rune() == '\t' {
|
||||
return '\t'
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (editor *Editor) GetTextLeftOfSelection() string {
|
||||
var to femto.Loc
|
||||
if editor.buffer.Cursor.HasSelection() {
|
||||
|
@ -488,9 +579,7 @@ func (editor *Editor) TriggerHeightRequestIfNecessary() {
|
|||
return
|
||||
}
|
||||
|
||||
rowAmount := editor.countRows(editor.GetText())
|
||||
|
||||
newRequestedHeight := rowAmount
|
||||
newRequestedHeight := editor.countRows(editor.GetText())
|
||||
if editor.internalTextView.IsBorderTop() {
|
||||
newRequestedHeight++
|
||||
}
|
||||
|
@ -575,16 +664,9 @@ func (editor *Editor) SetBorderColor(color tcell.Color) {
|
|||
editor.internalTextView.SetBorderColor(color)
|
||||
}
|
||||
|
||||
// SetBorderAttributes delegates to the underlying components SetBorderAttributes
|
||||
// method.
|
||||
func (editor *Editor) SetBorderAttributes(attr tcell.AttrMask) {
|
||||
editor.internalTextView.SetBorderAttributes(attr)
|
||||
}
|
||||
|
||||
// SetBorderFocusAttributes delegates to the underlying components SetBorderFocusAttributes
|
||||
// method.
|
||||
func (editor *Editor) SetBorderFocusAttributes(attr tcell.AttrMask) {
|
||||
editor.internalTextView.SetBorderFocusAttributes(attr)
|
||||
// SetBorderBlinking sets the blinking attribute of the border in tview.
|
||||
func (editor *Editor) SetBorderBlinking(blinking bool) {
|
||||
editor.internalTextView.SetBorderBlinking(blinking)
|
||||
}
|
||||
|
||||
// SetInputCapture sets the alternative input capture that will be used if the
|
||||
|
|
|
@ -6,7 +6,6 @@ import (
|
|||
"github.com/Bios-Marcel/cordless/readstate"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/Bios-Marcel/discordgo"
|
||||
"github.com/gdamore/tcell"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
"github.com/Bios-Marcel/cordless/ui/tviewutil"
|
||||
|
@ -16,7 +15,7 @@ import (
|
|||
// one of them.
|
||||
type GuildList struct {
|
||||
*tview.TreeView
|
||||
onGuildSelect func(node *tview.TreeNode, guildID string)
|
||||
onGuildSelect func(guildID string)
|
||||
}
|
||||
|
||||
// NewGuildList creates and initializes a ready to use GuildList.
|
||||
|
@ -38,7 +37,7 @@ func NewGuildList(guilds []*discordgo.Guild) *GuildList {
|
|||
guildList.SetSelectedFunc(func(node *tview.TreeNode) {
|
||||
guildID, ok := node.GetReference().(string)
|
||||
if ok && guildList.onGuildSelect != nil {
|
||||
guildList.onGuildSelect(node, guildID)
|
||||
guildList.onGuildSelect(guildID)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -52,7 +51,7 @@ func NewGuildList(guilds []*discordgo.Guild) *GuildList {
|
|||
guildNode.SetReference(guild.ID)
|
||||
root.AddChild(guildNode)
|
||||
|
||||
guildList.UpdateNodeState(guildNode, false)
|
||||
guildList.updateNodeState(guild, guildNode, false)
|
||||
|
||||
guildNode.SetSelectable(true)
|
||||
}
|
||||
|
@ -64,51 +63,57 @@ func NewGuildList(guilds []*discordgo.Guild) *GuildList {
|
|||
return guildList
|
||||
}
|
||||
|
||||
// UpdateNodeState updates the state of a node accordingly to its
|
||||
// readstate, unless the node is selected.
|
||||
//
|
||||
// FIXME selected should probably be removed here, but bugs will occur
|
||||
// so I'll do it someday ... :D
|
||||
func (g *GuildList) UpdateNodeState(node *tview.TreeNode, selected bool) {
|
||||
if selected {
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrUnderline)
|
||||
} else {
|
||||
node.SetColor(tview.Styles.ContrastBackgroundColor)
|
||||
}
|
||||
// UpdateNodeStateByGuild updates the state of a guilds node accordingly
|
||||
// to its readstate, unless the guild represented by that node is loaded.
|
||||
func (g *GuildList) UpdateNodeStateByGuild(guild *discordgo.Guild, loaded bool) {
|
||||
matchedNode := tviewutil.GetNodeByReference(guild.ID, g.TreeView)
|
||||
if matchedNode != nil {
|
||||
g.updateNodeState(guild, matchedNode, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GuildList) updateNodeState(guild *discordgo.Guild, node *tview.TreeNode, loaded bool) {
|
||||
if loaded {
|
||||
g.markNodeAsLoaded(node)
|
||||
} else {
|
||||
if !readstate.HasGuildBeenRead(node.GetReference().(string)) {
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrBlink)
|
||||
//Reset to avoid mistakes
|
||||
if tview.IsVtxxx {
|
||||
node.SetBlinking(false)
|
||||
node.SetUnderline(false)
|
||||
}
|
||||
if !readstate.HasGuildBeenRead(guild.ID) {
|
||||
if tview.IsVtxxx {
|
||||
node.SetBlinking(true)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().AttentionColor)
|
||||
}
|
||||
} else {
|
||||
node.SetAttributes(tcell.AttrNone)
|
||||
node.SetColor(tview.Styles.PrimaryTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
//Prefix order doesn't matter for now, as we never have more than one.
|
||||
if readstate.HasGuildBeenMentioned(guild.ID) {
|
||||
node.AddPrefix(mentionedIndicator)
|
||||
} else {
|
||||
node.RemovePrefix(mentionedIndicator)
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnGuildSelect sets the handler for when a guild is selected.
|
||||
func (g *GuildList) SetOnGuildSelect(handler func(node *tview.TreeNode, guildID string)) {
|
||||
func (g *GuildList) SetOnGuildSelect(handler func(guildID string)) {
|
||||
g.onGuildSelect = handler
|
||||
}
|
||||
|
||||
// RemoveGuild removes the node that refers to the given guildID.
|
||||
func (g *GuildList) RemoveGuild(guildID string) {
|
||||
children := g.GetRoot().GetChildren()
|
||||
indexToRemove := -1
|
||||
for index, node := range children {
|
||||
if node.GetReference() == guildID {
|
||||
indexToRemove = index
|
||||
g.GetRoot().SetChildren(append(children[:index], children[index+1:]...))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if indexToRemove != -1 {
|
||||
g.GetRoot().SetChildren(append(children[:indexToRemove], children[indexToRemove+1:]...))
|
||||
}
|
||||
}
|
||||
|
||||
// AddGuild adds a new node that references the given guildID and shows the
|
||||
|
@ -121,11 +126,9 @@ func (g *GuildList) AddGuild(guildID, name string) {
|
|||
|
||||
// UpdateName updates the name of the guild with the given ID.
|
||||
func (g *GuildList) UpdateName(guildID, newName string) {
|
||||
for _, node := range g.GetRoot().GetChildren() {
|
||||
if node.GetReference() == guildID {
|
||||
node.SetText(tviewutil.Escape(newName))
|
||||
break
|
||||
}
|
||||
node := tviewutil.GetNodeByReference(guildID, g.TreeView)
|
||||
if node != nil {
|
||||
node.SetText(tviewutil.Escape(newName))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -137,7 +140,7 @@ func (g *GuildList) setNotificationCount(count int) {
|
|||
}
|
||||
}
|
||||
|
||||
func (g *GuildList) amountOfUnreadGuilds() int {
|
||||
func (g *GuildList) countUnreadGuilds() int {
|
||||
var unreadCount int
|
||||
for _, child := range g.GetRoot().GetChildren() {
|
||||
if !readstate.HasGuildBeenRead((child.GetReference()).(string)) {
|
||||
|
@ -151,5 +154,24 @@ func (g *GuildList) amountOfUnreadGuilds() int {
|
|||
// UpdateUnreadGuildCount finds the number of guilds containing unread
|
||||
// channels and updates the title accordingly.
|
||||
func (g *GuildList) UpdateUnreadGuildCount() {
|
||||
g.setNotificationCount(g.amountOfUnreadGuilds())
|
||||
g.setNotificationCount(g.countUnreadGuilds())
|
||||
}
|
||||
|
||||
// MarkAsLoaded selects the guild and marks it as loaded.
|
||||
func (g *GuildList) MarkAsLoaded(guildID string) {
|
||||
guildNode := tviewutil.GetNodeByReference(guildID, g.TreeView)
|
||||
if guildNode != nil {
|
||||
g.SetCurrentNode(guildNode)
|
||||
g.markNodeAsLoaded(guildNode)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GuildList) markNodeAsLoaded(node *tview.TreeNode) {
|
||||
node.SetBlinking(false)
|
||||
if tview.IsVtxxx {
|
||||
node.SetUnderline(true)
|
||||
} else {
|
||||
node.SetUnderline(false)
|
||||
node.SetColor(tview.Styles.ContrastBackgroundColor)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,11 @@ import (
|
|||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/shortcuts"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/Bios-Marcel/discordgo"
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/ui/tviewutil"
|
||||
"github.com/Bios-Marcel/cordless/util/text"
|
||||
|
@ -44,7 +45,6 @@ type Login struct {
|
|||
loginTypePasswordButton *tview.Button
|
||||
sessionChannel chan *loginAttempt
|
||||
messageText *tview.TextView
|
||||
runNext chan bool
|
||||
|
||||
content *tview.Flex
|
||||
loginChoiceView tview.Primitive
|
||||
|
@ -76,8 +76,10 @@ func NewLogin(app *tview.Application, configDir string) *Login {
|
|||
}
|
||||
|
||||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyCtrlC {
|
||||
if shortcuts.ExitApplication.Equals(event) {
|
||||
app.Stop()
|
||||
//We call exit as we'd otherwise be waiting for a value
|
||||
//from the runNext channel.
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/discordutil"
|
||||
"github.com/Bios-Marcel/cordless/readstate"
|
||||
|
@ -34,7 +34,7 @@ type PrivateChatList struct {
|
|||
chatsNode *tview.TreeNode
|
||||
friendsNode *tview.TreeNode
|
||||
|
||||
onChannelSelect func(node *tview.TreeNode, channelID string)
|
||||
onChannelSelect func(channelID string)
|
||||
onFriendSelect func(userID string)
|
||||
privateChannelStates map[*tview.TreeNode]privateChannelState
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ func (privateList *PrivateChatList) onNodeSelected(node *tview.TreeNode) {
|
|||
if privateList.onChannelSelect != nil {
|
||||
channelID, ok := node.GetReference().(string)
|
||||
if ok {
|
||||
privateList.onChannelSelect(node, channelID)
|
||||
privateList.onChannelSelect(channelID)
|
||||
}
|
||||
}
|
||||
} else if node.GetParent() == privateList.friendsNode {
|
||||
|
@ -131,8 +131,8 @@ func (privateList *PrivateChatList) addChannel(channel *discordgo.Channel) {
|
|||
newNode := createPrivateChannelNode(channel)
|
||||
if !readstate.HasBeenRead(channel, channel.LastMessageID) {
|
||||
privateList.privateChannelStates[newNode] = unread
|
||||
if vtxxx {
|
||||
newNode.SetAttributes(tcell.AttrBlink)
|
||||
if tview.IsVtxxx {
|
||||
newNode.SetBlinking(true)
|
||||
} else {
|
||||
newNode.SetColor(config.GetTheme().AttentionColor)
|
||||
}
|
||||
|
@ -197,7 +197,6 @@ func (privateList *PrivateChatList) RemoveFriend(userID string) {
|
|||
// RemoveChannel removes a channel node if present.
|
||||
func (privateList *PrivateChatList) RemoveChannel(channel *discordgo.Channel) {
|
||||
newChildren := make([]*tview.TreeNode, 0)
|
||||
|
||||
channelID := channel.ID
|
||||
|
||||
for _, node := range privateList.chatsNode.GetChildren() {
|
||||
|
@ -222,19 +221,16 @@ func (privateList *PrivateChatList) RemoveChannel(channel *discordgo.Channel) {
|
|||
privateList.chatsNode.SetChildren(newChildren)
|
||||
}
|
||||
|
||||
// MarkChannelAsUnread marks the channel as unread, coloring it red.
|
||||
func (privateList *PrivateChatList) MarkChannelAsUnread(channel *discordgo.Channel) {
|
||||
for _, node := range privateList.chatsNode.GetChildren() {
|
||||
referenceChannelID, ok := node.GetReference().(string)
|
||||
if ok && referenceChannelID == channel.ID {
|
||||
privateList.privateChannelStates[node] = unread
|
||||
privateList.setNotificationCount(privateList.amountOfUnreadChannels())
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrBlink)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().AttentionColor)
|
||||
}
|
||||
break
|
||||
// MarkAsUnread marks the channel as unread, coloring it red.
|
||||
func (privateList *PrivateChatList) MarkAsUnread(channelID string) {
|
||||
node := tviewutil.GetNodeByReference(channelID, privateList.internalTreeView)
|
||||
if node != nil {
|
||||
privateList.privateChannelStates[node] = unread
|
||||
privateList.setNotificationCount(privateList.amountOfUnreadChannels())
|
||||
if tview.IsVtxxx {
|
||||
node.SetBlinking(true)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().AttentionColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -250,28 +246,24 @@ func (privateList *PrivateChatList) amountOfUnreadChannels() int {
|
|||
return amount
|
||||
}
|
||||
|
||||
// MarkChannelAsRead marks a channel as read if it isn't loaded already
|
||||
func (privateList *PrivateChatList) MarkChannelAsRead(channelID string) {
|
||||
for _, node := range privateList.chatsNode.GetChildren() {
|
||||
referenceChannelID, ok := node.GetReference().(string)
|
||||
if ok && referenceChannelID == channelID {
|
||||
if privateList.privateChannelStates[node] != loaded {
|
||||
privateList.setNotificationCount(privateList.amountOfUnreadChannels())
|
||||
privateList.privateChannelStates[node] = read
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrNone)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().PrimaryTextColor)
|
||||
}
|
||||
}
|
||||
break
|
||||
// MarkAsRead marks a channel as read.
|
||||
func (privateList *PrivateChatList) MarkAsRead(channelID string) {
|
||||
node := tviewutil.GetNodeByReference(channelID, privateList.internalTreeView)
|
||||
if node != nil {
|
||||
privateList.setNotificationCount(privateList.amountOfUnreadChannels())
|
||||
privateList.privateChannelStates[node] = read
|
||||
if tview.IsVtxxx {
|
||||
node.SetBlinking(false)
|
||||
node.SetUnderline(false)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().PrimaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReorderChannelList resorts the list of private chats according to their last
|
||||
// Reorder resorts the list of private chats according to their last
|
||||
// message times.
|
||||
func (privateList *PrivateChatList) ReorderChannelList() {
|
||||
func (privateList *PrivateChatList) Reorder() {
|
||||
children := privateList.chatsNode.GetChildren()
|
||||
sort.Slice(children, func(a, b int) bool {
|
||||
nodeA := children[a]
|
||||
|
@ -299,14 +291,15 @@ func (privateList *PrivateChatList) ReorderChannelList() {
|
|||
})
|
||||
}
|
||||
|
||||
// MarkChannelAsLoaded marks a channel as loaded, coloring it blue. If
|
||||
// MarkAsLoaded marks a channel as loaded, coloring it blue. If
|
||||
// a different channel had loaded before, it's set to read.
|
||||
func (privateList *PrivateChatList) MarkChannelAsLoaded(channel *discordgo.Channel) {
|
||||
func (privateList *PrivateChatList) MarkAsLoaded(channelID string) {
|
||||
for node, state := range privateList.privateChannelStates {
|
||||
if state == loaded {
|
||||
privateList.privateChannelStates[node] = read
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrNone)
|
||||
if tview.IsVtxxx {
|
||||
node.SetBlinking(false)
|
||||
node.SetUnderline(false)
|
||||
} else {
|
||||
node.SetColor(config.GetTheme().PrimaryTextColor)
|
||||
}
|
||||
|
@ -316,10 +309,10 @@ func (privateList *PrivateChatList) MarkChannelAsLoaded(channel *discordgo.Chann
|
|||
|
||||
for _, node := range privateList.chatsNode.GetChildren() {
|
||||
referenceChannelID, ok := node.GetReference().(string)
|
||||
if ok && referenceChannelID == channel.ID {
|
||||
if ok && referenceChannelID == channelID {
|
||||
privateList.privateChannelStates[node] = loaded
|
||||
if vtxxx {
|
||||
node.SetAttributes(tcell.AttrUnderline)
|
||||
if tview.IsVtxxx {
|
||||
node.SetUnderline(true)
|
||||
} else {
|
||||
node.SetColor(tview.Styles.ContrastBackgroundColor)
|
||||
}
|
||||
|
@ -338,7 +331,7 @@ func (privateList *PrivateChatList) SetOnFriendSelect(handler func(userID string
|
|||
|
||||
// SetOnChannelSelect sets the handler that decides what happens when a
|
||||
// channel node gets selected.
|
||||
func (privateList *PrivateChatList) SetOnChannelSelect(handler func(node *tview.TreeNode, channelID string)) {
|
||||
func (privateList *PrivateChatList) SetOnChannelSelect(handler func(channelID string)) {
|
||||
privateList.onChannelSelect = handler
|
||||
}
|
||||
|
||||
|
|
|
@ -2,38 +2,22 @@ package shortcutdialog
|
|||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/shortcuts"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
|
||||
"github.com/Bios-Marcel/cordless/config"
|
||||
"github.com/Bios-Marcel/cordless/ui/tviewutil"
|
||||
"github.com/Bios-Marcel/cordless/ui/components"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func checkVT() bool {
|
||||
VTxxx, err := regexp.MatchString("(vt)[0-9]+", os.Getenv("TERM"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return VTxxx
|
||||
}
|
||||
|
||||
var vtxxx = checkVT()
|
||||
|
||||
func ShowShortcutsDialog(app *tview.Application, onClose func()) {
|
||||
var table *ShortcutTable
|
||||
var shortcutDescription *tview.TextView
|
||||
var shortcutDescription *components.BottomBar
|
||||
var exitButton *tview.Button
|
||||
var resetButton *tview.Button
|
||||
|
||||
table = NewShortcutTable()
|
||||
table.SetShortcuts(shortcuts.Shortcuts)
|
||||
|
||||
table.SetOnClose(onClose)
|
||||
|
||||
exitButton = tview.NewButton("Go back")
|
||||
exitButton.SetSelectedFunc(onClose)
|
||||
exitButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
|
@ -41,11 +25,11 @@ func ShowShortcutsDialog(app *tview.Application, onClose func()) {
|
|||
app.SetFocus(table.GetPrimitive())
|
||||
} else if event.Key() == tcell.KeyBacktab {
|
||||
app.SetFocus(resetButton)
|
||||
} else if event.Key() == tcell.KeyESC {
|
||||
onClose()
|
||||
} else {
|
||||
return event
|
||||
}
|
||||
|
||||
return event
|
||||
return nil
|
||||
})
|
||||
|
||||
resetButton = tview.NewButton("Restore all defaults")
|
||||
|
@ -56,36 +40,27 @@ func ShowShortcutsDialog(app *tview.Application, onClose func()) {
|
|||
shortcuts.Persist()
|
||||
|
||||
table.SetShortcuts(shortcuts.Shortcuts)
|
||||
app.ForceDraw()
|
||||
})
|
||||
resetButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyTab {
|
||||
app.SetFocus(exitButton)
|
||||
} else if event.Key() == tcell.KeyBacktab {
|
||||
app.SetFocus(table.GetPrimitive())
|
||||
} else if event.Key() == tcell.KeyESC {
|
||||
onClose()
|
||||
} else {
|
||||
return event
|
||||
}
|
||||
|
||||
return event
|
||||
return nil
|
||||
})
|
||||
|
||||
primitiveBGColor := tviewutil.ColorToHex(config.GetTheme().PrimitiveBackgroundColor)
|
||||
primaryTextColor := tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor)
|
||||
shortcutDescription = components.NewBottomBar()
|
||||
shortcutDescription.SetBorderPadding(1, 0, 0, 0)
|
||||
|
||||
shortcutDescription.AddItem("R - Reset shortcut")
|
||||
shortcutDescription.AddItem("Backspace - Delete shortcut")
|
||||
shortcutDescription.AddItem("Enter - Change shortcut")
|
||||
shortcutDescription.AddItem("ESC - Close dialog")
|
||||
|
||||
shortcutDescription = tview.NewTextView()
|
||||
shortcutDescription.SetDynamicColors(true)
|
||||
if vtxxx {
|
||||
shortcutDescription.SetText("R [::r]Reset shortcut[::-]" +
|
||||
"[::-] Backspace [::r]Delete shortcut" +
|
||||
"[::-] Enter [::r]Change shortcut" +
|
||||
"[::-] Esc [::r]Close dialog")
|
||||
} else {
|
||||
shortcutDescription.SetText("[" + primaryTextColor + "][:" + primitiveBGColor + "]R [:" + primaryTextColor + "][" + primitiveBGColor + "]Reset shortcut" +
|
||||
"[" + primaryTextColor + "][:" + primitiveBGColor + "] Backspace [:" + primaryTextColor + "][" + primitiveBGColor + "]Delete shortcut" +
|
||||
"[" + primaryTextColor + "][:" + primitiveBGColor + "] Enter [:" + primaryTextColor + "][" + primitiveBGColor + "]Change shortcut" +
|
||||
"[" + primaryTextColor + "][:" + primitiveBGColor + "] Esc [:" + primaryTextColor + "][" + primitiveBGColor + "]Close dialog")
|
||||
}
|
||||
table.SetFocusNext(func() { app.SetFocus(resetButton) })
|
||||
table.SetFocusPrevious(func() { app.SetFocus(exitButton) })
|
||||
|
||||
|
@ -98,10 +73,21 @@ func ShowShortcutsDialog(app *tview.Application, onClose func()) {
|
|||
|
||||
shortcutsView := tview.NewFlex()
|
||||
shortcutsView.SetDirection(tview.FlexRow)
|
||||
shortcutsView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if table.IsDefiningShortcut() {
|
||||
return event
|
||||
}
|
||||
|
||||
if event.Key() == tcell.KeyESC {
|
||||
onClose()
|
||||
return nil
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
shortcutsView.AddItem(table.GetPrimitive(), 0, 1, false)
|
||||
shortcutsView.AddItem(buttonBar, 1, 0, false)
|
||||
shortcutsView.AddItem(shortcutDescription, 1, 0, false)
|
||||
shortcutsView.AddItem(shortcutDescription, 2, 0, false)
|
||||
|
||||
app.SetRoot(shortcutsView, true)
|
||||
app.SetFocus(table.GetPrimitive())
|
||||
|
@ -113,6 +99,14 @@ func RunShortcutsDialogStandalone() {
|
|||
log.Fatalf("Error loading shortcuts: %s\n", loadError)
|
||||
}
|
||||
app := tview.NewApplication()
|
||||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if shortcuts.ExitApplication.Equals(event) {
|
||||
app.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
ShowShortcutsDialog(app, func() {
|
||||
app.Stop()
|
||||
})
|
||||
|
|
|
@ -6,7 +6,7 @@ import (
|
|||
|
||||
"github.com/Bios-Marcel/cordless/shortcuts"
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -20,7 +20,6 @@ const (
|
|||
type ShortcutTable struct {
|
||||
table *tview.Table
|
||||
shortcuts []*shortcuts.Shortcut
|
||||
onClose func()
|
||||
selection int
|
||||
focusNext func()
|
||||
focusPrevious func()
|
||||
|
@ -36,9 +35,6 @@ func NewShortcutTable() *ShortcutTable {
|
|||
|
||||
table.SetSelectable(true, false)
|
||||
table.SetBorder(true)
|
||||
if vtxxx {
|
||||
table.SetSelectedStyle(tcell.ColorBlack, tcell.ColorWhite, tcell.AttrReverse)
|
||||
}
|
||||
|
||||
//Header + emptyrow
|
||||
table.SetFixed(2, 3)
|
||||
|
@ -116,13 +112,6 @@ func (shortcutTable *ShortcutTable) SetFocusPrevious(function func()) {
|
|||
|
||||
func (shortcutTable *ShortcutTable) handleInput(event *tcell.EventKey) *tcell.EventKey {
|
||||
if shortcutTable.selection == -1 {
|
||||
if event.Key() == tcell.KeyESC {
|
||||
if shortcutTable.onClose != nil {
|
||||
shortcutTable.onClose()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if event.Key() == tcell.KeyTab {
|
||||
if shortcutTable.focusNext != nil {
|
||||
shortcutTable.focusNext()
|
||||
|
@ -132,7 +121,6 @@ func (shortcutTable *ShortcutTable) handleInput(event *tcell.EventKey) *tcell.Ev
|
|||
shortcutTable.focusPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
if event.Key() == tcell.KeyUp || event.Key() == tcell.KeyDown {
|
||||
return event
|
||||
}
|
||||
|
@ -185,12 +173,6 @@ func (shortcutTable *ShortcutTable) handleInput(event *tcell.EventKey) *tcell.Ev
|
|||
return nil
|
||||
}
|
||||
|
||||
// SetOnClose sets the handler that will be run when someone attempts closing
|
||||
// the shortcuts table.
|
||||
func (shortcutTable *ShortcutTable) SetOnClose(onClose func()) {
|
||||
shortcutTable.onClose = onClose
|
||||
}
|
||||
|
||||
// EventToString renders a tcell.EventKey as a human readable string
|
||||
func EventToString(event *tcell.EventKey) string {
|
||||
if event == nil {
|
||||
|
@ -236,3 +218,9 @@ func EventToString(event *tcell.EventKey) string {
|
|||
|
||||
return tview.Escape(s)
|
||||
}
|
||||
|
||||
// IsDefiningShortcut indicates whether the user is currently selecting a
|
||||
// shortcut for any function.
|
||||
func (shortcutTable *ShortcutTable) IsDefiningShortcut() bool {
|
||||
return shortcutTable.selection != -1
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ package tviewutil
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -3,7 +3,7 @@ package tviewutil
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestColorToHex(t *testing.T) {
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
package tviewutil
|
||||
|
||||
import "github.com/Bios-Marcel/cordless/tview"
|
||||
|
||||
func FocusNextIfPossible(direction tview.FocusDirection, app *tview.Application, focused tview.Primitive) {
|
||||
if focused == nil {
|
||||
return
|
||||
}
|
||||
|
||||
focusNext := focused.NextFocusableComponent(direction)
|
||||
if focusNext != nil {
|
||||
app.SetFocus(focusNext)
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ package tviewutil
|
|||
|
||||
import (
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func CreateFocusTextViewOnTypeInputHandler(app *tview.Application, component *tview.TextView) func(event *tcell.EventKey) *tcell.EventKey {
|
||||
|
@ -27,3 +27,20 @@ func CreateFocusTextViewOnTypeInputHandler(app *tview.Application, component *tv
|
|||
|
||||
return eventHandler
|
||||
}
|
||||
|
||||
// GetNodeByReference returns the first matched node where the given reference
|
||||
// is equal. If no node with a matching reference exists, the return value
|
||||
// is nil.
|
||||
func GetNodeByReference(reference interface{}, tree *tview.TreeView) *tview.TreeNode {
|
||||
var matchedNode *tview.TreeNode
|
||||
tree.GetRoot().Walk(func(node, parent *tview.TreeNode) bool {
|
||||
if node.GetReference() == reference {
|
||||
matchedNode = node
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return matchedNode
|
||||
}
|
||||
|
|
145
ui/usertree.go
145
ui/usertree.go
|
@ -10,13 +10,14 @@ import (
|
|||
|
||||
"github.com/Bios-Marcel/cordless/tview"
|
||||
"github.com/Bios-Marcel/discordgo"
|
||||
"github.com/gdamore/tcell"
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// UserTree represents the visual list of users in a guild.
|
||||
type UserTree struct {
|
||||
*sync.Mutex
|
||||
|
||||
internalTreeView *tview.TreeView
|
||||
rootNode *tview.TreeNode
|
||||
|
||||
state *discordgo.State
|
||||
|
||||
|
@ -24,43 +25,44 @@ type UserTree struct {
|
|||
roleNodes map[string]*tview.TreeNode
|
||||
roles []*discordgo.Role
|
||||
|
||||
lock *sync.Mutex
|
||||
loaded bool
|
||||
loadedFor interface{}
|
||||
}
|
||||
|
||||
// NewUserTree creates a new pre-configured UserTree that is empty.
|
||||
func NewUserTree(state *discordgo.State) *UserTree {
|
||||
userTree := &UserTree{
|
||||
state: state,
|
||||
rootNode: tview.NewTreeNode(""),
|
||||
internalTreeView: tview.NewTreeView(),
|
||||
loaded: false,
|
||||
lock: &sync.Mutex{},
|
||||
Mutex: &sync.Mutex{},
|
||||
}
|
||||
|
||||
userTree.internalTreeView.
|
||||
SetVimBindingsEnabled(config.Current.OnTypeInListBehaviour == config.DoNothingOnTypeInList).
|
||||
SetRoot(userTree.rootNode).
|
||||
SetRoot(tview.NewTreeNode("")).
|
||||
SetTopLevel(1).
|
||||
SetCycleSelection(true)
|
||||
userTree.internalTreeView.SetBorder(true)
|
||||
SetCycleSelection(true).
|
||||
SetBorder(true)
|
||||
|
||||
return userTree
|
||||
}
|
||||
|
||||
// Clear removes all nodes and data out of the view.
|
||||
func (userTree *UserTree) Clear() {
|
||||
userTree.lock.Lock()
|
||||
defer userTree.lock.Unlock()
|
||||
userTree.Lock()
|
||||
defer userTree.Unlock()
|
||||
userTree.clear()
|
||||
}
|
||||
|
||||
func (userTree *UserTree) rootNode() *tview.TreeNode {
|
||||
return userTree.internalTreeView.GetRoot()
|
||||
}
|
||||
|
||||
func (userTree *UserTree) clear() {
|
||||
for _, roleNode := range userTree.roleNodes {
|
||||
roleNode.ClearChildren()
|
||||
}
|
||||
userTree.rootNode.ClearChildren()
|
||||
userTree.loaded = false
|
||||
userTree.rootNode().ClearChildren()
|
||||
userTree.loadedFor = nil
|
||||
|
||||
// After clearing, we don't reallocate anything, since we don't know
|
||||
// whether we actually want to repopulate the tree.
|
||||
|
@ -71,21 +73,26 @@ func (userTree *UserTree) clear() {
|
|||
|
||||
// LoadGroup loads all users for a group-channel.
|
||||
func (userTree *UserTree) LoadGroup(channelID string) error {
|
||||
userTree.lock.Lock()
|
||||
defer userTree.lock.Unlock()
|
||||
userTree.clear()
|
||||
|
||||
userTree.userNodes = make(map[string]*tview.TreeNode)
|
||||
userTree.roleNodes = make(map[string]*tview.TreeNode)
|
||||
userTree.Lock()
|
||||
defer userTree.Unlock()
|
||||
|
||||
channel, stateError := userTree.state.PrivateChannel(channelID)
|
||||
if stateError != nil {
|
||||
return stateError
|
||||
}
|
||||
|
||||
//Already loaded
|
||||
if channel == userTree.loadedFor {
|
||||
return nil
|
||||
}
|
||||
|
||||
userTree.clear()
|
||||
userTree.userNodes = make(map[string]*tview.TreeNode)
|
||||
userTree.roleNodes = make(map[string]*tview.TreeNode)
|
||||
|
||||
userTree.addOrUpdateUsers(channel.Recipients)
|
||||
|
||||
userTree.loaded = true
|
||||
userTree.loadedFor = channel
|
||||
userTree.selectFirstNode()
|
||||
|
||||
return nil
|
||||
|
@ -94,25 +101,35 @@ func (userTree *UserTree) LoadGroup(channelID string) error {
|
|||
// LoadGuild will load all available roles of the guild and then load all
|
||||
// available members. Afterwards the first available node will be selected.
|
||||
func (userTree *UserTree) LoadGuild(guildID string) error {
|
||||
userTree.lock.Lock()
|
||||
defer userTree.lock.Unlock()
|
||||
userTree.clear()
|
||||
userTree.Lock()
|
||||
defer userTree.Unlock()
|
||||
|
||||
guild, stateError := userTree.state.Guild(guildID)
|
||||
if stateError != nil {
|
||||
return stateError
|
||||
}
|
||||
|
||||
//Already loaded
|
||||
if guild == userTree.loadedFor {
|
||||
return nil
|
||||
}
|
||||
|
||||
userTree.clear()
|
||||
userTree.userNodes = make(map[string]*tview.TreeNode)
|
||||
userTree.roleNodes = make(map[string]*tview.TreeNode)
|
||||
|
||||
guildRoles, roleLoadError := userTree.loadGuildRoles(guildID)
|
||||
guildRoles, roleLoadError := userTree.loadGuildRoles(guild)
|
||||
if roleLoadError != nil {
|
||||
return roleLoadError
|
||||
}
|
||||
userTree.roles = guildRoles
|
||||
|
||||
userLoadError := userTree.loadGuildMembers(guildID)
|
||||
userLoadError := userTree.loadGuildMembers(guild)
|
||||
if userLoadError != nil {
|
||||
return userLoadError
|
||||
}
|
||||
|
||||
userTree.loaded = true
|
||||
userTree.loadedFor = guild
|
||||
userTree.selectFirstNode()
|
||||
|
||||
return nil
|
||||
|
@ -120,15 +137,15 @@ func (userTree *UserTree) LoadGuild(guildID string) error {
|
|||
|
||||
func (userTree *UserTree) selectFirstNode() {
|
||||
if userTree.internalTreeView.GetCurrentNode() == nil {
|
||||
userNodes := userTree.rootNode.GetChildren()
|
||||
userNodes := userTree.rootNode().GetChildren()
|
||||
if len(userNodes) > 0 {
|
||||
userTree.internalTreeView.SetCurrentNode(userTree.rootNode.GetChildren()[0])
|
||||
userTree.internalTreeView.SetCurrentNode(userTree.rootNode().GetChildren()[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (userTree *UserTree) loadGuildMembers(guildID string) error {
|
||||
members, stateError := userTree.state.Members(guildID)
|
||||
func (userTree *UserTree) loadGuildMembers(guild *discordgo.Guild) error {
|
||||
members, stateError := userTree.state.Members(guild.ID)
|
||||
if stateError != nil {
|
||||
return stateError
|
||||
}
|
||||
|
@ -138,12 +155,7 @@ func (userTree *UserTree) loadGuildMembers(guildID string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (userTree *UserTree) loadGuildRoles(guildID string) ([]*discordgo.Role, error) {
|
||||
guild, stateError := userTree.state.Guild(guildID)
|
||||
if stateError != nil {
|
||||
return nil, stateError
|
||||
}
|
||||
|
||||
func (userTree *UserTree) loadGuildRoles(guild *discordgo.Guild) ([]*discordgo.Role, error) {
|
||||
guildRoles := guild.Roles
|
||||
|
||||
sort.Slice(guildRoles, func(a, b int) bool {
|
||||
|
@ -152,11 +164,17 @@ func (userTree *UserTree) loadGuildRoles(guildID string) ([]*discordgo.Role, err
|
|||
|
||||
for _, role := range guildRoles {
|
||||
if role.Hoist {
|
||||
roleNode := tview.NewTreeNode("[" + discordutil.GetRoleColor(role) +
|
||||
"]" + tviewutil.Escape(role.Name))
|
||||
roleColor := discordutil.GetRoleColor(role)
|
||||
roleName := tviewutil.Escape(role.Name)
|
||||
var roleNode *tview.TreeNode
|
||||
if roleColor != "" {
|
||||
roleNode = tview.NewTreeNode("[" + roleColor + "]" + roleName)
|
||||
} else {
|
||||
roleNode = tview.NewTreeNode(roleName)
|
||||
}
|
||||
roleNode.SetSelectable(false)
|
||||
userTree.roleNodes[role.ID] = roleNode
|
||||
userTree.rootNode.AddChild(roleNode)
|
||||
userTree.rootNode().AddChild(roleNode)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -166,9 +184,9 @@ func (userTree *UserTree) loadGuildRoles(guildID string) ([]*discordgo.Role, err
|
|||
// AddOrUpdateMember adds the passed member to the tree, unless it is
|
||||
// already part of the tree, in that case the nodes name is updated.
|
||||
func (userTree *UserTree) AddOrUpdateMember(member *discordgo.Member) {
|
||||
userTree.lock.Lock()
|
||||
defer userTree.lock.Unlock()
|
||||
if !userTree.loaded {
|
||||
userTree.Lock()
|
||||
defer userTree.Unlock()
|
||||
if !userTree.isLoaded() {
|
||||
return
|
||||
}
|
||||
userTree.addOrUpdateMember(member)
|
||||
|
@ -197,15 +215,15 @@ func (userTree *UserTree) addOrUpdateMember(member *discordgo.Member) {
|
|||
}
|
||||
}
|
||||
|
||||
userTree.rootNode.AddChild(userNode)
|
||||
userTree.rootNode().AddChild(userNode)
|
||||
}
|
||||
|
||||
// AddOrUpdateUser adds a user to the tree, unless the user already exists,
|
||||
// in that case the users node gets updated.
|
||||
func (userTree *UserTree) AddOrUpdateUser(user *discordgo.User) {
|
||||
userTree.lock.Lock()
|
||||
defer userTree.lock.Unlock()
|
||||
if !userTree.loaded {
|
||||
userTree.Lock()
|
||||
defer userTree.Unlock()
|
||||
if !userTree.isLoaded() {
|
||||
return
|
||||
}
|
||||
userTree.addOrUpdateUser(user)
|
||||
|
@ -223,15 +241,15 @@ func (userTree *UserTree) addOrUpdateUser(user *discordgo.User) {
|
|||
|
||||
userNode = tview.NewTreeNode(nameToUse)
|
||||
userTree.userNodes[user.ID] = userNode
|
||||
userTree.rootNode.AddChild(userNode)
|
||||
userTree.rootNode().AddChild(userNode)
|
||||
}
|
||||
|
||||
// AddOrUpdateUsers adds users to the tree, unless they already exists, in that
|
||||
// case the users nodes gets updated.
|
||||
func (userTree *UserTree) AddOrUpdateUsers(users []*discordgo.User) {
|
||||
userTree.lock.Lock()
|
||||
defer userTree.lock.Unlock()
|
||||
if !userTree.loaded {
|
||||
userTree.Lock()
|
||||
defer userTree.Unlock()
|
||||
if !userTree.isLoaded() {
|
||||
return
|
||||
}
|
||||
userTree.addOrUpdateUsers(users)
|
||||
|
@ -246,9 +264,9 @@ func (userTree *UserTree) addOrUpdateUsers(users []*discordgo.User) {
|
|||
// AddOrUpdateMembers adds the all passed members to the tree, unless a node is
|
||||
// already part of the tree, in that case the nodes name is updated.
|
||||
func (userTree *UserTree) AddOrUpdateMembers(members []*discordgo.Member) {
|
||||
userTree.lock.Lock()
|
||||
defer userTree.lock.Unlock()
|
||||
if !userTree.loaded {
|
||||
userTree.Lock()
|
||||
defer userTree.Unlock()
|
||||
if !userTree.isLoaded() {
|
||||
return
|
||||
}
|
||||
userTree.addOrUpdateMembers(members)
|
||||
|
@ -262,9 +280,9 @@ func (userTree *UserTree) addOrUpdateMembers(members []*discordgo.Member) {
|
|||
|
||||
// RemoveMember finds and removes a node from the tree.
|
||||
func (userTree *UserTree) RemoveMember(member *discordgo.Member) {
|
||||
userTree.lock.Lock()
|
||||
defer userTree.lock.Unlock()
|
||||
if !userTree.loaded {
|
||||
userTree.Lock()
|
||||
defer userTree.Unlock()
|
||||
if !userTree.isLoaded() {
|
||||
return
|
||||
}
|
||||
userTree.removeMember(member)
|
||||
|
@ -273,7 +291,7 @@ func (userTree *UserTree) RemoveMember(member *discordgo.Member) {
|
|||
func (userTree *UserTree) removeMember(member *discordgo.Member) {
|
||||
userNode, contains := userTree.userNodes[member.User.ID]
|
||||
if contains {
|
||||
userTree.rootNode.Walk(func(node, parent *tview.TreeNode) bool {
|
||||
userTree.rootNode().Walk(func(node, parent *tview.TreeNode) bool {
|
||||
if node == userNode {
|
||||
if len(parent.GetChildren()) == 1 {
|
||||
parent.SetChildren(make([]*tview.TreeNode, 0))
|
||||
|
@ -306,9 +324,9 @@ func (userTree *UserTree) removeMember(member *discordgo.Member) {
|
|||
|
||||
// RemoveMembers finds and removes all passed members from the tree.
|
||||
func (userTree *UserTree) RemoveMembers(members []*discordgo.Member) {
|
||||
userTree.lock.Lock()
|
||||
defer userTree.lock.Unlock()
|
||||
if !userTree.loaded {
|
||||
userTree.Lock()
|
||||
defer userTree.Unlock()
|
||||
if !userTree.isLoaded() {
|
||||
return
|
||||
}
|
||||
for _, member := range members {
|
||||
|
@ -321,6 +339,7 @@ func (userTree *UserTree) SetInputCapture(capture func(event *tcell.EventKey) *t
|
|||
userTree.internalTreeView.SetInputCapture(capture)
|
||||
}
|
||||
|
||||
func (userTree *UserTree) IsLoaded() bool {
|
||||
return userTree.loaded
|
||||
// isLoaded indicates whether the UserList is currently displaying any data.
|
||||
func (userTree *UserTree) isLoaded() bool {
|
||||
return userTree.loadedFor != nil
|
||||
}
|
||||
|
|
1425
ui/window.go
1425
ui/window.go
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,65 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AddToZip writes the given data to a zip archive using the passed writer.
|
||||
// Folders are added to the zip recursively. The folder structure is fully
|
||||
// preserved.
|
||||
func AddToZip(zipWriter *zip.Writer, filename string) error {
|
||||
info, statError := os.Stat(filename)
|
||||
if os.IsNotExist(statError) || statError != nil {
|
||||
return statError
|
||||
}
|
||||
|
||||
var baseDir string
|
||||
if info.IsDir() {
|
||||
baseDir = filepath.Base(filename)
|
||||
}
|
||||
|
||||
filepath.Walk(filename, func(path string, info os.FileInfo, readError error) error {
|
||||
if readError != nil {
|
||||
return readError
|
||||
}
|
||||
|
||||
header, readError := zip.FileInfoHeader(info)
|
||||
if readError != nil {
|
||||
return readError
|
||||
}
|
||||
|
||||
if baseDir != "" {
|
||||
header.Name = filepath.Join(baseDir, strings.TrimPrefix(path, filename))
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
header.Name += "/"
|
||||
} else {
|
||||
header.Method = zip.Deflate
|
||||
}
|
||||
|
||||
writer, headerError := zipWriter.CreateHeader(header)
|
||||
if headerError != nil {
|
||||
return headerError
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, readError := os.Open(path)
|
||||
if readError != nil {
|
||||
return readError
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
_, readError = io.Copy(writer, file)
|
||||
return readError
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue