Compare commits

...

134 Commits

Author SHA1 Message Date
Marcel Schramm 17efaa7880 Remove CI badges 2021-12-16 12:27:25 +01:00
Marcel Schramm a517dc2cb3 Update readme 2021-12-16 12:25:54 +01:00
Marcel Schramm 4e8f514a24 Remove CI files 2021-12-16 12:22:42 +01:00
Marcel Schramm 8bdb018434 Vendor dependencies and run go mod tidy 2021-12-16 12:20:39 +01:00
Marcel Schramm 54865be813
Improve readme 2020-11-24 21:54:24 +01:00
iAmir a4129c885e fix a typo in readme 2020-11-23 20:54:29 +01:00
Marcel Schramm 499d5a23c7
Add notice about closing the project
Additionally installation instructions have been removed.
2020-11-22 22:29:57 +01:00
Marcel Schramm 42a4104c45
Reaction rendering
Reactions can now be rendered inline. This can be toggled via the
`ShowReactionsInline` configuration field.
2020-11-22 19:16:53 +01:00
Marcel Schramm 041a8423a7
If a user leaves a group, it won't display the it as someone else being removed anymore 2020-11-21 21:36:48 +01:00
Marcel Schramm 66883c6d61
The 'logged in as' bottom bar entry now indicates whether current account is a bot account 2020-11-21 15:42:51 +01:00
Marcel Schramm feca3d8de3
Fix layout demo 2020-11-19 20:17:44 +01:00
Marcel Schramm 61d36aebba
move autocomplete into separate file and improve event forwarding 2020-11-19 20:17:31 +01:00
Marcel Schramm 4c83b1448c
Add missing docs for Get/SetParent 2020-11-18 23:33:50 +01:00
Marcel Schramm e886913ccc
Editor now supports bracketed paste
* Solves problem of pasting content with newlines and accidentally
sending everything
* Improves pasting speed imensly
* Allows paste without external utility (xclip and so on)
2020-11-18 23:15:41 +01:00
Marcel Schramm f6eca3f302
tview package now supports bracketed paste. 2020-11-18 23:15:23 +01:00
Marcel Schramm cf17e677f3
Rune input of editor now ignores all control characters except for \t and \n 2020-11-18 20:27:07 +01:00
Marcel Schramm d45ceb32ab
Alt+Backspace and Ctrl+Backspace shouldn't insert characters anymore when they aren't assigned as shortcuts 2020-11-18 19:57:07 +01:00
Marcel Schramm e839f4bc23
Make sure app veyor builds use go modules 2020-11-17 17:46:50 +01:00
Marcel Schramm 5fe1add9b9
Order of delete shortcuts in shortcut dialog was messed up. 2020-11-16 18:04:03 +01:00
Marcel Schramm 02eafc8e19
Adresses #367 2020-11-16 18:01:27 +01:00
Marcel Schramm 2dd57fb965
Messags are now prepared before checking for file:// prefixes in order to allow scripts to expand things into local filepaths 2020-11-13 19:46:01 +01:00
Marcel Schramm 0c823dc695
Roles with default colors don't have a pair of square brackets in front of them anymore 2020-11-09 21:52:33 +01:00
Marcel Schramm a46f58d610
Quoting now escapes @here and @everyone to avoid pissing everyone off 2020-11-09 19:18:14 +01:00
Marcel Schramm bac98222a4
private chats were incorrectly underlined sometimes 2020-10-27 20:25:38 +01:00
Marcel Schramm 4147f60b0c
Fix #361 2020-10-26 19:38:58 +01:00
Marcel Schramm 59627d95d5
Edited message now also resolve emojis and such 2020-10-26 18:01:42 +01:00
Marcel Schramm 8cb0f71c97
Downgrade chroma, due to test failure with v8+ 2020-10-26 17:58:43 +01:00
Marcel Schramm 9e0e2be9e8
Update dependencies 2020-10-25 20:53:31 +01:00
Marcel Schramm 405a0301f3
Shortcuts dialog now reacts to ExitApplicationShortcut 2020-10-25 15:42:44 +01:00
Marcel Schramm 7ed2c8e6ba
Improve application startup and restart. Fixes #268 2020-10-25 15:37:24 +01:00
Marcel Schramm 57c05c571a
Add some debugging util 2020-10-25 12:38:24 +01:00
Marcel Schramm c2de2873ff
Refactor version checking slightly 2020-10-25 12:22:52 +01:00
Marcel Schramm 84e549dca0
Prepare changelog for next version 2020-10-25 11:39:58 +01:00
Marcel Schramm ecdbc5b9f2
Add doc to version number 2020-10-25 11:38:53 +01:00
Marcel Schramm e6a4741398
Release reminder now says that snap isn't supported anymore 2020-10-25 11:37:15 +01:00
Marcel Schramm 5b7cd99899
Improve docs of release scripts 2020-10-25 10:28:47 +01:00
Marcel Schramm a69272ec76
Next time, the release script will push both the tags and the commits 2020-10-25 10:25:25 +01:00
Marcel Schramm c8868f0d20
Bump cordless versionnumber to version 2020-10-24 2020-10-24 19:21:41 +02:00
Marcel Schramm f721c4aeb2
Release script doesn't attempt pushing scoop package file anymore 2020-10-24 19:14:50 +02:00
Marcel Schramm 7a0b89af31
Add scoop json to git ignore 2020-10-24 19:11:28 +02:00
Marcel Schramm acb148b1e3
remove scoop json from repository 2020-10-24 19:10:44 +02:00
Marcel Schramm a4c7af0c04
Selected node in treeview now preserves color on Non-Vtxxx terminals 2020-10-24 18:55:28 +02:00
Marcel Schramm e05ac946ca
Add missing test util method to bottombar 2020-10-24 14:06:13 +02:00
Marcel Schramm 8e361fd31c
Remove potentially unnecessary QueueUpdateDraw 2020-10-24 13:56:18 +02:00
Marcel Schramm a512146cc6
Selection in treeviews was displayed incorrectly. It now uses Reverse only 2020-10-24 13:50:18 +02:00
Marcel Schramm dbe352c168
Userlist wasn't focusable in DMs 2020-10-24 13:48:21 +02:00
Marcel Schramm 7502b76f55
Remove useless Window#ForceRedraw calls 2020-10-24 13:48:02 +02:00
Marcel Schramm 52ee681981
Update to tcell v2.0.0 (#358)
Migrate tcell to 2.0.0

* tview.TreeView#SetSelectedStyle removed
* tview.Primitives don't allow directly setting attributes for now (might change again with tview 2.+)
2020-10-24 13:22:48 +02:00
Marcel Schramm 62946ab52f
Attempt 2 at enabling UTF8 by default on new WT 2020-10-21 20:15:22 +02:00
Marcel Schramm f05eccf270
UTF8 should now be enabled by default in the new windows terminal 2020-10-21 20:06:42 +02:00
Marcel Schramm 743b358917
Channels weren't correctly marked as read when other clients read a message and cordless crashed when no channel was selected 2020-10-19 18:11:36 +02:00
Marcel Schramm 0ff0f3eb00
Fixes #246 2020-10-18 17:16:15 +02:00
Marcel Schramm 315eda9869
Improve user tree usage
There were scenarios where the user tree would be shown in a bad
situation, or be shown / not shown incorrectly. It would also be shown
on startup, even though no guild or groupDM was loaded.
2020-10-18 16:57:52 +02:00
Marcel Schramm 78e4d6c9a3
Remove unnecessary forcedraw from RefreshLayout 2020-10-18 16:20:05 +02:00
Marcel Schramm 7c46594181
Fix bug, where announcement channels wouldn't have a chatheader 2020-10-18 16:11:44 +02:00
Marcel Schramm 601c232a8b
Update changelog 2020-10-18 15:55:05 +02:00
Marcel Schramm 835aaa6aa3
Move code used for sending a file from a file URI into discordutil 2020-10-18 15:53:36 +02:00
Marcel Schramm 49ef544bcd
Add tab/backtab to dialogs shown by window.go 2020-10-18 15:53:15 +02:00
Marcel Schramm 5a22b33744
Improve docs in discordutil.GetPrivateChannelName and split method into tview specific and unspecific one 2020-10-18 15:52:44 +02:00
Marcel Schramm d9d8cffca8
Remove debug code to show a dialog from master 2020-10-18 15:19:00 +02:00
Marcel Schramm 113fb9e77e
command output in baremode doesn't have left and right borders anymore 2020-10-18 14:34:55 +02:00
Marcel Schramm 767d19a5f8
Readstate now treats temporarily muted correctly
This solves the problem that a temporarily muted channel or guild will
always be seen as muted and cause no notifications to be sent.
2020-10-18 14:26:34 +02:00
Marcel Schramm 83c0ee7a0b
Buttons now return event nil when the default events are handled
This solves the need for calling ForceDraw in SetSelectedHandler of
button
2020-10-17 20:38:25 +02:00
Marcel Schramm 6bef24a182
Update gitignore 2020-10-17 11:37:56 +02:00
Marcel Schramm 562a7aeda4
Improve editor event handling
The editor now always handles the externally set input handlers first.
This was changed due to incorrect values being returned from the input
handler, which in turn caused the upcoming draw to be skipped. While
this didn't cause any critical problems, it made the UI feel laggy at
times. Technically certain editor shortcuts could now be overwritten
from outside, but if the user choses to do so, we shall not care.
2020-10-16 23:56:10 +02:00
Marcel Schramm 88629aa4ae
Mention indicator now properly removed on guilds 2020-10-16 21:24:37 +02:00
Marcel Schramm 7be90c4ae7
Fixes several focus issues
* When a dialog is open, focus will now be held on the dialog
* Tab / Backtab works again in the shortcuts dialog
* The Chatview now has a separate set of "global" shortcuts. For example
you can't accidentally activate the baremode inside of the shortcut view
anymore.
2020-10-16 19:24:58 +02:00
Marcel Schramm 1485a8849d
Focus and key handling changed
* Directional focus changing doesn't happen in tivew anymore, instead
the application can handle it itself. This gives the application more
power over the focus and the user input handling.
* Key events now trickle down from parents to children, this allows for
defining a set of events for a whole view without having to set it in
the tview.Application instance or having to register multiple handlers
for one event.
2020-10-16 19:22:18 +02:00
Marcel Schramm 1bd0717ad9
Chatview is now a bit more generous with memory to avoid multiple allocations 2020-10-16 19:21:52 +02:00
Marcel Schramm 852b98cb8e
Update changelog 2020-10-16 16:19:03 +02:00
Marcel Schramm 1ac2aef556
IsCursorInsideBlock is now a bit less wasteful 2020-10-16 16:08:58 +02:00
Marcel Schramm 2a3d78301e
Guild leaving / deleting didn't clean up properly
Too many wrong IFs that prevented anything from happening at all and the
chatview wasn't properly cleaned up with the corresponding function.
2020-10-16 15:48:47 +02:00
Marcel Schramm 6eab4ff504
Improve some inline-docs and replace some loops with tviewutil.GetNodeByReference 2020-10-16 15:24:09 +02:00
Marcel Schramm 4310b8ec7a
Make ChannelTree methods only available for ChannelTree struct 2020-10-16 15:08:25 +02:00
Marcel Schramm f1d56c0f9e
Channel names weren't escaped 2020-10-16 15:01:07 +02:00
Marcel Schramm 8cfac956f9
Mention indicator wasn't removed when loading other channel 2020-10-16 11:38:08 +02:00
Marcel Schramm a4980f0d49
Fixed inability to jump to mentioned channel by name
* mutex instances have been removed from components, instead the
components are mutexes themselves now
* vtxxx checks have been replaced with tview calls
* duplicated code has been removed
2020-10-16 11:14:30 +02:00
Marcel Schramm 265c001ba1
VTxxx check not part of utils and Prefixes for TreeNodes improved 2020-10-16 11:06:10 +02:00
Marcel Schramm e1276b06d6
Fixed potential deadlock during channel loading
Due to the fact that channels where loaded in a background thread which
then moved 99% of it's logic back onto the UI-thread and waited for it
to finish, switching channels very quickly (Alt+L - Load previous
channel) caused a deadlock. The reasoning behind this back then, was to
reduce timeframes in which the UI doesn't respond, but it was basically
incorrect either way.
2020-10-14 15:28:58 +02:00
Marcel Schramm dcab2d1faf
Add new method for opening direct message
There have been two different chunks of code with the same goal.
The newer one of both has now been put into an easy to use function.
The caller shouldn't have to worry about whether a channel already
exists or not if another user is to be DMed.
2020-10-14 14:25:11 +02:00
Marcel Schramm 7e60fe672c
Refactor channel loading
* LoadChannel has been split off into LoadChannel and UnloadChannel
* "Switch to last Channel" has been hugely simplified
  * window.previosuGuild reference is completly gone now, as it's
  infered from window.previousChannel
* Simplified some names and methods in the components for DMs, Guilds
and Channels
2020-10-14 13:51:59 +02:00
Marcel Schramm 34783d3823
Introduce workaround for panic caused by the commit that removed node references from the window struct 2020-10-13 17:51:39 +02:00
Marcel Schramm 0f4e1b1332
Simplify and comment message.go#LoadMessages 2020-10-13 17:42:44 +02:00
Marcel Schramm 2bfe3447d7
Improve docs on message loader and supplier 2020-10-13 17:33:10 +02:00
Marcel Schramm 159d9d1062
Improve docs for JS engine slightly 2020-10-13 17:07:10 +02:00
Marcel Schramm 75a9dea28f
Remove tview.TreeNode references from ui/window.go
These references caused the code to be more error prone, as the
references to the node and the corresponding guild/channel ID always
had to be kept in sync.
2020-10-13 16:53:57 +02:00
Marcel Schramm 9bffea00fe
Focus refactor (#354)
* Focusing is now handled in tview
* Each component decides which component is focused next
* Focus shortcuts are set via boolean functions in the app
* Focus shortcuts are handled before everything else
2020-10-13 15:55:04 +02:00
Marcel Schramm 7b8bd775ca
Add snap deprecation to bug issue template 2020-10-12 16:56:56 +02:00
Marcel Schramm fa7f634846
Duplicated download function has been moved to local variable 2020-10-12 12:21:29 +02:00
Marcel Schramm 5247123a93
Add mark as read for categories
* Categories are now selectable nodes
* Ctrl+R on a category will mark all unread channels beneath it as read
* The UI updates right after receiving a message ack event now
2020-10-11 15:53:16 +02:00
Marcel Schramm 6c4ee307a5
Last commit broke file downloading completly. 2020-10-10 16:01:51 +02:00
Marcel Schramm d8830f9ead
Delete logger output that slipped into commit 2020-10-10 15:59:35 +02:00
Marcel Schramm 47f3bf84e1
Fixes #352 2020-10-10 15:55:45 +02:00
Marcel Schramm 0e0052fd8c
Refactor some code and update changelog 2020-10-03 12:38:05 +02:00
Marcel Schramm 6927e3603f
Allow creating DMs
* DMs can be opened via "p" (configurable) in the chatview
* New command "dm-open" that takes a username or a user-ID
2020-10-03 10:58:50 +02:00
Marcel Schramm 939d35f1ea
Fixes #347 2020-10-02 23:23:16 +02:00
Marcel Schramm 538a78656a
Add another safeguard to avoid unnecessarily sending read states to discord when hitting ctrl+r 2020-09-27 13:27:49 +02:00
Marcel Schramm 8e750b6345
Filepaths in a message are now resolved and sent as a file 2020-09-27 13:09:49 +02:00
Marcel Schramm e750fc5e8c
Update changelog 2020-09-26 20:21:34 +02:00
Marcel Schramm b1d1f4d68c
Add Ctrl+R to mark channels or guilds as unread 2020-09-26 20:13:59 +02:00
Marcel Schramm a299c1edd7
Login screen now uses the exit application shortcut instead of CtrlC 2020-09-26 19:10:24 +02:00
Marcel Schramm 07249c0d83
Log parameter can now be left empty 2020-09-26 14:40:00 +02:00
Marcel Schramm ea5a2ceba7
Add --log option to add a logfile 2020-09-26 14:36:20 +02:00
Marcel Schramm b5c8f4af80
Add nullcheck to session after login attempt 2020-09-26 13:54:19 +02:00
Marcel Schramm 9cf225209d
Mentions are now displayed in the guildlist 2020-09-26 12:00:02 +02:00
Marcel Schramm fe49d6caaf
Fixed bug that didn't allow to do @everyone 2020-09-25 18:43:06 +02:00
Marcel Schramm 542973e1f2
Config loading error was checked too late 2020-09-24 22:16:43 +02:00
Marcel Schramm 1f58c5f9da
Fixes 240 2020-09-24 21:58:24 +02:00
Marcel Schramm 6b0720617a
Fixes #260 2020-09-24 21:51:04 +02:00
Marcel Schramm dc6f73f456
Add top padding to shortcut description in shortcuts dialog 2020-09-24 21:43:40 +02:00
Marcel Schramm 59842d1d7b
Rid everything of snap
Fixes #179
2020-09-24 21:28:17 +02:00
Marcel Schramm ea2a040610
Improve docs and coverage of GenerateQuote 2020-09-13 13:35:57 +02:00
Marcel Schramm 1770f61216
Cancelling with folders involved, but withour -r flag didn't work 2020-09-13 13:07:03 +02:00
Marcel Schramm 21a93cb1d1
Improve bugreport template. 2020-09-13 12:59:05 +02:00
Marcel Schramm 77c29d2003
Remove already completed TODO 2020-09-13 12:54:53 +02:00
Marcel Schramm c66351b7ba
Updated / Added some comments 2020-09-12 16:38:31 +02:00
Marcel Schramm c84561e5c0
Add simple test for bottombar 2020-09-12 16:08:37 +02:00
Marcel Schramm 1a80834ab5
Bottombar now more abstract and thread-safe 2020-09-12 15:58:34 +02:00
Marcel Schramm b704c9183b
Fix #335 2020-09-12 15:39:30 +02:00
Marcel Schramm 710475bc0b
focus shifting shortcuts are now configurable 2020-09-11 22:42:00 +02:00
Marcel Schramm 2871c803df
Add new aliases for file-send 2020-09-11 16:12:06 +02:00
Marcel Schramm b2fb05dac9
Change build instructions to a more manual but less errorprone approach. Fixes #333 2020-09-11 16:08:39 +02:00
Marcel Schramm b527ab8fae
Remove unused fields and functions 2020-09-10 21:42:49 +02:00
Marcel Schramm e0a10a6715
Update changelog 2020-09-10 21:15:24 +02:00
Marcel Schramm 7a55188672
Fix #332 2020-09-07 18:55:45 +02:00
Marcel Schramm ecd18e34f6
Split handleNotifications into isElligible and handle 2020-09-06 13:14:46 +02:00
Marcel Schramm 5c3523a5de
Notifications for blocked users don't show anymore. Fixes #331 2020-09-06 11:34:10 +02:00
Marcel Schramm 2143a56941
Prevent unnecessary spawning of go-routines on message create and delete events too. 2020-09-04 16:35:24 +02:00
Marcel Schramm 6d20e69105
Fix potentially incorrect chat view locking on message edit events and prevent unnecessary spawning of go-routines. 2020-09-04 16:32:12 +02:00
Marcel Schramm 52c2beea91
Already rendered messages that contain a link will not disappear anymore. 2020-09-03 18:57:44 +02:00
__Dire 8210a69fdd
Added chat seperator between Message Author and Message Body (#330)
Added chat seperator between Message Author and Message Body
2020-09-03 18:21:53 +02:00
Marcel Schramm b44cb95897
Improve build-instructions; Adresses #326 2020-08-31 18:31:18 +02:00
Marcel Schramm e483d9e429
Remove unnecessarily drawn space between items in bottombar 2020-08-31 18:21:05 +02:00
Marcel Schramm 485759fecc
Correct snap config home for cordless 2020-08-31 18:14:34 +02:00
1322 changed files with 339483 additions and 1980 deletions

View File

@ -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

View File

@ -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". --> |

11
.gitignore vendored
View File

@ -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

View File

@ -1,5 +0,0 @@
os: osx
language: go
go:
- "1.13"
script: go test -race ./...

17
.vscode/launch.json vendored Normal file
View File

@ -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
View File

@ -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:

View File

@ -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

View File

@ -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 ./...

3
build_debug.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/sh
go build -gcflags="all=-N -l" -o cordless_debug

View File

@ -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
}

106
commands/commandimpls/dm.go Normal file
View File

@ -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"}
}

View File

@ -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.

View File

@ -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 {

View File

@ -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

View File

@ -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) == ""
}

View File

@ -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

View File

@ -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"
}
}
}

3
debug.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/sh
dlv --listen=:2345 --headless=true --api-version=2 --accept-multiclient exec ./cordless_debug

View File

@ -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
}

View File

@ -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]
}

View File

@ -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)
}
})
}

View File

@ -1,7 +1,7 @@
package femto
import (
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/mattn/go-runewidth"
)

View File

@ -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

View File

@ -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
View File

@ -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
View File

@ -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=

73
logging/logging.go Normal file
View File

@ -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
View File

@ -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)
}
}
}

View File

@ -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.

View File

@ -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/

View File

@ -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
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -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))
}

View File

@ -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/

View File

@ -4,7 +4,7 @@ import (
"fmt"
"github.com/Bios-Marcel/cordless/tview"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
)
func main() {

View File

@ -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
}

View File

@ -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"
)

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
})
}

View File

@ -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
})
}

View File

@ -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)

View File

@ -2,7 +2,7 @@
package main
import (
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -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() {

View File

@ -2,7 +2,7 @@
package main
import (
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -4,7 +4,7 @@ package main
import (
"strings"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -3,7 +3,7 @@ package main
import (
"strings"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -3,7 +3,7 @@ package main
import (
"fmt"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -1,7 +1,7 @@
package main
import (
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -1,7 +1,7 @@
package main
import (
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -1,7 +1,7 @@
package main
import (
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -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]
)

View File

@ -16,7 +16,7 @@ import (
"fmt"
"strconv"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -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]
)

View File

@ -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))
}},
}},

View File

@ -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
})
}

View File

@ -4,7 +4,7 @@ package main
import (
"strings"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
"github.com/Bios-Marcel/cordless/tview"
)

View File

@ -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"
)

View File

@ -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
})
}

View File

@ -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
}

View File

@ -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

View File

@ -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.

View File

@ -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
})
}

View File

@ -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
})
}

View File

@ -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
})
}

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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
)

View File

@ -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.
//

View File

@ -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 {

View File

@ -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
})
}

View File

@ -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
})
}

View File

@ -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

View File

@ -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]+|\-)?)?)?\]`)

View File

@ -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()
}

View File

@ -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) {

View File

@ -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()
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
})

View File

@ -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
}

View File

@ -3,7 +3,7 @@ package tviewutil
import (
"fmt"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
)
var (

View File

@ -3,7 +3,7 @@ package tviewutil
import (
"testing"
"github.com/gdamore/tcell"
tcell "github.com/gdamore/tcell/v2"
)
func TestColorToHex(t *testing.T) {

14
ui/tviewutil/focus.go Normal file
View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

File diff suppressed because it is too large Load Diff

65
util/files/zip.go Normal file
View File

@ -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