Compare commits
10 commits
b860df0a25
...
af615e4724
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af615e4724 | ||
|
|
eb4028957d | ||
|
|
cb4c82d3e1 | ||
|
|
1bcd7650c8 | ||
|
|
cfb31cf2f3 | ||
|
|
bee05cbb25 | ||
|
|
b46624796d | ||
|
|
68ee346501 | ||
|
|
f25149a05e | ||
|
|
23b45b9c16 |
39 changed files with 2846 additions and 479 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
- name: Test
|
||||
run: |
|
||||
go test ${{ matrix.os != 'windows-11-arm' && '-race' || '' }} -v ./...
|
||||
go test ${{ matrix.os != 'windows-11-arm' && '-race' || '' }} -v -tags no_spoof_tls_fingerprint,no_voice ./...
|
||||
go test ${{ matrix.os != 'windows-11-arm' && '-race' || '' }} -v -tags no_spoof_tls_fingerprint,no_voice,no_syntax_highlighting ./...
|
||||
|
||||
- name: Build
|
||||
run: go build -trimpath -ldflags=-s .
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ go build .
|
|||
|
||||
- `no_voice` to build without voice support.
|
||||
- `no_spoof_tls_fingerprint` to build without TLS fingerprint spoofing.
|
||||
- `no_syntax_highlighting` to build without syntax highlighting in code blocks.
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
|
|||
8
go.mod
8
go.mod
|
|
@ -4,6 +4,8 @@ go 1.27.0
|
|||
|
||||
// replace github.com/ayn2op/tview => ../tview
|
||||
|
||||
replace github.com/gdamore/tcell/v3 => github.com/ayn2op/tcell/v3 v3.0.0-20261018233832-2d0ebd98add4
|
||||
|
||||
replace github.com/bogdanfinn/fhttp => github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935
|
||||
|
||||
// replace (
|
||||
|
|
@ -16,10 +18,10 @@ require (
|
|||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20261016043052-54a68a54e42e
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426
|
||||
github.com/ayn2op/tview v0.0.0-20261017205514-f83a3769b18a
|
||||
github.com/ayn2op/tview v0.0.0-20261018235036-82c614dca59b
|
||||
github.com/bogdanfinn/fhttp v0.6.9
|
||||
github.com/bogdanfinn/tls-client v1.16.0
|
||||
github.com/gdamore/tcell/v3 v3.6.0
|
||||
github.com/gdamore/tcell/v3 v3.6.1-0.20261018231941-fe0db1fcff11
|
||||
github.com/gen2brain/malgo v0.11.26
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-shellwords v1.0.16
|
||||
|
|
@ -51,7 +53,7 @@ require (
|
|||
github.com/dchest/jsmin v1.0.0 // indirect
|
||||
github.com/disgoorg/godave v0.3.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.9.1 // indirect
|
||||
github.com/ebitengine/purego v0.11.1 // indirect
|
||||
github.com/ebitengine/purego v0.11.2 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.4 // indirect
|
||||
github.com/gorilla/schema v1.4.1 // indirect
|
||||
|
|
|
|||
12
go.sum
12
go.sum
|
|
@ -16,8 +16,10 @@ github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935 h1:oVxkY1/nVsVQ8Cn
|
|||
github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935/go.mod h1:7phoj3f/q54WuiTnCTlAhEoaVcdlruKkSzCSD47LzHk=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 h1:0ndRWpAiSSgR0z9b7K+pwMlCHEk3VzQeS5ls+zWtbr0=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426/go.mod h1:4S2B9frwrjELeAldoqPgasA/VfnOySgRlektwYLuVks=
|
||||
github.com/ayn2op/tview v0.0.0-20261017205514-f83a3769b18a h1:gJyFlYT+igFchVDNUMapJdy8iM0cVA0/LxOqZ8n6FZc=
|
||||
github.com/ayn2op/tview v0.0.0-20261017205514-f83a3769b18a/go.mod h1:kn8Up2Tk2vhDUtW6H9nqAvRk9hDVyCzk18CCAo2qkjA=
|
||||
github.com/ayn2op/tcell/v3 v3.0.0-20261018233832-2d0ebd98add4 h1:44EjnvU6uycJgBFnoyM/pxaZ+szUqVUq6ctPr95n9OI=
|
||||
github.com/ayn2op/tcell/v3 v3.0.0-20261018233832-2d0ebd98add4/go.mod h1:TMjaFRfs5NCU3o1aiuxoRHdEdyixCy1mdGcHdvj7lvc=
|
||||
github.com/ayn2op/tview v0.0.0-20261018235036-82c614dca59b h1:JqeBdf2doBvjbtkQmZvDc2ZeNyf9s3YZXnV0CcT7mzM=
|
||||
github.com/ayn2op/tview v0.0.0-20261018235036-82c614dca59b/go.mod h1:th/NRjP7oc1J91pGuKE6hw2nEQmGG+Wewe8wjNMPlUA=
|
||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
||||
|
|
@ -44,12 +46,10 @@ github.com/disgoorg/godave v0.3.0 h1:37F3ZuiMd8/EXeoCtTeE8iP2eT2nWsfEa4/ITwoKfe4
|
|||
github.com/disgoorg/godave v0.3.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y=
|
||||
github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
|
||||
github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
|
||||
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
|
||||
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v3 v3.6.0 h1:jW20qS6Tm38KVDqLHq9KWSvyjNl00FbhbD8rIAnHBws=
|
||||
github.com/gdamore/tcell/v3 v3.6.0/go.mod h1:CBZxkl2T+XxEyuBfOh4zaahqm27dngvhfBfCCZxJsgc=
|
||||
github.com/gen2brain/malgo v0.11.26 h1:k5WcPIKw1bbJAbPqrvNPt7nehPLoaPNcOFde2+eruiM=
|
||||
github.com/gen2brain/malgo v0.11.26/go.mod h1:xLVG3ROA33Bzol1quF3e4ehqcFuqh8QK4B8T6LQUs/M=
|
||||
github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U=
|
||||
|
|
|
|||
134
internal/emoji/emoji.go
Normal file
134
internal/emoji/emoji.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package emoji
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
Name string
|
||||
InsertText string
|
||||
Display string
|
||||
Custom bool
|
||||
}
|
||||
|
||||
type itemList []Item
|
||||
|
||||
func (l itemList) String(i int) string {
|
||||
return l[i].Name
|
||||
}
|
||||
|
||||
func (l itemList) Len() int {
|
||||
return len(l)
|
||||
}
|
||||
|
||||
var unicodeList = sync.OnceValue(func() []Item {
|
||||
items := make([]Item, 0, len(Unicode))
|
||||
for name, glyph := range Unicode {
|
||||
items = append(items, Item{
|
||||
Name: name,
|
||||
InsertText: glyph,
|
||||
Display: ":" + name + ": " + glyph,
|
||||
Custom: false,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(items, func(a, b Item) int {
|
||||
if a.Name < b.Name {
|
||||
return -1
|
||||
} else if a.Name > b.Name {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
// Search searches custom emojis and standard Unicode emojis matching the query.
|
||||
func Search(query string, custom []discord.Emoji, limit int) []Item {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
customItems := make([]Item, 0, len(custom))
|
||||
for _, e := range custom {
|
||||
customItems = append(customItems, Item{
|
||||
Name: e.Name,
|
||||
InsertText: e.String(),
|
||||
Display: ":" + e.Name + ":",
|
||||
Custom: true,
|
||||
})
|
||||
}
|
||||
|
||||
uItems := unicodeList()
|
||||
|
||||
if query == "" {
|
||||
results := make([]Item, 0, limit)
|
||||
results = append(results, customItems...)
|
||||
if len(results) < limit {
|
||||
needed := limit - len(results)
|
||||
if needed > len(uItems) {
|
||||
needed = len(uItems)
|
||||
}
|
||||
results = append(results, uItems[:needed]...)
|
||||
}
|
||||
if len(results) > limit {
|
||||
return results[:limit]
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Exact prefix matches on custom emojis get highest priority
|
||||
var results []Item
|
||||
lowerQuery := strings.ToLower(query)
|
||||
|
||||
for _, item := range customItems {
|
||||
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy search across remaining custom emojis
|
||||
if len(customItems) > 0 {
|
||||
customMatches := fuzzy.FindFrom(query, itemList(customItems))
|
||||
for _, m := range customMatches {
|
||||
item := customItems[m.Index]
|
||||
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && it.Custom }) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact prefix matches on unicode emojis
|
||||
for _, item := range uItems {
|
||||
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy search across unicode emojis
|
||||
uMatches := fuzzy.FindFrom(query, itemList(uItems))
|
||||
for _, m := range uMatches {
|
||||
item := uItems[m.Index]
|
||||
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && !it.Custom }) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
87
internal/emoji/emoji_test.go
Normal file
87
internal/emoji/emoji_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package emoji
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
)
|
||||
|
||||
func TestSearchUnicode(t *testing.T) {
|
||||
results := Search("smile", nil, 5)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for 'smile', got none")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range results {
|
||||
if item.Name == "smile" && item.InsertText == "😄" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected smile -> 😄 in results, got %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCustomEmojis(t *testing.T) {
|
||||
custom := []discord.Emoji{
|
||||
{
|
||||
ID: 123456789,
|
||||
Name: "custom_cat",
|
||||
},
|
||||
{
|
||||
ID: 987654321,
|
||||
Name: "pepe_dance",
|
||||
Animated: true,
|
||||
},
|
||||
}
|
||||
|
||||
results := Search("cat", custom, 5)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for 'cat', got none")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range results {
|
||||
if item.Name == "custom_cat" {
|
||||
found = true
|
||||
if item.InsertText != "<:custom_cat:123456789>" {
|
||||
t.Errorf("expected custom format, got %s", item.InsertText)
|
||||
}
|
||||
if !item.Custom {
|
||||
t.Errorf("expected Custom=true")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected custom_cat in results, got %+v", results)
|
||||
}
|
||||
|
||||
// Test animated emoji
|
||||
animResults := Search("dance", custom, 5)
|
||||
if len(animResults) == 0 {
|
||||
t.Fatal("expected results for 'dance', got none")
|
||||
}
|
||||
if animResults[0].InsertText != "<a:pepe_dance:987654321>" {
|
||||
t.Errorf("expected animated format, got %s", animResults[0].InsertText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEmptyQuery(t *testing.T) {
|
||||
custom := []discord.Emoji{
|
||||
{
|
||||
ID: 111,
|
||||
Name: "alpha",
|
||||
},
|
||||
}
|
||||
results := Search("", custom, 10)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for empty query, got none")
|
||||
}
|
||||
// Custom emojis should be listed first
|
||||
if results[0].Name != "alpha" {
|
||||
t.Errorf("expected custom emoji first for empty query, got %s", results[0].Name)
|
||||
}
|
||||
}
|
||||
1860
internal/emoji/unicode.go
Normal file
1860
internal/emoji/unicode.go
Normal file
File diff suppressed because it is too large
Load diff
85
internal/markdown/highlight.go
Normal file
85
internal/markdown/highlight.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
//go:build !no_syntax_highlighting
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
)
|
||||
|
||||
func (r *Renderer) writeCodeBlock(builder *Builder, code string, language string, base tview.Style) {
|
||||
lexer := lexers.Get(language)
|
||||
declaredLanguageSupported := lexer != nil
|
||||
|
||||
// Detect the language from its content.
|
||||
var analyzed bool
|
||||
if lexer == nil {
|
||||
lexer = lexers.Analyse(code)
|
||||
analyzed = lexer != nil
|
||||
}
|
||||
if lexer == nil {
|
||||
lexer = lexers.Fallback
|
||||
}
|
||||
|
||||
// Some lexers emit many tiny tokens; coalesce runs of the same type.
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
|
||||
var header string
|
||||
switch {
|
||||
case analyzed:
|
||||
header = "code: analyzed"
|
||||
case language == "":
|
||||
header = "code"
|
||||
case !declaredLanguageSupported:
|
||||
header = "code: " + language
|
||||
}
|
||||
if header != "" {
|
||||
builder.Write(codeBlockIndent+header, base.Dim(true))
|
||||
builder.NewLine()
|
||||
}
|
||||
|
||||
iterator, err := lexer.Tokenise(nil, code)
|
||||
if err != nil {
|
||||
builder.Write(codeBlockIndent, base)
|
||||
writeCode(builder, code, base, base)
|
||||
return
|
||||
}
|
||||
|
||||
theme := styles.Get(r.cfg.Markdown.Theme)
|
||||
if theme == nil {
|
||||
theme = styles.Fallback
|
||||
}
|
||||
|
||||
builder.Write(codeBlockIndent, base)
|
||||
for token := iterator(); token != chroma.EOF; token = iterator() {
|
||||
style := applyChromaStyle(base, theme.Get(token.Type))
|
||||
writeCode(builder, token.Value, style, base)
|
||||
}
|
||||
}
|
||||
|
||||
func applyChromaStyle(base tview.Style, entry chroma.StyleEntry) tview.Style {
|
||||
style := base
|
||||
if entry.Colour.IsSet() {
|
||||
style = style.Foreground(tcell.NewRGBColor(
|
||||
int32(entry.Colour.Red()),
|
||||
int32(entry.Colour.Green()),
|
||||
int32(entry.Colour.Blue()),
|
||||
))
|
||||
}
|
||||
// Token background colors are not applied, so code blocks keep the user's terminal/chat background.
|
||||
|
||||
// An attribute chroma passes on is left as it is.
|
||||
if entry.Bold != chroma.Pass {
|
||||
style = style.Bold(entry.Bold == chroma.Yes)
|
||||
}
|
||||
if entry.Italic != chroma.Pass {
|
||||
style = style.Italic(entry.Italic == chroma.Yes)
|
||||
}
|
||||
if entry.Underline != chroma.Pass {
|
||||
style = style.Underline(entry.Underline == chroma.Yes)
|
||||
}
|
||||
return style
|
||||
}
|
||||
17
internal/markdown/highlight_none.go
Normal file
17
internal/markdown/highlight_none.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//go:build no_syntax_highlighting
|
||||
|
||||
package markdown
|
||||
|
||||
import "github.com/ayn2op/tview"
|
||||
|
||||
func (r *Renderer) writeCodeBlock(builder *Builder, code string, language string, base tview.Style) {
|
||||
header := "code"
|
||||
if language != "" {
|
||||
header += ": " + language
|
||||
}
|
||||
builder.Write(codeBlockIndent+header, base.Dim(true))
|
||||
builder.NewLine()
|
||||
|
||||
builder.Write(codeBlockIndent, base)
|
||||
writeCode(builder, code, base, base)
|
||||
}
|
||||
|
|
@ -4,14 +4,10 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
md "github.com/ayn2op/arikawa/v3/markdown"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/text"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/yuin/goldmark/v2/ast"
|
||||
)
|
||||
|
||||
|
|
@ -124,62 +120,17 @@ func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style, em
|
|||
|
||||
func (r *Renderer) renderCodeBlock(builder *Builder, source []byte, node *ast.CodeBlock, base tview.Style) {
|
||||
var code strings.Builder
|
||||
lines := node.Value.Segments()
|
||||
for _, line := range lines {
|
||||
for _, line := range node.Value.Segments() {
|
||||
code.Write(line.Bytes(source))
|
||||
}
|
||||
|
||||
language, _ := node.Language(source)
|
||||
language = strings.TrimSpace(language)
|
||||
lexer := lexers.Get(language)
|
||||
declaredLanguageSupported := lexer != nil
|
||||
r.writeCodeBlock(builder, code.String(), strings.TrimSpace(language), base)
|
||||
}
|
||||
|
||||
// Detect the language from its content.
|
||||
var analyzed bool
|
||||
if lexer == nil {
|
||||
lexer = lexers.Analyse(code.String())
|
||||
analyzed = lexer != nil
|
||||
}
|
||||
if lexer == nil {
|
||||
lexer = lexers.Fallback
|
||||
}
|
||||
|
||||
// Some lexers emit many tiny tokens; coalesce runs of the same type.
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
|
||||
var header string
|
||||
switch {
|
||||
case analyzed:
|
||||
header = "code: analyzed"
|
||||
case language == "":
|
||||
header = "code"
|
||||
case !declaredLanguageSupported:
|
||||
header = "code: " + language
|
||||
}
|
||||
if header != "" {
|
||||
builder.Write(codeBlockIndent+header, base.Dim(true))
|
||||
builder.NewLine()
|
||||
}
|
||||
|
||||
iterator, err := lexer.Tokenise(nil, code.String())
|
||||
if err != nil {
|
||||
for _, line := range lines {
|
||||
builder.Write(codeBlockIndent+string(line.Bytes(source)), base)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
theme := styles.Get(r.cfg.Markdown.Theme)
|
||||
if theme == nil {
|
||||
theme = styles.Fallback
|
||||
}
|
||||
|
||||
builder.Write(codeBlockIndent, base)
|
||||
for token := iterator(); token != chroma.EOF; token = iterator() {
|
||||
style := applyChromaStyle(base, theme.Get(token.Type))
|
||||
// Chroma tokens may include embedded newlines, so split and re-emit with indentation on each visual line.
|
||||
parts := strings.Split(token.Value, "\n")
|
||||
for i, part := range parts {
|
||||
// writeCode writes s in style, starting each line after the first with the code block indent in base.
|
||||
func writeCode(builder *Builder, s string, style, base tview.Style) {
|
||||
for i, part := range strings.Split(s, "\n") {
|
||||
if i > 0 {
|
||||
builder.NewLine()
|
||||
builder.Write(codeBlockIndent, base)
|
||||
|
|
@ -188,7 +139,6 @@ func (r *Renderer) renderCodeBlock(builder *Builder, source []byte, node *ast.Co
|
|||
builder.Write(part, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldMask reports whether node is a spoiler shown as "[spoiler]" in place of what it hides.
|
||||
|
|
@ -210,30 +160,6 @@ func listMarker(item *ast.ListItem) string {
|
|||
return strconv.Itoa(number) + ". "
|
||||
}
|
||||
|
||||
func applyChromaStyle(base tview.Style, entry chroma.StyleEntry) tview.Style {
|
||||
style := base
|
||||
if entry.Colour.IsSet() {
|
||||
style = style.Foreground(tcell.NewRGBColor(
|
||||
int32(entry.Colour.Red()),
|
||||
int32(entry.Colour.Green()),
|
||||
int32(entry.Colour.Blue()),
|
||||
))
|
||||
}
|
||||
// Token background colors are not applied, so code blocks keep the user's terminal/chat background.
|
||||
|
||||
// An attribute chroma passes on is left as it is.
|
||||
if entry.Bold != chroma.Pass {
|
||||
style = style.Bold(entry.Bold == chroma.Yes)
|
||||
}
|
||||
if entry.Italic != chroma.Pass {
|
||||
style = style.Italic(entry.Italic == chroma.Yes)
|
||||
}
|
||||
if entry.Underline != chroma.Pass {
|
||||
style = style.Underline(entry.Underline == chroma.Yes)
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
func mentionText(node *md.Mention) string {
|
||||
switch {
|
||||
case node.Channel != nil:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package composer
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/help"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/ayn2op/tview/textarea"
|
||||
)
|
||||
|
||||
var _ help.KeyMap = Model{}
|
||||
|
|
@ -60,3 +62,17 @@ func (m Model) FullHelp() [][]keybind.Keybind {
|
|||
openEditor,
|
||||
}
|
||||
}
|
||||
|
||||
// keybind binds the configured newline key and textarea's other default keys, leaving the composer's own keys to Update.
|
||||
func (m Model) keybind(key tview.KeyMsg) textarea.Action {
|
||||
if m.isBound(key) {
|
||||
return textarea.ActionNone
|
||||
}
|
||||
if keybind.Matches(key, m.cfg.Keybinds.Composer.Newline.Keybind) {
|
||||
return textarea.ActionNewline
|
||||
}
|
||||
if action := textarea.DefaultKeybind(key); action != textarea.ActionNewline {
|
||||
return action
|
||||
}
|
||||
return textarea.ActionNone
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"github.com/ayn2op/tview/layout"
|
||||
"io"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
|
@ -14,6 +13,8 @@ import (
|
|||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/ayn2op/tview/layout"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/api"
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
|
|
@ -24,6 +25,7 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/consts"
|
||||
"github.com/ayn2op/discordo/internal/ui"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
|
|
@ -70,6 +72,7 @@ type Model struct {
|
|||
sendMessageData *api.SendMessageData
|
||||
memberSearchCache map[string]uint
|
||||
mentionsList mentionslist.Model
|
||||
emojisList emojislist.Model
|
||||
lastSearch time.Time
|
||||
|
||||
typingUntil time.Time
|
||||
|
|
@ -86,6 +89,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
|
|||
sendMessageData: &api.SendMessageData{},
|
||||
memberSearchCache: make(map[string]uint),
|
||||
mentionsList: mentionslist.NewModel(cfg),
|
||||
emojisList: emojislist.NewModel(cfg),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
|
@ -119,43 +123,20 @@ func (m Model) placeholder() string {
|
|||
}
|
||||
|
||||
// View shows the text area in a box, taking keys if focused.
|
||||
func (m Model) View(focused bool) tview.Widget { return view{&m, focused} }
|
||||
|
||||
// view is the composer as shown, with or without the focus.
|
||||
type view struct {
|
||||
*Model
|
||||
focused bool
|
||||
}
|
||||
|
||||
func (v view) Size() (width, height layout.Length) {
|
||||
return v.box(v.focused).Size()
|
||||
}
|
||||
|
||||
func (v view) Layout(limits layout.Limits) layout.Size {
|
||||
return v.box(v.focused).Layout(limits)
|
||||
}
|
||||
|
||||
func (v view) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||
v.box(v.focused).Draw(screen, area)
|
||||
}
|
||||
|
||||
// Handle leaves bound keys to Update and passes other messages to the text area.
|
||||
func (v view) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
||||
if key, ok := msg.(tview.KeyMsg); ok && v.isBound(key) {
|
||||
return msg
|
||||
}
|
||||
return v.box(v.focused).Handle(msg, area)
|
||||
}
|
||||
func (m Model) View(focused bool) tview.Widget { return m.box(focused) }
|
||||
|
||||
// box returns the text area in a box with the title and footer.
|
||||
func (m *Model) box(focused bool) box.Widget {
|
||||
text := textarea.New(m.text, m.cursor).
|
||||
Placeholder(m.placeholder()).
|
||||
Keybind(m.editAction).
|
||||
Keybind(m.keybind).
|
||||
Focused(focused && !m.disabled).
|
||||
OnInput(func(text string, cursor int) tview.Msg { return editMsg{text, cursor} }).
|
||||
Height(layout.Shrink.Max(m.cfg.Composer.MaxHeight))
|
||||
return ui.Box(text, m.cfg, focused).Title(m.title()).Footer(m.footer()).Height(layout.Shrink)
|
||||
return ui.Box(text, m.cfg, focused).
|
||||
Title(ui.Label(m.title(), m.cfg.Theme.Title, focused)).
|
||||
Footer(ui.Label(m.footer(), m.cfg.Theme.Footer, focused)).
|
||||
Height(layout.Shrink)
|
||||
}
|
||||
|
||||
// title returns what the composer is doing: editing, replying, or nothing.
|
||||
|
|
@ -288,7 +269,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
m.pasteText()
|
||||
return m, pasteImage()
|
||||
case keybind.Matches(msg, kbs.Send.Keybind):
|
||||
if m.mentionsListVisible() {
|
||||
if m.mentionsListVisible() || m.emojisListVisible() {
|
||||
return m, m.tabComplete()
|
||||
}
|
||||
return m, m.send()
|
||||
|
|
@ -299,14 +280,14 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
m.stopTabCompletion()
|
||||
return m, m.pickFiles()
|
||||
case keybind.Matches(msg, kbs.Cancel.Keybind):
|
||||
if m.mentionsListVisible() {
|
||||
if m.mentionsListVisible() || m.emojisListVisible() {
|
||||
m.stopTabCompletion()
|
||||
return m, nil
|
||||
}
|
||||
m.reset()
|
||||
return m, nil
|
||||
case keybind.Matches(msg, kbs.TabComplete.Keybind):
|
||||
if m.mentionsListVisible() {
|
||||
if m.mentionsListVisible() || m.emojisListVisible() {
|
||||
return m, m.tabComplete()
|
||||
}
|
||||
m.insert("\t")
|
||||
|
|
@ -322,6 +303,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
var cmd tview.Cmd
|
||||
m.mentionsList, cmd = m.mentionsList.Update(msg)
|
||||
return m, cmd
|
||||
case emojislist.Msg:
|
||||
var cmd tview.Cmd
|
||||
m.emojisList, cmd = m.emojisList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
|
@ -329,7 +314,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
func (m Model) canEditLastMessage() bool {
|
||||
return !m.Disabled() && m.editing == nil && m.text == "" &&
|
||||
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
|
||||
!m.mentionsListVisible()
|
||||
!m.mentionsListVisible() && !m.emojisListVisible()
|
||||
}
|
||||
|
||||
// StartEdit puts message in the composer to be edited.
|
||||
|
|
@ -443,6 +428,10 @@ func isMentionChar(r rune) bool {
|
|||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
|
||||
}
|
||||
|
||||
func isEmojiChar(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '+' || r == '-'
|
||||
}
|
||||
|
||||
// suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user.
|
||||
func (m *Model) suggestRecentAuthors(channel *discord.Channel) {
|
||||
messages, err := m.state.Cabinet.Messages(channel.ID)
|
||||
|
|
@ -522,12 +511,23 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
// MentionsListView places the mentions list at the bottom of the area above the composer, near the cursor, or returns nil if it is empty or the composer is not focused.
|
||||
func (m Model) MentionsListView(focused bool) tview.Widget {
|
||||
if !focused || !m.mentionsListVisible() {
|
||||
// AutocompleteView places the active completion list (mentions or emojis) at the bottom of the area above the composer, near the cursor, or returns nil if none are active or the composer is not focused.
|
||||
func (m Model) AutocompleteView(focused bool) tview.Widget {
|
||||
if !focused {
|
||||
return nil
|
||||
}
|
||||
if m.mentionsListVisible() {
|
||||
return mentionsPopup{m}
|
||||
}
|
||||
if m.emojisListVisible() {
|
||||
return emojisPopup{m}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MentionsListView is an alias for AutocompleteView.
|
||||
func (m Model) MentionsListView(focused bool) tview.Widget {
|
||||
return m.AutocompleteView(focused)
|
||||
}
|
||||
|
||||
// mentionsPopup places the mentions list within the area of the messages, which it is drawn over.
|
||||
|
|
@ -561,6 +561,33 @@ func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle {
|
|||
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
|
||||
}
|
||||
|
||||
type emojisPopup struct {
|
||||
c Model
|
||||
}
|
||||
|
||||
func (emojisPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
|
||||
|
||||
func (emojisPopup) Layout(limits layout.Limits) layout.Size {
|
||||
return layout.Atomic(limits, layout.Fill, layout.Fill)
|
||||
}
|
||||
|
||||
func (p emojisPopup) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||
p.c.emojisList.View().Draw(screen, p.area(area))
|
||||
}
|
||||
|
||||
func (p emojisPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
||||
return p.c.emojisList.View().Handle(msg, p.area(area))
|
||||
}
|
||||
|
||||
func (p emojisPopup) area(area tview.Rectangle) tview.Rectangle {
|
||||
c := p.c
|
||||
size := c.emojisList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
|
||||
value, cursor := c.text, c.cursor
|
||||
col := uucode.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
|
||||
x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width)
|
||||
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
|
||||
}
|
||||
|
||||
func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool {
|
||||
if member == nil {
|
||||
return false
|
||||
|
|
@ -615,10 +642,11 @@ func (m *Model) addMentionUser(user *discord.User) {
|
|||
})
|
||||
}
|
||||
|
||||
// stopTabCompletion clears the mentions list, which hides it, when it is in use.
|
||||
// stopTabCompletion clears the autocomplete lists, which hides them, when they are in use.
|
||||
func (m *Model) stopTabCompletion() {
|
||||
if m.cfg.AutocompleteLimit > 0 {
|
||||
m.mentionsList.Clear()
|
||||
m.emojisList.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -627,6 +655,10 @@ func (m Model) mentionsListVisible() bool {
|
|||
return m.mentionsList.ItemCount() != 0
|
||||
}
|
||||
|
||||
func (m Model) emojisListVisible() bool {
|
||||
return m.emojisList.ItemCount() != 0
|
||||
}
|
||||
|
||||
func (m Model) attach(name string, reader io.Reader) {
|
||||
m.sendMessageData.Files = append(m.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
|
||||
}
|
||||
|
|
@ -635,17 +667,6 @@ func (m Model) canAttachFiles() bool {
|
|||
return m.channel != nil && m.state.HasPermissions(m.channel.ID, discord.PermissionAttachFiles)
|
||||
}
|
||||
|
||||
// editAction binds the configured newline key and textarea's other default keys.
|
||||
func (m Model) editAction(key tview.KeyMsg) textarea.Action {
|
||||
if keybind.Matches(key, m.cfg.Keybinds.Composer.Newline.Keybind) {
|
||||
return textarea.ActionNewline
|
||||
}
|
||||
if action := textarea.DefaultKeybind(key); action != textarea.ActionNewline {
|
||||
return action
|
||||
}
|
||||
return textarea.ActionNone
|
||||
}
|
||||
|
||||
func humanJoin(items []string) string {
|
||||
count := len(items)
|
||||
switch count {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
ningenEmoji "github.com/ayn2op/ningen/v3/states/emoji"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
|
|
@ -200,3 +201,63 @@ func TestModelProcessText(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEmojiSuggest(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||
c.SetChannel(&channel)
|
||||
c.setText(":smi", 4)
|
||||
c.tabSuggest()
|
||||
if got := c.emojisList.ItemCount(); got == 0 {
|
||||
t.Fatal("expected emoji suggestions for ':smi', got none")
|
||||
}
|
||||
if !c.emojisListVisible() {
|
||||
t.Fatal("expected emojisListVisible to be true")
|
||||
}
|
||||
if c.mentionsListVisible() {
|
||||
t.Fatal("expected mentionsListVisible to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEmojiComplete(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||
c.SetChannel(&channel)
|
||||
c.setText("hello :smile", 12)
|
||||
c.tabSuggest()
|
||||
if !c.emojisListVisible() {
|
||||
t.Fatal("expected emojis list visible")
|
||||
}
|
||||
c.tabComplete()
|
||||
if got := c.text; got != "hello 😄 " {
|
||||
t.Fatalf("text after completion = %q, want 'hello 😄 '", got)
|
||||
}
|
||||
if c.emojisListVisible() {
|
||||
t.Fatal("expected emojis list to be hidden after completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCustomEmojiComplete(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
guildID := discord.GuildID(100)
|
||||
channel := discord.Channel{ID: 10, GuildID: guildID, Type: discord.GuildText}
|
||||
c.state.EmojiState = ningenEmoji.NewState(c.state.Cabinet)
|
||||
_ = c.state.Cabinet.GuildSet(&discord.Guild{ID: guildID}, false)
|
||||
_ = c.state.Cabinet.EmojiSet(guildID, []discord.Emoji{
|
||||
{ID: 555, Name: "custom_pepe"},
|
||||
}, false)
|
||||
|
||||
c.SetChannel(&channel)
|
||||
c.setText(":custom_pepe", 12)
|
||||
c.tabSuggest()
|
||||
if !c.emojisListVisible() {
|
||||
t.Fatal("expected emojis list visible")
|
||||
}
|
||||
c.tabComplete()
|
||||
if got := c.text; got != "<:custom_pepe:555> " {
|
||||
t.Fatalf("text after completion = %q, want '<:custom_pepe:555> '", got)
|
||||
}
|
||||
if c.emojisListVisible() {
|
||||
t.Fatal("expected emojis list to be hidden after completion")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ import (
|
|||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/arikawa/v3/utils/json/option"
|
||||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||
"github.com/ayn2op/discordo/internal/emoji"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/ncruces/zenity"
|
||||
"github.com/sahilm/fuzzy"
|
||||
"golang.design/x/clipboard"
|
||||
|
|
@ -168,6 +171,16 @@ func (m *Model) send() tview.Cmd {
|
|||
}
|
||||
|
||||
func (m *Model) tabComplete() tview.Cmd {
|
||||
if m.mentionsListVisible() {
|
||||
return m.completeMention()
|
||||
}
|
||||
if m.emojisListVisible() {
|
||||
return m.completeEmoji()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) completeMention() tview.Cmd {
|
||||
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||
if r != '@' {
|
||||
m.stopTabCompletion()
|
||||
|
|
@ -219,12 +232,41 @@ func (m *Model) tabComplete() tview.Cmd {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) tabSuggest() tview.Cmd {
|
||||
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||
if r != '@' {
|
||||
func (m *Model) completeEmoji() tview.Cmd {
|
||||
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
|
||||
if r != ':' {
|
||||
m.stopTabCompletion()
|
||||
return nil
|
||||
}
|
||||
pos := posEnd - (len(name) + 1)
|
||||
|
||||
insertText, ok := m.emojisList.SelectedInsertText()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
m.replace(pos, posEnd, insertText+" ")
|
||||
m.stopTabCompletion()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) tabSuggest() tview.Cmd {
|
||||
_, mentionName, rMention := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||
if rMention == '@' {
|
||||
m.emojisList.Clear()
|
||||
return m.suggestMentions(mentionName)
|
||||
}
|
||||
|
||||
_, emojiName, rEmoji := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
|
||||
if rEmoji == ':' {
|
||||
m.mentionsList.Clear()
|
||||
return m.suggestEmojis(emojiName)
|
||||
}
|
||||
|
||||
m.stopTabCompletion()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) suggestMentions(name string) tview.Cmd {
|
||||
channel := m.channel
|
||||
if channel == nil {
|
||||
return nil
|
||||
|
|
@ -273,6 +315,49 @@ func (m *Model) tabSuggest() tview.Cmd {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) suggestEmojis(name string) tview.Cmd {
|
||||
channel := m.channel
|
||||
if channel == nil {
|
||||
return nil
|
||||
}
|
||||
m.emojisList.Clear()
|
||||
|
||||
var customEmojis []discord.Emoji
|
||||
if m.state != nil && m.state.EmojiState != nil {
|
||||
guilds, err := m.state.EmojiState.ForGuild(channel.GuildID)
|
||||
if err == nil {
|
||||
for _, g := range guilds {
|
||||
customEmojis = append(customEmojis, g.Emojis...)
|
||||
}
|
||||
} else {
|
||||
slog.Debug("failed to get emojis for guild", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
limit := int(m.cfg.AutocompleteLimit)
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
results := emoji.Search(name, customEmojis, limit)
|
||||
for _, item := range results {
|
||||
style := tcell.StyleDefault
|
||||
m.emojisList.Append(emojislist.Item{
|
||||
Name: item.Name,
|
||||
InsertText: item.InsertText,
|
||||
DisplayText: item.Display,
|
||||
Style: style,
|
||||
})
|
||||
}
|
||||
|
||||
if m.emojisList.ItemCount() == 0 {
|
||||
m.stopTabCompletion()
|
||||
return nil
|
||||
}
|
||||
m.emojisList.Rebuild()
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchMember performs member discovery in a command goroutine.
|
||||
// It emits a follow-up suggestion message once results are loaded.
|
||||
func (m *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {
|
||||
|
|
|
|||
10
internal/ui/chat/emojislist/item.go
Normal file
10
internal/ui/chat/emojislist/item.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package emojislist
|
||||
|
||||
import "github.com/ayn2op/tview"
|
||||
|
||||
type Item struct {
|
||||
Name string
|
||||
InsertText string
|
||||
DisplayText string
|
||||
Style tview.Style
|
||||
}
|
||||
103
internal/ui/chat/emojislist/model.go
Normal file
103
internal/ui/chat/emojislist/model.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package emojislist
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/ui"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/layout"
|
||||
"github.com/ayn2op/tview/list"
|
||||
"github.com/ayn2op/tview/scrollbar"
|
||||
"github.com/ayn2op/tview/text"
|
||||
"github.com/ayn2op/tview/viewport"
|
||||
"github.com/rockorager/go-uucode"
|
||||
)
|
||||
|
||||
// Model shows emoji suggestions as a list in a box.
|
||||
type Model struct {
|
||||
cfg *config.Config
|
||||
|
||||
items []Item
|
||||
cursor int
|
||||
scrollState viewport.ScrollState
|
||||
}
|
||||
|
||||
func NewModel(cfg *config.Config) Model {
|
||||
return Model{cfg: cfg, cursor: -1}
|
||||
}
|
||||
|
||||
// View shows the list in a box whose bottom corners join the composer's border below it.
|
||||
func (m Model) View() tview.Widget {
|
||||
set := m.cfg.UI.Border.NormalSet.BorderSet
|
||||
set.BottomLeft, set.BottomRight = set.BottomT, set.BottomT
|
||||
cfg := m.cfg.UI.MentionsList
|
||||
list, width := m.listView().Height(layout.Shrink.Max(int(cfg.MaxHeight))), layout.Fill
|
||||
if cfg.MinWidth != 0 {
|
||||
list, width = list.Width(layout.Fixed(max(int(cfg.MinWidth), m.maxDisplayWidth()))), layout.Shrink
|
||||
}
|
||||
|
||||
return ui.Box(list, m.cfg, false).Title(ui.Label("Emoji", m.cfg.Theme.Title, false)).BorderSet(set).Width(width).Height(layout.Shrink)
|
||||
}
|
||||
|
||||
func (Model) Init() tview.Cmd { return nil }
|
||||
|
||||
func (m Model) listView() viewport.Widget {
|
||||
l := list.New(m.cursor, len(m.items), func(i int) tview.Widget {
|
||||
item := m.items[i]
|
||||
return text.New(item.DisplayText).Style(item.Style).Wrapping(text.WrappingNone)
|
||||
}).
|
||||
SelectedStyle(m.cfg.Theme.MentionsList.SelectedStyle.Style).
|
||||
Keybind(ui.SelectionKeybind(m.cfg.Keybinds.MentionsList.SelectionKeybinds)).
|
||||
Focused(true).
|
||||
OnChange(func(cursor int) tview.Msg { return listMsg(cursor) })
|
||||
return viewport.New(l, m.scrollState).
|
||||
Target(l.Target(m.cursor)).
|
||||
ScrollBar(scrollbar.New(), viewport.ScrollBarVisibilityAutomatic).
|
||||
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) })
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case listMsg:
|
||||
m.cursor = int(msg)
|
||||
m.scrollState.ScrollToTarget()
|
||||
case scrollMsg:
|
||||
m.scrollState = viewport.ScrollState(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) Append(item Item) {
|
||||
m.items = append(m.items, item)
|
||||
}
|
||||
|
||||
func (m *Model) Clear() {
|
||||
m.items = nil
|
||||
m.cursor = -1
|
||||
}
|
||||
|
||||
func (m Model) ItemCount() int {
|
||||
return len(m.items)
|
||||
}
|
||||
|
||||
func (m Model) SelectedInsertText() (string, bool) {
|
||||
index := m.cursor
|
||||
if index < 0 || index >= len(m.items) {
|
||||
return "", false
|
||||
}
|
||||
return m.items[index].InsertText, true
|
||||
}
|
||||
|
||||
func (m Model) maxDisplayWidth() int {
|
||||
width := 0
|
||||
for _, item := range m.items {
|
||||
width = max(width, uucode.StringWidth(item.DisplayText))
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func (m *Model) Rebuild() {
|
||||
m.cursor = min(0, len(m.items)-1)
|
||||
m.scrollState.ScrollToTarget()
|
||||
}
|
||||
|
||||
var _ tview.Model[Model] = Model{}
|
||||
9
internal/ui/chat/emojislist/msg.go
Normal file
9
internal/ui/chat/emojislist/msg.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package emojislist
|
||||
|
||||
import "github.com/ayn2op/tview/viewport"
|
||||
|
||||
type Msg any
|
||||
|
||||
type listMsg int
|
||||
|
||||
type scrollMsg viewport.ScrollState
|
||||
|
|
@ -72,8 +72,8 @@ func (m Model) View(focused bool) tview.Widget {
|
|||
onChange,
|
||||
onSelect,
|
||||
).
|
||||
Title("Guilds").
|
||||
Footer(m.voiceFooter())
|
||||
Title(ui.Label("Guilds", m.cfg.Theme.Title, focused)).
|
||||
Footer(ui.Label(m.voiceFooter(), m.cfg.Theme.Footer, focused))
|
||||
}
|
||||
|
||||
func (m *Model) reset() *tree.Node {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/arikawa/v3/state"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/ui"
|
||||
uitree "github.com/ayn2op/discordo/internal/ui/tree"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
|
|
@ -99,7 +100,7 @@ func (m Model) View(focused bool) tview.Widget {
|
|||
onChange,
|
||||
onSelect,
|
||||
).
|
||||
Title("Members")
|
||||
Title(ui.Label("Members", m.cfg.Theme.Title, focused))
|
||||
}
|
||||
|
||||
// setCurrentNode selects node and scrolls to it.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/ayn2op/tview/layout"
|
||||
"github.com/ayn2op/tview/list"
|
||||
"github.com/ayn2op/tview/scrollbar"
|
||||
"github.com/ayn2op/tview/text"
|
||||
"github.com/ayn2op/tview/viewport"
|
||||
"github.com/rockorager/go-uucode"
|
||||
)
|
||||
|
|
@ -35,7 +36,7 @@ func (m Model) View() tview.Widget {
|
|||
list, width = list.Width(layout.Fixed(max(int(cfg.MinWidth), m.maxDisplayWidth()))), layout.Shrink
|
||||
}
|
||||
|
||||
return ui.Box(list, m.cfg, false).Title("Mentions").BorderSet(set).Width(width).Height(layout.Shrink)
|
||||
return ui.Box(list, m.cfg, false).Title(ui.Label("Mentions", m.cfg.Theme.Title, false)).BorderSet(set).Width(width).Height(layout.Shrink)
|
||||
}
|
||||
|
||||
func (Model) Init() tview.Cmd { return nil }
|
||||
|
|
@ -43,7 +44,7 @@ func (Model) Init() tview.Cmd { return nil }
|
|||
func (m Model) listView() viewport.Widget {
|
||||
l := list.New(m.cursor, len(m.items), func(i int) tview.Widget {
|
||||
item := m.items[i]
|
||||
return row{text: item.DisplayText, style: item.Style}
|
||||
return text.New(item.DisplayText).Style(item.Style).Wrapping(text.WrappingNone)
|
||||
}).
|
||||
SelectedStyle(m.cfg.Theme.MentionsList.SelectedStyle.Style).
|
||||
Keybind(ui.SelectionKeybind(m.cfg.Keybinds.MentionsList.SelectionKeybinds)).
|
||||
|
|
@ -102,26 +103,3 @@ func (m *Model) Rebuild() {
|
|||
}
|
||||
|
||||
var _ tview.Model[Model] = Model{}
|
||||
|
||||
// row is a mention on one line in its style.
|
||||
type row struct {
|
||||
text string
|
||||
style tview.Style
|
||||
}
|
||||
|
||||
// Size returns Fill and a height of one row.
|
||||
func (row) Size() (width, height layout.Length) { return layout.Fill, layout.Fixed(1) }
|
||||
|
||||
// Layout returns a height of one row.
|
||||
func (row) Layout(limits layout.Limits) layout.Size {
|
||||
return layout.Atomic(limits, layout.Fill, layout.Fixed(1))
|
||||
}
|
||||
|
||||
func (r row) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||
for x := area.X; x < area.X+area.Width; x++ {
|
||||
screen.Put(x, area.Y, " ", r.style)
|
||||
}
|
||||
tview.Print(screen, r.text, area.X, area.Y, area.Width, tview.HorizontalAlignmentLeft, r.style)
|
||||
}
|
||||
|
||||
func (row) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }
|
||||
|
|
|
|||
|
|
@ -8,22 +8,28 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/markdown"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/box"
|
||||
"github.com/ayn2op/tview/layout"
|
||||
"github.com/ayn2op/tview/text"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/rockorager/go-uucode"
|
||||
"github.com/yuin/goldmark/v2/ast"
|
||||
)
|
||||
|
||||
// embedPrefix is drawn before each line of an embed, in the embed's color.
|
||||
const embedPrefix = " ▎ "
|
||||
|
||||
// embedPrefixWidth is how many cells embedPrefix takes.
|
||||
var embedPrefixWidth = uucode.StringWidth(embedPrefix)
|
||||
const (
|
||||
// embedBar is drawn down the left of an embed, in the embed's color.
|
||||
embedBar = "▎"
|
||||
// embedIndent is the cells before the bar, and embedBarGap those between the bar and the text.
|
||||
embedIndent, embedBarGap = 2, 1
|
||||
)
|
||||
|
||||
// embedViews returns each embed of message as its text with a bar down its left, or nil if the message does not show its embeds: one without any, by a blocked user, or that is not drawn with its own content, as a forward.
|
||||
// The views capture the model and the message, so they are made once with the text of the message rather than in buildItem.
|
||||
// root is the parsed content of message, or nil to parse it here.
|
||||
func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, root ast.Node) {
|
||||
if len(message.Embeds) == 0 {
|
||||
return
|
||||
func (m Model) embedViews(message discord.Message, root ast.Node) []tview.Widget {
|
||||
forward := message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward
|
||||
ownContent := message.Type == discord.DefaultMessage && !forward || message.Type == discord.InlinedReplyMessage
|
||||
if len(message.Embeds) == 0 || m.blocked(message.Author) || !ownContent {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Embed URLs are deduplicated against links already shown in the message body.
|
||||
|
|
@ -33,47 +39,51 @@ func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, ba
|
|||
seenURLs[u] = struct{}{}
|
||||
}
|
||||
|
||||
baseStyle := m.cfg.Theme.MessagesList.MessageStyle.Style
|
||||
lineStyles := embedLineStyles(baseStyle, m.cfg.Theme.MessagesList.Embeds)
|
||||
defaultPrefixStyle := baseStyle.Dim(true)
|
||||
// Wrap against the list's width, so the message is rendered again when it changes.
|
||||
wrapWidth := max(width-embedPrefixWidth, 1)
|
||||
|
||||
var views []tview.Widget
|
||||
for _, embed := range message.Embeds {
|
||||
lines := embedLines(embed, seenURLs)
|
||||
if len(lines) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
prefixStyle := defaultPrefixStyle
|
||||
barStyle := baseStyle.Dim(true)
|
||||
if embed.Color != discord.NullColor && embed.Color != 0 {
|
||||
prefixStyle = prefixStyle.Foreground(tcell.NewHexColor(int32(embed.Color)))
|
||||
}
|
||||
prefix := text.Span{Text: embedPrefix, Style: prefixStyle}
|
||||
builder.NewLine()
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line.text) == "" {
|
||||
continue
|
||||
}
|
||||
msg := message
|
||||
msg.Content = line.text
|
||||
lineStyle := lineStyles[line.kind]
|
||||
// Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled.
|
||||
rendered := m.renderContent(msg, nil, lineStyle, line.kind == embedLineDescription)
|
||||
if line.url != "" {
|
||||
rendered = spansWithURL(rendered, line.url)
|
||||
barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color)))
|
||||
}
|
||||
|
||||
// The prefix is added after wrapping, so every visual line has it, not only the first.
|
||||
for _, wrapped := range text.Lines(rendered, wrapWidth, text.WrappingGlyph) {
|
||||
bar := box.New(messageText(m.renderEmbed(message, lines, lineStyles))).
|
||||
Borders(tview.BordersLeft).
|
||||
BorderSet(tview.BorderSet{Left: embedBar}).
|
||||
BorderStyle(barStyle).
|
||||
Padding(0, 0, embedBarGap, 0).
|
||||
Height(layout.Shrink)
|
||||
views = append(views, box.New(bar).Padding(0, 0, embedIndent, 0).Height(layout.Shrink))
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
// renderEmbed returns lines of an embed of message, each on its own line in the style of its kind.
|
||||
func (m Model) renderEmbed(message discord.Message, lines []embedLine, lineStyles [embedLineKinds]tview.Style) []text.Span {
|
||||
builder := new(markdown.Builder)
|
||||
for _, line := range lines {
|
||||
if !builder.LineEmpty() {
|
||||
builder.NewLine()
|
||||
}
|
||||
|
||||
builder.WriteSpans(prefix)
|
||||
builder.WriteSpans(wrapped...)
|
||||
}
|
||||
msg := message
|
||||
msg.Content = line.text
|
||||
// Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled.
|
||||
rendered := m.renderContent(msg, nil, lineStyles[line.kind], line.kind == embedLineDescription)
|
||||
if line.url != "" {
|
||||
rendered = spansWithURL(rendered, line.url)
|
||||
}
|
||||
builder.WriteSpans(rendered...)
|
||||
}
|
||||
|
||||
return builder.Finish()
|
||||
}
|
||||
|
||||
func spansWithURL(spans []text.Span, rawURL string) []text.Span {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
md "github.com/ayn2op/arikawa/v3/markdown"
|
||||
|
|
@ -20,85 +19,18 @@ import (
|
|||
"github.com/yuin/goldmark/v2/ast"
|
||||
)
|
||||
|
||||
// messageViews returns the views of the text of message and of what it replies to.
|
||||
// They capture the model and the message, so they are made here: capturing them in buildItem would copy both to the heap on every call, as the list builds every item each time it is laid out.
|
||||
// root is the parsed content of message, or nil to parse it when it is rendered.
|
||||
func (m Model) messageViews(message discord.Message, root ast.Node, grouped bool) (content, reply *messageView) {
|
||||
style := m.cfg.Theme.MessagesList.MessageStyle.Style
|
||||
content = &messageView{render: func(width int) []text.Span { return m.renderMessage(message, root, style, width, grouped) }}
|
||||
reply = &messageView{render: func(int) []text.Span {
|
||||
builder := new(markdown.Builder)
|
||||
m.drawReply(builder, message, style)
|
||||
return builder.Finish()
|
||||
}}
|
||||
return content, reply
|
||||
}
|
||||
|
||||
func (m Model) blocked(user discord.User) bool {
|
||||
return m.cfg.HideBlockedUsers && m.state.UserIsBlocked(user.ID)
|
||||
}
|
||||
|
||||
// messageView is the text of a message. It keeps the wrapped lines until the width changes, as the list measures every message when it draws.
|
||||
type messageView struct {
|
||||
render func(width int) []text.Span
|
||||
width int
|
||||
rendered bool
|
||||
lines [][]text.Span
|
||||
// messageText returns spans as text that fills its width, keeping its wrapped lines until the width changes, as the list measures every message when it draws.
|
||||
func messageText(spans []text.Span) tview.Widget {
|
||||
return text.Rich(spans...).Width(layout.Fill).Wrapping(text.WrappingWordOrGlyph).Paragraph(new(text.Paragraph))
|
||||
}
|
||||
|
||||
func (v *messageView) wrap(width int) {
|
||||
if !v.rendered || v.width != width {
|
||||
v.lines, v.width, v.rendered = text.Lines(v.render(width), width, text.WrappingWordOrGlyph), width, true
|
||||
}
|
||||
}
|
||||
|
||||
func (v *messageView) Size() (width, height layout.Length) { return layout.Fill, layout.Shrink }
|
||||
|
||||
func (v *messageView) Layout(limits layout.Limits) layout.Size {
|
||||
return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size {
|
||||
v.wrap(limits.Max.Width)
|
||||
return layout.Size{Height: len(v.lines)}
|
||||
})
|
||||
}
|
||||
|
||||
func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||
v.wrap(area.Width)
|
||||
for i, line := range v.lines[:min(len(v.lines), area.Height)] {
|
||||
text.Rich(line...).Wrapping(text.WrappingNone).Draw(screen, tview.Rectangle{X: area.X, Y: area.Y + i, Width: area.Width, Height: 1})
|
||||
}
|
||||
}
|
||||
|
||||
func (v *messageView) Handle(msg tview.Msg, _ tview.Rectangle) tview.Msg { return msg }
|
||||
|
||||
// dateSeparator is a line with the date in the middle, filled to the width it is drawn at.
|
||||
type dateSeparator struct {
|
||||
date, fill string
|
||||
style tview.Style
|
||||
}
|
||||
|
||||
// Size returns Fill and a height of one row.
|
||||
func (dateSeparator) Size() (width, height layout.Length) { return layout.Fill, layout.Fixed(1) }
|
||||
|
||||
// Layout returns a height of one row.
|
||||
func (dateSeparator) Layout(limits layout.Limits) layout.Size {
|
||||
return layout.Atomic(limits, layout.Fill, layout.Fixed(1))
|
||||
}
|
||||
|
||||
func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||
line := d.date
|
||||
label := " " + d.date + " "
|
||||
if labelWidth := utf8.RuneCountInString(label); area.Width > labelWidth {
|
||||
fill := area.Width - labelWidth
|
||||
line = strings.Repeat(d.fill, fill/2) + label + strings.Repeat(d.fill, fill-fill/2)
|
||||
}
|
||||
tview.Print(screen, line, area.X, area.Y, area.Width, tview.HorizontalAlignmentLeft, d.style)
|
||||
}
|
||||
|
||||
func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }
|
||||
|
||||
func (m Model) renderMessage(message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) []text.Span {
|
||||
func (m Model) renderMessage(message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) []text.Span {
|
||||
builder := new(markdown.Builder)
|
||||
m.writeMessage(builder, message, root, baseStyle, width, grouped)
|
||||
m.writeMessage(builder, message, root, baseStyle, grouped)
|
||||
// Without a header or content, the message starts with the line break that attachments and embeds start below.
|
||||
return trimLeadingLineBreaks(builder.Finish())
|
||||
}
|
||||
|
|
@ -114,7 +46,7 @@ func trimLeadingLineBreaks(spans []text.Span) []text.Span {
|
|||
return spans
|
||||
}
|
||||
|
||||
func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) {
|
||||
func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) {
|
||||
if m.blocked(message.Author) {
|
||||
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
|
||||
return
|
||||
|
|
@ -125,12 +57,12 @@ func (m Model) writeMessage(builder *markdown.Builder, message discord.Message,
|
|||
if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
|
||||
m.drawForwardedMessage(builder, message, baseStyle, grouped)
|
||||
} else {
|
||||
m.drawDefaultMessage(builder, message, root, baseStyle, width, grouped)
|
||||
m.drawDefaultMessage(builder, message, root, baseStyle, grouped)
|
||||
}
|
||||
case discord.GuildMemberJoinMessage:
|
||||
m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) })
|
||||
case discord.InlinedReplyMessage:
|
||||
m.drawDefaultMessage(builder, message, root, baseStyle, width, false)
|
||||
m.drawDefaultMessage(builder, message, root, baseStyle, false)
|
||||
case discord.ChannelPinnedMessage:
|
||||
drawPinnedMessage(builder, message, baseStyle)
|
||||
default:
|
||||
|
|
@ -338,14 +270,13 @@ func (m Model) drawSnapshotContent(builder *markdown.Builder, parent discord.Mes
|
|||
m.drawContent(builder, message, nil, baseStyle)
|
||||
}
|
||||
|
||||
func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) {
|
||||
func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) {
|
||||
content := func() { m.drawContent(builder, message, root, baseStyle) }
|
||||
if message.Content == "" {
|
||||
content = nil
|
||||
}
|
||||
|
||||
m.drawLayout(builder, message, baseStyle, grouped, content)
|
||||
m.drawEmbeds(builder, message, baseStyle, width, root)
|
||||
|
||||
attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style)
|
||||
// A voice message is shown as its player.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/voice"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/box"
|
||||
"github.com/ayn2op/tview/column"
|
||||
tviewimage "github.com/ayn2op/tview/image"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
|
|
@ -19,16 +20,21 @@ import (
|
|||
"github.com/ayn2op/tview/row"
|
||||
"github.com/ayn2op/tview/scrollbar"
|
||||
"github.com/ayn2op/tview/spacer"
|
||||
"github.com/ayn2op/tview/text"
|
||||
"github.com/ayn2op/tview/viewport"
|
||||
"github.com/yuin/goldmark/v2/ast"
|
||||
)
|
||||
|
||||
// placeholderTerminals are the terminals, by XTVERSION name, that draw kitty's Unicode placeholders.
|
||||
// WezTerm and Konsole support kitty graphics but not Unicode placeholders.
|
||||
var placeholderTerminals = []string{"kitty", "ghostty", "Rio"}
|
||||
|
||||
type messageItem struct {
|
||||
message discord.Message
|
||||
// text and reply are the message and what it replies to as wrapped, or nil until they are shown.
|
||||
text, reply *messageView
|
||||
// A separator shows the date of the message after it.
|
||||
separator bool
|
||||
// view is the message as drawn, keeping its wrapped text, or nil until it is shown and after what it shows changes.
|
||||
view tview.Widget
|
||||
// separator is the line with the date of the message after it, or nil for a message.
|
||||
separator tview.Widget
|
||||
// grouped reports whether the message is drawn without a header, under that of the message before it.
|
||||
grouped bool
|
||||
|
||||
|
|
@ -83,7 +89,9 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
|
|||
|
||||
// View shows the messages in a box titled with the channel and footed with footer, such as who is typing.
|
||||
func (m Model) View(focused bool, footer string) tview.Widget {
|
||||
return ui.Box(m.listView(focused), m.cfg, focused).Title(m.title()).Footer(footer)
|
||||
return ui.Box(m.listView(focused), m.cfg, focused).
|
||||
Title(ui.Label(m.title(), m.cfg.Theme.Title, focused)).
|
||||
Footer(ui.Label(footer, m.cfg.Theme.Footer, focused))
|
||||
}
|
||||
|
||||
// title returns the selected channel and its topic, or "Messages" when none is selected.
|
||||
|
|
@ -179,7 +187,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int {
|
|||
if m.channel == nil || m.channel.ID != channelID {
|
||||
return -1
|
||||
}
|
||||
return slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == id })
|
||||
return slices.IndexFunc(m.items, func(item messageItem) bool { return item.separator == nil && item.message.ID == id })
|
||||
}
|
||||
|
||||
func (m *Model) reset() {
|
||||
|
|
@ -207,7 +215,7 @@ func (m *Model) AddMessage(message discord.Message) tview.Cmd {
|
|||
|
||||
separator := m.shouldSeparate(previous, message)
|
||||
if separator {
|
||||
m.items = append(m.items, messageItem{separator: true})
|
||||
m.items = append(m.items, messageItem{separator: m.separatorView(message)})
|
||||
}
|
||||
|
||||
grouped := !separator && m.shouldGroup(previous, message)
|
||||
|
|
@ -216,7 +224,7 @@ func (m *Model) AddMessage(message discord.Message) tview.Cmd {
|
|||
}
|
||||
|
||||
func (m *Model) setMessage(index int, message discord.Message) {
|
||||
if index < 0 || index >= len(m.items) || m.items[index].separator {
|
||||
if index < 0 || index >= len(m.items) || m.items[index].separator != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +233,7 @@ func (m *Model) setMessage(index int, message discord.Message) {
|
|||
}
|
||||
|
||||
func (m *Model) deleteMessage(index int) {
|
||||
if index < 0 || index >= len(m.items) || m.items[index].separator {
|
||||
if index < 0 || index >= len(m.items) || m.items[index].separator != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -253,17 +261,28 @@ func (m Model) buildItem(index int) tview.Widget {
|
|||
return nil
|
||||
}
|
||||
item := &m.items[index]
|
||||
if item.separator {
|
||||
date := m.items[index+1].message.Timestamp.Time().In(time.Local).Format(m.cfg.DateSeparator.Format)
|
||||
return dateSeparator{date: date, fill: m.cfg.DateSeparator.Character, style: m.cfg.Theme.MessagesList.MessageStyle.Dim(true)}
|
||||
if item.separator != nil {
|
||||
return item.separator
|
||||
}
|
||||
|
||||
// A voice message shows its player, which changes as it plays.
|
||||
if item.view == nil || isVoiceMessage(item.message) {
|
||||
item.view = m.itemView(item)
|
||||
}
|
||||
return item.view
|
||||
}
|
||||
|
||||
// itemView returns the message of item with its header or avatar, embeds, previews, reactions and what it replies to.
|
||||
func (m Model) itemView(item *messageItem) tview.Widget {
|
||||
message := &item.message
|
||||
if item.text == nil {
|
||||
item.text, item.reply = m.messageViews(*message, m.contentRoot(item), item.grouped)
|
||||
}
|
||||
root := m.contentRoot(item)
|
||||
style := m.cfg.Theme.MessagesList.MessageStyle.Style
|
||||
|
||||
body := column.New(item.text).Height(layout.Shrink)
|
||||
// Attachments are named in the text, before the embeds.
|
||||
body := column.New(messageText(m.renderMessage(*message, root, style, item.grouped))).Height(layout.Shrink)
|
||||
for _, embed := range m.embedViews(*message, root) {
|
||||
body = body.Push(embed)
|
||||
}
|
||||
if isVoiceMessage(*message) {
|
||||
body = body.Push(m.voiceMessageView(message.Attachments[0]))
|
||||
}
|
||||
|
|
@ -290,7 +309,9 @@ func (m Model) buildItem(index int) tview.Widget {
|
|||
}
|
||||
|
||||
if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) {
|
||||
view = column.New(item.reply, view).Height(layout.Shrink)
|
||||
builder := new(markdown.Builder)
|
||||
m.drawReply(builder, *message, style)
|
||||
view = column.New(messageText(builder.Finish()), view).Height(layout.Shrink)
|
||||
}
|
||||
|
||||
return view
|
||||
|
|
@ -308,23 +329,39 @@ func (m Model) shouldGroup(previous *discord.Message, message discord.Message) b
|
|||
message.Timestamp.Time().Sub(previous.Timestamp.Time()) <= cfg.Interval
|
||||
}
|
||||
|
||||
// separatorDatePadding is on either side of the date on its separator line.
|
||||
const separatorDatePadding = " "
|
||||
|
||||
// separatorView returns the line with the date of message in the middle of it. It is made once, when the separator is added, as the list builds every item each time it is laid out.
|
||||
func (m Model) separatorView(message discord.Message) tview.Widget {
|
||||
date := message.Timestamp.Time().In(time.Local).Format(m.cfg.DateSeparator.Format)
|
||||
style := m.cfg.Theme.MessagesList.MessageStyle.Dim(true)
|
||||
return box.New(spacer.New()).
|
||||
Borders(tview.BordersTop).
|
||||
BorderSet(tview.BorderSet{Top: m.cfg.DateSeparator.Character}).
|
||||
BorderStyle(style).
|
||||
// The separator is kept, so its date is laid out once.
|
||||
Title(text.New(separatorDatePadding + date + separatorDatePadding).Style(style).Wrapping(text.WrappingNone).Paragraph(new(text.Paragraph))).
|
||||
Height(layout.Fixed(1))
|
||||
}
|
||||
|
||||
// rebuildItems replaces date separators and groups while retaining message views and selection.
|
||||
func (m *Model) rebuildItems() {
|
||||
items := make([]messageItem, 0, len(m.items))
|
||||
cursor, target := -1, -1
|
||||
var previous *discord.Message
|
||||
for i, item := range m.items {
|
||||
if item.separator {
|
||||
if item.separator != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
separator := m.shouldSeparate(previous, item.message)
|
||||
if separator {
|
||||
items = append(items, messageItem{separator: true})
|
||||
items = append(items, messageItem{separator: m.separatorView(item.message)})
|
||||
}
|
||||
// A message that joins or leaves a group is drawn again, with or without its header.
|
||||
if grouped := !separator && m.shouldGroup(previous, item.message); grouped != item.grouped {
|
||||
item.grouped, item.text = grouped, nil
|
||||
item.grouped, item.view = grouped, nil
|
||||
}
|
||||
if i == m.cursor {
|
||||
cursor = len(items)
|
||||
|
|
@ -354,7 +391,7 @@ func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool {
|
|||
// messageIndex finds the next message in direction (-1 or 1), excluding start.
|
||||
func (m Model) messageIndex(start, direction int) int {
|
||||
for i := start + direction; i >= 0 && i < len(m.items); i += direction {
|
||||
if !m.items[i].separator {
|
||||
if m.items[i].separator == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
|
@ -362,7 +399,7 @@ func (m Model) messageIndex(start, direction int) int {
|
|||
}
|
||||
|
||||
func (m *Model) onRowCursorChanged(index int) {
|
||||
if index < 0 || index >= len(m.items) || !m.items[index].separator {
|
||||
if index < 0 || index >= len(m.items) || m.items[index].separator == nil {
|
||||
return
|
||||
}
|
||||
target := m.messageIndex(index, -1)
|
||||
|
|
@ -374,7 +411,7 @@ func (m *Model) onRowCursorChanged(index int) {
|
|||
|
||||
func (m Model) selectedMessage() (*discord.Message, bool) {
|
||||
cursor := m.cursor
|
||||
if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator {
|
||||
if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
|
@ -472,17 +509,18 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
m.images[msg.proxy] = preview
|
||||
// An emoji is drawn within the text of the messages that use it, so the text is rendered again.
|
||||
for i := range m.items {
|
||||
m.items[i].text = nil
|
||||
m.items[i].view = nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID })
|
||||
index := slices.IndexFunc(m.items, func(item messageItem) bool { return item.separator == nil && item.message.ID == msg.messageID })
|
||||
if index < 0 || m.items[index].previews == nil {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.items[index].previews[msg.proxy] = preview
|
||||
m.items[index].view = nil
|
||||
return m, cmd
|
||||
case playMsg:
|
||||
return m, m.play(discord.Attachment(msg))
|
||||
|
|
@ -523,8 +561,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
case "halfblocks":
|
||||
m.kitty = false
|
||||
default:
|
||||
// WezTerm and Konsole support kitty graphics but not Unicode placeholders.
|
||||
m.kitty = msg.Name == "kitty" || msg.Name == "ghostty"
|
||||
m.kitty = slices.Contains(placeholderTerminals, msg.Name)
|
||||
}
|
||||
case scrollMsg:
|
||||
m.scrollState = viewport.ScrollState(msg)
|
||||
|
|
@ -582,7 +619,7 @@ func (m *Model) selectReply() {
|
|||
|
||||
if ref := messages[cursor].message.ReferencedMessage; ref != nil {
|
||||
refIdx := slices.IndexFunc(messages, func(m messageItem) bool {
|
||||
return !m.separator && m.message.ID == ref.ID
|
||||
return m.separator == nil && m.message.ID == ref.ID
|
||||
})
|
||||
if refIdx != -1 {
|
||||
m.setCursor(refIdx)
|
||||
|
|
@ -598,7 +635,7 @@ func (m *Model) SelectLastOwn() (discord.Message, bool) {
|
|||
}
|
||||
for i, item := range slices.Backward(m.items) {
|
||||
message := item.message
|
||||
if item.separator || message.ChannelID != channel.ID || !ui.IsMe(m.state, message.Author.ID) ||
|
||||
if item.separator != nil || message.ChannelID != channel.ID || !ui.IsMe(m.state, message.Author.ID) ||
|
||||
!message.ID.IsValid() || message.Content == "" || len(message.MessageSnapshots) > 0 ||
|
||||
(message.Type != discord.DefaultMessage && message.Type != discord.InlinedReplyMessage) {
|
||||
continue
|
||||
|
|
@ -616,7 +653,7 @@ func (m Model) canDeleteMessage(message discord.Message) bool {
|
|||
|
||||
func (m Model) InvalidateRendered() {
|
||||
for i := range m.items {
|
||||
m.items[i].text, m.items[i].root = nil, nil
|
||||
m.items[i].view, m.items[i].root = nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,15 +46,11 @@ func TestModelBuildItem(t *testing.T) {
|
|||
if ml.items[ml.messageIndex(-1, 1)].message.ID != 2 {
|
||||
t.Fatal("messages are not oldest first")
|
||||
}
|
||||
// text returns the wrapped text kept for the newest message once it is shown.
|
||||
text := func() *messageView {
|
||||
index := ml.messageIndex(len(ml.items), -1)
|
||||
ml.buildItem(index)
|
||||
return ml.items[index].text
|
||||
}
|
||||
view := text()
|
||||
if text() != view {
|
||||
t.Fatal("message text was not reused")
|
||||
// kept reports whether the newest message keeps the view it was shown with.
|
||||
kept := func() bool { return ml.items[ml.messageIndex(len(ml.items), -1)].view != nil }
|
||||
ml.buildItem(ml.messageIndex(len(ml.items), -1))
|
||||
if !kept() {
|
||||
t.Fatal("message view was not kept")
|
||||
}
|
||||
if ml.selectTop() == nil || ml.selectTop() != nil || ml.selectUp() != nil {
|
||||
t.Fatal("older messages were not fetched once on reaching the top")
|
||||
|
|
@ -66,21 +62,21 @@ func TestModelBuildItem(t *testing.T) {
|
|||
ml.selectBottom()
|
||||
*ml, _ = ml.Update(olderMessagesLoadedMsg{channelID: 1, older: []discord.Message{{ID: 1}}})
|
||||
selected, ok := ml.selectedMessage()
|
||||
if !ok || selected.ID != 3 || text() != view {
|
||||
t.Fatal("prepend lost selection or cached text")
|
||||
if !ok || selected.ID != 3 || !kept() {
|
||||
t.Fatal("prepend lost selection or cached view")
|
||||
}
|
||||
ml.deleteMessage(ml.messageIndex(-1, 1))
|
||||
if text() != view {
|
||||
t.Fatal("deletion discarded another message's text")
|
||||
if !kept() {
|
||||
t.Fatal("deletion discarded another message's view")
|
||||
}
|
||||
ml.setMessage(ml.messageIndex(len(ml.items), -1), discord.Message{ID: 3, Content: "edited"})
|
||||
edited := text()
|
||||
if edited == view {
|
||||
t.Fatal("edit reused stale text")
|
||||
if kept() {
|
||||
t.Fatal("edit reused stale view")
|
||||
}
|
||||
ml.buildItem(ml.messageIndex(len(ml.items), -1))
|
||||
ml.InvalidateRendered()
|
||||
if text() == edited {
|
||||
t.Fatal("global invalidation reused stale text")
|
||||
if kept() {
|
||||
t.Fatal("global invalidation reused stale view")
|
||||
}
|
||||
ml.reset()
|
||||
if len(ml.items) != 0 {
|
||||
|
|
@ -113,8 +109,8 @@ func TestModelRebuildItems(t *testing.T) {
|
|||
t.Helper()
|
||||
var got []discord.MessageID
|
||||
for i, item := range ml.items {
|
||||
if item.separator {
|
||||
if !tt.separators || i+1 == len(ml.items) || ml.items[i+1].separator {
|
||||
if item.separator != nil {
|
||||
if !tt.separators || i+1 == len(ml.items) || ml.items[i+1].separator != nil {
|
||||
t.Fatalf("item %d: orphaned separator", i)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -223,7 +219,7 @@ func TestModelDrawLayout(t *testing.T) {
|
|||
cfg.UI.MessagesList.Compact = compact
|
||||
|
||||
var got strings.Builder
|
||||
for _, span := range ml.renderMessage(message, nil, tview.Style{}, 80, false) {
|
||||
for _, span := range ml.renderMessage(message, nil, tview.Style{}, false) {
|
||||
got.WriteString(span.Text)
|
||||
}
|
||||
|
||||
|
|
@ -272,13 +268,13 @@ func TestModelShouldGroup(t *testing.T) {
|
|||
if !ml.items[newest].grouped || ml.items[ml.messageIndex(-1, 1)].grouped {
|
||||
t.Fatal("only the first message has a header")
|
||||
}
|
||||
if got := ml.renderMessage(ml.items[newest].message, nil, tview.Style{}, 80, true); len(got) != 1 || got[0].Text != "hi" {
|
||||
if got := ml.renderMessage(ml.items[newest].message, nil, tview.Style{}, true); len(got) != 1 || got[0].Text != "hi" {
|
||||
t.Fatalf("grouped message = %v, want only its content", got)
|
||||
}
|
||||
second := ml.messageIndex(newest, -1)
|
||||
ml.buildItem(second)
|
||||
ml.deleteMessage(ml.messageIndex(-1, 1))
|
||||
if first := ml.messageIndex(-1, 1); ml.items[first].grouped || ml.items[first].text != nil {
|
||||
if first := ml.messageIndex(-1, 1); ml.items[first].grouped || ml.items[first].view != nil {
|
||||
t.Fatal("deleting the first message did not give the next its header")
|
||||
}
|
||||
if ml.AddMessage(message("a", 3*time.Minute, discord.DefaultMessage)); !ml.items[len(ml.items)-1].grouped {
|
||||
|
|
@ -286,7 +282,7 @@ func TestModelShouldGroup(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessageViewLayout(t *testing.T) {
|
||||
func TestModelBuildItemLayout(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
cfg.UI.MessagesList.Compact = true
|
||||
ml := newTestModel(t, cfg)
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ func (m Model) loadPreviews() tview.Cmd {
|
|||
var cmds []tview.Cmd
|
||||
for i := range m.items {
|
||||
item := &m.items[i]
|
||||
if item.separator {
|
||||
if item.separator != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ func (m Model) voiceMessageView(a discord.Attachment) tview.Widget {
|
|||
return rail[cell], style.Dim(true)
|
||||
})
|
||||
|
||||
play := button.New().Label(symbol).OnClick(playMsg(a))
|
||||
play := button.New(text.New(symbol)).OnClick(playMsg(a))
|
||||
|
||||
return row.New(play, waveform, text.New(label)).Spacing(1).Height(layout.Fixed(1))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/composer"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
||||
|
|
@ -304,7 +305,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
ui.DialogButton{Label: "Yes", Cmd: tview.Sequence(closeState(m.state), logout())},
|
||||
)
|
||||
}
|
||||
case composer.TabSuggestMsg, mentionslist.Msg:
|
||||
case composer.TabSuggestMsg, mentionslist.Msg, emojislist.Msg:
|
||||
return m, m.updatePane(paneComposer, msg)
|
||||
case paneMsg:
|
||||
if msg.focus {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ var (
|
|||
nextTab = keybind.NewSingleKeybind("ctrl+l", "next tab")
|
||||
)
|
||||
|
||||
func tabAction(key tview.KeyMsg) tabs.Action {
|
||||
func tabsKeybind(key tview.KeyMsg) tabs.Action {
|
||||
switch {
|
||||
case keybind.Matches(key, previousTab):
|
||||
return tabs.ActionPrevious
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package login
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/tview/layout"
|
||||
"github.com/ayn2op/tview/tabs"
|
||||
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
|
|
@ -63,7 +64,7 @@ func (m Model) View() tview.Widget {
|
|||
}
|
||||
t := m.tabs().
|
||||
Active(int(m.active)).
|
||||
Keybind(tabAction).
|
||||
Keybind(tabsKeybind).
|
||||
OnSelect(func(i int) tview.Msg { return selectTabMsg(i) })
|
||||
return ui.Box(column.New(t, content), m.cfg, false)
|
||||
}
|
||||
|
|
@ -72,6 +73,7 @@ func (m Model) View() tview.Widget {
|
|||
func (m Model) tabs() tabs.Widget {
|
||||
cfg := m.cfg.UI.Tabs
|
||||
return tabs.New(m.password.Label(), m.qr.Label(), m.token.Label()).
|
||||
Width(layout.Fill).
|
||||
Alignment(cfg.Alignment.HorizontalAlignment).
|
||||
Wrap(cfg.Wrap).
|
||||
Separator(cfg.Separator).
|
||||
|
|
|
|||
|
|
@ -73,14 +73,13 @@ func (m Model) View() tview.Widget {
|
|||
Focused(m.focus == focusPassword).
|
||||
OnInput(func(text string, cursor int) tview.Msg { return passwordMsg{text, cursor} }).
|
||||
OnSubmit(focusMsg(focusButton))
|
||||
submit := button.New().
|
||||
Label("Login").
|
||||
submit := button.New(text.New("Login")).
|
||||
Width(layout.Fixed(len("Login") + 4)).
|
||||
Focused(m.focus == focusButton).
|
||||
OnClick(submitMsg{})
|
||||
return column.New(
|
||||
row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(layout.Shrink),
|
||||
row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(layout.Shrink),
|
||||
row.New(text.New("Login").Width(labelWidth), login).Height(layout.Shrink),
|
||||
row.New(text.New("Password").Width(labelWidth), password).Height(layout.Shrink),
|
||||
submit,
|
||||
).Spacing(1).AlignX(tview.HorizontalAlignmentCenter)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,37 @@ package qr
|
|||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"strings"
|
||||
stdimage "image"
|
||||
"time"
|
||||
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/box"
|
||||
"github.com/ayn2op/tview/center"
|
||||
"github.com/ayn2op/tview/column"
|
||||
"github.com/ayn2op/tview/image"
|
||||
"github.com/ayn2op/tview/layout"
|
||||
"github.com/ayn2op/tview/text"
|
||||
"github.com/ayn2op/tview/viewport"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/skip2/go-qrcode"
|
||||
"github.com/gdamore/tcell/v3/color"
|
||||
)
|
||||
|
||||
const (
|
||||
// quietZoneColor is the white of the margin around the code, as go-qrcode draws its light modules.
|
||||
quietZoneColor = color.IsRGB | color.IsValid | 0xffffff
|
||||
// quietZoneRows and quietZoneColumns make a margin of two modules on every side, as a row holds two.
|
||||
quietZoneRows, quietZoneColumns = 1, 2
|
||||
// statusSpacing is the blank rows between the code and the status.
|
||||
statusSpacing = 1
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
scrollState viewport.ScrollState
|
||||
|
||||
conn *gatewayConn
|
||||
heartbeatInterval time.Duration
|
||||
privateKey *rsa.PrivateKey
|
||||
fingerprint string
|
||||
|
||||
qrCode *qrcode.QRCode
|
||||
// qrCode is the code at one pixel per module, made once as it arrives.
|
||||
qrCode stdimage.Image
|
||||
status string
|
||||
}
|
||||
|
||||
|
|
@ -54,9 +64,6 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
return m, closeConn(m.conn)
|
||||
}
|
||||
return m, nil
|
||||
case scrollMsg:
|
||||
m.scrollState = viewport.ScrollState(msg)
|
||||
return m, nil
|
||||
|
||||
case connCreateMsg:
|
||||
m.conn = msg.conn
|
||||
|
|
@ -113,53 +120,21 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
// halfBlock packs a vertical pair of QR pixels into one glyph, fitting two bitmap rows into a single terminal row.
|
||||
func halfBlock(top, bottom bool) rune {
|
||||
switch [2]bool{top, bottom} {
|
||||
case [2]bool{true, true}:
|
||||
return '█'
|
||||
case [2]bool{true, false}:
|
||||
return '▀'
|
||||
case [2]bool{false, true}:
|
||||
return '▄'
|
||||
default:
|
||||
return ' '
|
||||
}
|
||||
}
|
||||
|
||||
// View centers the code, which scrolls when it is taller than the tab.
|
||||
// View centers the code above the status.
|
||||
func (m Model) View() tview.Widget {
|
||||
code := m.code()
|
||||
return center.New(
|
||||
viewport.New(text.New(code).Wrapping(text.WrappingNone).AlignX(tview.HorizontalAlignmentCenter), m.scrollState).
|
||||
Height(layout.Shrink).
|
||||
Focused(true).
|
||||
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }),
|
||||
)
|
||||
}
|
||||
|
||||
// code returns the QR code above the status.
|
||||
func (m Model) code() string {
|
||||
var out strings.Builder
|
||||
var qrCode tview.Widget
|
||||
if m.qrCode != nil {
|
||||
bitmap := m.qrCode.Bitmap()
|
||||
for y := 0; y < len(bitmap); y += 2 {
|
||||
if y > 0 {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
for x := range bitmap[y] {
|
||||
top := bitmap[y][x]
|
||||
bottom := y+1 < len(bitmap) && bitmap[y+1][x]
|
||||
out.WriteRune(halfBlock(top, bottom))
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.status != "" {
|
||||
if out.Len() > 0 {
|
||||
out.WriteString("\n\n")
|
||||
}
|
||||
out.WriteString(m.status)
|
||||
// The white margin around the code, two modules wide, is what scanners find it by against a dark terminal.
|
||||
qrCode = box.New(image.New(m.qrCode)).
|
||||
Background(quietZoneColor).
|
||||
Padding(quietZoneRows, quietZoneRows, quietZoneColumns, quietZoneColumns).
|
||||
Width(layout.Shrink).
|
||||
Height(layout.Shrink)
|
||||
}
|
||||
|
||||
return out.String()
|
||||
return center.New(column.New(qrCode, text.New(m.status)).
|
||||
Spacing(statusSpacing).
|
||||
AlignX(tview.HorizontalAlignmentCenter).
|
||||
Width(layout.Shrink).
|
||||
Height(layout.Shrink))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func TestModelView(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.qrCode = code
|
||||
m.qrCode = code.Image(-1)
|
||||
m.status = "status"
|
||||
m.View().Draw(screen, tview.Rectangle{Width: width, Height: height})
|
||||
|
||||
|
|
@ -42,5 +42,12 @@ func TestModelView(t *testing.T) {
|
|||
if !strings.Contains(drawn.String(), "status") || !strings.ContainsAny(drawn.String(), "█▀▄") {
|
||||
t.Fatalf("missing content:\n%s", drawn.String())
|
||||
}
|
||||
|
||||
// The status is centered across the width, under the centered code.
|
||||
for line := range strings.Lines(drawn.String()) {
|
||||
if start := strings.Index(line, "status"); start >= 0 && (start < width/2-4 || start > width/2-2) {
|
||||
t.Fatalf("status starts at %d, want it centered:\n%s", start, drawn.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"encoding/base64"
|
||||
json "encoding/json/v2"
|
||||
"errors"
|
||||
"image"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
|
|
@ -18,7 +19,6 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/gateway"
|
||||
"github.com/ayn2op/discordo/internal/http"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/viewport"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
|
@ -231,7 +231,7 @@ func sendNonceProof(conn *gatewayConn, privateKey *rsa.PrivateKey, encryptedNonc
|
|||
}
|
||||
|
||||
type qrCodeMsg struct {
|
||||
qrCode *qrcode.QRCode
|
||||
qrCode image.Image
|
||||
}
|
||||
|
||||
func generateQRCode(fingerprint string) tview.Cmd {
|
||||
|
|
@ -242,7 +242,7 @@ func generateQRCode(fingerprint string) tview.Cmd {
|
|||
return err
|
||||
}
|
||||
qrCode.DisableBorder = true
|
||||
return qrCodeMsg{qrCode: qrCode}
|
||||
return qrCodeMsg{qrCode: qrCode.Image(-1)}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,8 +301,5 @@ func exchangeTicket(fingerprint string, privateKey *rsa.PrivateKey, ticket strin
|
|||
}
|
||||
}
|
||||
|
||||
// scrollMsg scrolls the code.
|
||||
type scrollMsg viewport.ScrollState
|
||||
|
||||
// startMsg starts connecting to the Remote Auth Gateway.
|
||||
type startMsg struct{}
|
||||
|
|
|
|||
|
|
@ -54,13 +54,12 @@ func (m Model) View() tview.Widget {
|
|||
Focused(!m.buttonFocused).
|
||||
OnInput(func(text string, cursor int) tview.Msg { return tokenMsg{text, cursor} }).
|
||||
OnSubmit(focusButtonMsg{})
|
||||
submit := button.New().
|
||||
Label("Login").
|
||||
submit := button.New(text.New("Login")).
|
||||
Width(layout.Fixed(len("Login") + 4)).
|
||||
Focused(m.buttonFocused).
|
||||
OnClick(submitMsg{})
|
||||
return column.New(
|
||||
row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Shrink),
|
||||
row.New(text.New("Token").Width(layout.Fixed(len("Token")+1)), input).Height(layout.Shrink),
|
||||
submit,
|
||||
).Spacing(1).AlignX(tview.HorizontalAlignmentCenter)
|
||||
}
|
||||
|
|
|
|||
20
internal/ui/picker/keymap.go
Normal file
20
internal/ui/picker/keymap.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package picker
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/ayn2op/tview/picker"
|
||||
)
|
||||
|
||||
func pickerKeybind(kbs config.PickerKeybinds) func(tview.KeyMsg) picker.Action {
|
||||
return func(key tview.KeyMsg) picker.Action {
|
||||
switch {
|
||||
case keybind.Matches(key, kbs.Select.Keybind):
|
||||
return picker.ActionSelect
|
||||
case keybind.Matches(key, kbs.Cancel.Keybind):
|
||||
return picker.ActionCancel
|
||||
}
|
||||
return picker.ActionNone
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/ui"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/ayn2op/tview/picker"
|
||||
"github.com/ayn2op/tview/scrollbar"
|
||||
)
|
||||
|
|
@ -33,12 +32,12 @@ func (m Model) View(onSelect func(picker.Item) tview.Msg, onCancel tview.Msg) tv
|
|||
ThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style)
|
||||
p := picker.New(m.items, &m.searchState).
|
||||
ScrollBar(bar, cfg.UI.ScrollBar.Visibility.ScrollBarVisibility).
|
||||
Keybind(bindKeys(cfg.Keybinds.Picker)).
|
||||
Keybind(pickerKeybind(cfg.Keybinds.Picker)).
|
||||
ListKeybind(ui.SelectionKeybind(cfg.Keybinds.Picker.SelectionKeybinds)).
|
||||
OnChange(func(s *picker.SearchState) tview.Msg { return changeMsg(*s) }).
|
||||
OnSelect(onSelect).
|
||||
OnCancel(onCancel)
|
||||
return ui.Box(p, cfg, true).Title(m.title)
|
||||
return ui.Box(p, cfg, true).Title(ui.Label(m.title, cfg.Theme.Title, true))
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||
|
|
@ -56,15 +55,3 @@ func (m *Model) SetItems(items picker.Items) {
|
|||
|
||||
// Reset clears the query.
|
||||
func (m *Model) Reset() { m.searchState.Reset() }
|
||||
|
||||
func bindKeys(kbs config.PickerKeybinds) func(tview.KeyMsg) picker.Action {
|
||||
return func(key tview.KeyMsg) picker.Action {
|
||||
switch {
|
||||
case keybind.Matches(key, kbs.Select.Keybind):
|
||||
return picker.ActionSelect
|
||||
case keybind.Matches(key, kbs.Cancel.Keybind):
|
||||
return picker.ActionCancel
|
||||
}
|
||||
return picker.ActionNone
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ package tree
|
|||
|
||||
import (
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/ui"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/ayn2op/tview/tree"
|
||||
"github.com/ayn2op/tview/viewport"
|
||||
)
|
||||
|
||||
// SelectHelp returns the up and down keybinds that move the selection away from current.
|
||||
|
|
@ -32,3 +35,36 @@ func canSelectDown(root, current *tree.Node) bool {
|
|||
}
|
||||
return last != root && current != last
|
||||
}
|
||||
|
||||
func treeKeybind(kbs config.TreeKeybinds) func(tview.KeyMsg) tree.Action {
|
||||
return func(key tview.KeyMsg) tree.Action {
|
||||
switch {
|
||||
case keybind.Matches(key, kbs.SelectUp.Keybind):
|
||||
return tree.ActionUp
|
||||
case keybind.Matches(key, kbs.SelectDown.Keybind):
|
||||
return tree.ActionDown
|
||||
case keybind.Matches(key, kbs.SelectTop.Keybind):
|
||||
return tree.ActionTop
|
||||
case keybind.Matches(key, kbs.SelectBottom.Keybind):
|
||||
return tree.ActionBottom
|
||||
case keybind.Matches(key, kbs.MoveToParentNode.Keybind):
|
||||
return tree.ActionMoveToParent
|
||||
case keybind.Matches(key, kbs.SelectCurrent.Keybind):
|
||||
return tree.ActionSelect
|
||||
}
|
||||
return tree.ActionNone
|
||||
}
|
||||
}
|
||||
|
||||
func scrollKeybind(kbs config.TreeKeybinds) func(tview.KeyMsg) viewport.Action {
|
||||
scroll := ui.ScrollKeybind(kbs.ScrollKeybinds)
|
||||
return func(key tview.KeyMsg) viewport.Action {
|
||||
switch {
|
||||
case keybind.Matches(key, kbs.ScrollLeft.Keybind):
|
||||
return viewport.ActionLeft
|
||||
case keybind.Matches(key, kbs.ScrollRight.Keybind):
|
||||
return viewport.ActionRight
|
||||
}
|
||||
return scroll(key)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/ui"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/box"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/ayn2op/tview/layout"
|
||||
"github.com/ayn2op/tview/tree"
|
||||
"github.com/ayn2op/tview/viewport"
|
||||
|
|
@ -44,7 +43,7 @@ func New(
|
|||
Graphics(graphics).
|
||||
GraphicsSet(set).
|
||||
GraphicsStyle(tcell.StyleDefault.Foreground(tcell.GetColor(theme.GraphicsColor))).
|
||||
Keybind(bindKeys(kbs)).
|
||||
Keybind(treeKeybind(kbs)).
|
||||
Focused(focused).
|
||||
OnChange(onChange).
|
||||
OnSelect(onSelect)
|
||||
|
|
@ -53,41 +52,8 @@ func New(
|
|||
ContentWidth(t.RowsWidth).
|
||||
OnChildMsg(func(s *viewport.ScrollState, msg tview.Msg) tview.Msg { return ScrollMsg{ScrollState: *s, Msg: msg} }).
|
||||
Axes(layout.Axes{Width: true, Height: true}).
|
||||
Keybind(bindScrollKeys(kbs)).
|
||||
Keybind(scrollKeybind(kbs)).
|
||||
Focused(focused).
|
||||
OnChange(func(s *viewport.ScrollState) tview.Msg { return ScrollMsg{ScrollState: *s} })
|
||||
return ui.Box(v, cfg, focused)
|
||||
}
|
||||
|
||||
func bindKeys(kbs config.TreeKeybinds) func(tview.KeyMsg) tree.Action {
|
||||
return func(key tview.KeyMsg) tree.Action {
|
||||
switch {
|
||||
case keybind.Matches(key, kbs.SelectUp.Keybind):
|
||||
return tree.ActionUp
|
||||
case keybind.Matches(key, kbs.SelectDown.Keybind):
|
||||
return tree.ActionDown
|
||||
case keybind.Matches(key, kbs.SelectTop.Keybind):
|
||||
return tree.ActionTop
|
||||
case keybind.Matches(key, kbs.SelectBottom.Keybind):
|
||||
return tree.ActionBottom
|
||||
case keybind.Matches(key, kbs.MoveToParentNode.Keybind):
|
||||
return tree.ActionMoveToParent
|
||||
case keybind.Matches(key, kbs.SelectCurrent.Keybind):
|
||||
return tree.ActionSelect
|
||||
}
|
||||
return tree.ActionNone
|
||||
}
|
||||
}
|
||||
|
||||
func bindScrollKeys(kbs config.TreeKeybinds) func(tview.KeyMsg) viewport.Action {
|
||||
scroll := ui.ScrollKeybind(kbs.ScrollKeybinds)
|
||||
return func(key tview.KeyMsg) viewport.Action {
|
||||
switch {
|
||||
case keybind.Matches(key, kbs.ScrollLeft.Keybind):
|
||||
return viewport.ActionLeft
|
||||
case keybind.Matches(key, kbs.ScrollRight.Keybind):
|
||||
return viewport.ActionRight
|
||||
}
|
||||
return scroll(key)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/box"
|
||||
"github.com/ayn2op/tview/text"
|
||||
)
|
||||
|
||||
// Box returns child in a box laid out and styled by cfg, highlighted while focused.
|
||||
|
|
@ -24,15 +25,22 @@ func Box(child tview.Widget, cfg *config.Config, focused bool) box.Widget {
|
|||
b = b.Borders(tview.BordersAll)
|
||||
}
|
||||
if focused {
|
||||
return b.BorderStyle(theme.Border.ActiveStyle.Style).
|
||||
BorderSet(ui.Border.ActiveSet.BorderSet).
|
||||
TitleStyle(theme.Title.ActiveStyle.Style).
|
||||
FooterStyle(theme.Footer.ActiveStyle.Style)
|
||||
return b.BorderStyle(theme.Border.ActiveStyle.Style).BorderSet(ui.Border.ActiveSet.BorderSet)
|
||||
}
|
||||
return b.BorderStyle(theme.Border.NormalStyle.Style).
|
||||
BorderSet(ui.Border.NormalSet.BorderSet).
|
||||
TitleStyle(theme.Title.NormalStyle.Style).
|
||||
FooterStyle(theme.Footer.NormalStyle.Style)
|
||||
return b.BorderStyle(theme.Border.NormalStyle.Style).BorderSet(ui.Border.NormalSet.BorderSet)
|
||||
}
|
||||
|
||||
// Label returns s on one line in the style of theme for whether its pane is focused, as the title or footer of a box, or nil if s is empty, so the box keeps no row for it.
|
||||
func Label(s string, theme config.StyleThemeConfig, focused bool) tview.Widget {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
style := theme.NormalStyle.Style
|
||||
if focused {
|
||||
style = theme.ActiveStyle.Style
|
||||
}
|
||||
return text.New(s).Style(style).Wrapping(text.WrappingNone)
|
||||
}
|
||||
|
||||
// ChannelToString returns how channel is shown in the UI: an icon for its type and its name, or the recipients' names for a DM.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue