forked from Mirrors/gomuks
Compare commits
16 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7bc4075d86 | ||
![]() |
054d0e7c11 | ||
![]() |
836869a692 | ||
![]() |
bef2e4e582 | ||
![]() |
16a1ef30b1 | ||
![]() |
18c5a8b231 | ||
![]() |
3990427c97 | ||
![]() |
13f0e1b0f7 | ||
![]() |
7aaf59f6ca | ||
![]() |
aa7ca67976 | ||
![]() |
dd3245eb90 | ||
![]() |
b7d9a914e1 | ||
![]() |
e5cdd95733 | ||
![]() |
cb7d053d9e | ||
![]() |
6a96f3f800 | ||
![]() |
57b9847637 |
53 changed files with 10123 additions and 2 deletions
|
@ -25,12 +25,14 @@ import (
|
||||||
|
|
||||||
"go.mau.fi/gomuks/pkg/gomuks"
|
"go.mau.fi/gomuks/pkg/gomuks"
|
||||||
"go.mau.fi/gomuks/pkg/hicli"
|
"go.mau.fi/gomuks/pkg/hicli"
|
||||||
|
"go.mau.fi/gomuks/tui"
|
||||||
"go.mau.fi/gomuks/version"
|
"go.mau.fi/gomuks/version"
|
||||||
"go.mau.fi/gomuks/web"
|
"go.mau.fi/gomuks/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
var wantHelp, _ = flag.MakeHelpFlag()
|
var wantHelp, _ = flag.MakeHelpFlag()
|
||||||
var wantVersion = flag.MakeFull("v", "version", "View gomuks version and quit.", "false").Bool()
|
var wantVersion = flag.MakeFull("v", "version", "View gomuks version and quit.", "false").Bool()
|
||||||
|
var wantTUI = flag.MakeFull("t", "tui", "Open gomuks terminal", "false").Bool()
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
hicli.InitialDeviceDisplayName = "gomuks web"
|
hicli.InitialDeviceDisplayName = "gomuks web"
|
||||||
|
@ -59,5 +61,8 @@ func main() {
|
||||||
gmx.LinkifiedVersion = version.LinkifiedVersion
|
gmx.LinkifiedVersion = version.LinkifiedVersion
|
||||||
gmx.BuildTime = version.ParsedBuildTime
|
gmx.BuildTime = version.ParsedBuildTime
|
||||||
gmx.FrontendFS = web.Frontend
|
gmx.FrontendFS = web.Frontend
|
||||||
|
if *wantTUI {
|
||||||
|
gmx.TUI = tui.New(gmx)
|
||||||
|
}
|
||||||
gmx.Run()
|
gmx.Run()
|
||||||
}
|
}
|
||||||
|
|
6
go.mod
6
go.mod
|
@ -11,6 +11,7 @@ require (
|
||||||
github.com/coder/websocket v1.8.12
|
github.com/coder/websocket v1.8.12
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8
|
github.com/gabriel-vasile/mimetype v1.4.8
|
||||||
|
github.com/gdamore/tcell/v2 v2.7.4
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
github.com/rivo/uniseg v0.4.7
|
github.com/rivo/uniseg v0.4.7
|
||||||
|
@ -18,6 +19,7 @@ require (
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
github.com/tidwall/sjson v1.2.5
|
github.com/tidwall/sjson v1.2.5
|
||||||
github.com/yuin/goldmark v1.7.8
|
github.com/yuin/goldmark v1.7.8
|
||||||
|
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5
|
||||||
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95
|
||||||
go.mau.fi/webp v0.2.0
|
go.mau.fi/webp v0.2.0
|
||||||
go.mau.fi/zeroconfig v0.1.3
|
go.mau.fi/zeroconfig v0.1.3
|
||||||
|
@ -35,13 +37,17 @@ require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
|
github.com/gdamore/encoding v1.0.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a // indirect
|
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a // indirect
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/zyedidia/clipboard v1.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect
|
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
golang.org/x/term v0.29.0 // indirect
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
47
go.sum
47
go.sum
|
@ -28,6 +28,10 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||||
|
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||||
|
github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
|
||||||
|
github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
@ -40,6 +44,9 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a h1:ckxP/kGzsxvxXo8jO6E/0QJ8MMmwI7IRj4Fys9QbAZA=
|
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a h1:ckxP/kGzsxvxXo8jO6E/0QJ8MMmwI7IRj4Fys9QbAZA=
|
||||||
|
@ -47,6 +54,8 @@ github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a/go.mod h1:pxMtw7c
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
@ -66,14 +75,21 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljULEo=
|
||||||
|
github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA=
|
||||||
|
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5 h1:apKftqeRRyj/Vpd5s81fNhS8UErwgfs07KG3NSHB/4Q=
|
||||||
|
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5/go.mod h1:G0Qkfwt84f+5tagHsaRdiTuUFeTlIZu61MN/JL9D8Qo=
|
||||||
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95 h1:5EfVWWjU2Hte9uE6B/hBgvjnVfBx/7SYDZBnsuo+EBs=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95 h1:5EfVWWjU2Hte9uE6B/hBgvjnVfBx/7SYDZBnsuo+EBs=
|
||||||
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
|
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
|
||||||
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
||||||
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
||||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
||||||
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA=
|
golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA=
|
||||||
golang.org/x/crypto v0.34.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
golang.org/x/crypto v0.34.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||||
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg=
|
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg=
|
||||||
|
@ -81,17 +97,48 @@ golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLo
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||||
|
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
|
|
@ -111,7 +111,7 @@ func (gmx *Gomuks) LoadConfig() error {
|
||||||
gmx.Config.Web.TokenKey = random.String(64)
|
gmx.Config.Web.TokenKey = random.String(64)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if !gmx.DisableAuth && (gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "") {
|
if !gmx.DisableAuth && gmx.TUI == nil && (gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "") {
|
||||||
fmt.Println("Please create a username and password for authenticating the web app")
|
fmt.Println("Please create a username and password for authenticating the web app")
|
||||||
gmx.Config.Web.Username, err = readline.Line("Username: ")
|
gmx.Config.Web.Username, err = readline.Line("Username: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -25,6 +25,7 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
@ -35,6 +36,7 @@ import (
|
||||||
"go.mau.fi/util/exerrors"
|
"go.mau.fi/util/exerrors"
|
||||||
"go.mau.fi/util/exzerolog"
|
"go.mau.fi/util/exzerolog"
|
||||||
"go.mau.fi/util/ptr"
|
"go.mau.fi/util/ptr"
|
||||||
|
"go.mau.fi/zeroconfig"
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
|
|
||||||
"go.mau.fi/gomuks/pkg/hicli"
|
"go.mau.fi/gomuks/pkg/hicli"
|
||||||
|
@ -67,6 +69,11 @@ type Gomuks struct {
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
|
||||||
EventBuffer *EventBuffer
|
EventBuffer *EventBuffer
|
||||||
|
TUI tui
|
||||||
|
}
|
||||||
|
|
||||||
|
type tui interface {
|
||||||
|
Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGomuks() *Gomuks {
|
func NewGomuks() *Gomuks {
|
||||||
|
@ -148,6 +155,12 @@ func (gmx *Gomuks) InitDirectories() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gmx *Gomuks) SetupLog() {
|
func (gmx *Gomuks) SetupLog() {
|
||||||
|
if gmx.TUI != nil {
|
||||||
|
// Remove stdout and stderr writers if TUI is enabled
|
||||||
|
gmx.Config.Logging.Writers = slices.DeleteFunc(gmx.Config.Logging.Writers, func(config zeroconfig.WriterConfig) bool {
|
||||||
|
return config.Type == zeroconfig.WriterTypeStdout || config.Type == zeroconfig.WriterTypeStderr
|
||||||
|
})
|
||||||
|
}
|
||||||
gmx.Log = exerrors.Must(gmx.Config.Logging.Compile())
|
gmx.Log = exerrors.Must(gmx.Config.Logging.Compile())
|
||||||
exzerolog.SetupDefaults(gmx.Log)
|
exzerolog.SetupDefaults(gmx.Log)
|
||||||
}
|
}
|
||||||
|
@ -250,7 +263,11 @@ func (gmx *Gomuks) Run() {
|
||||||
gmx.StartServer()
|
gmx.StartServer()
|
||||||
gmx.StartClient()
|
gmx.StartClient()
|
||||||
gmx.Log.Info().Msg("Initialization complete")
|
gmx.Log.Info().Msg("Initialization complete")
|
||||||
|
if gmx.TUI != nil {
|
||||||
|
gmx.TUI.Run()
|
||||||
|
} else {
|
||||||
gmx.WaitForInterrupt()
|
gmx.WaitForInterrupt()
|
||||||
|
}
|
||||||
gmx.Log.Info().Msg("Shutting down...")
|
gmx.Log.Info().Msg("Shutting down...")
|
||||||
gmx.DirectStop()
|
gmx.DirectStop()
|
||||||
gmx.Log.Info().Msg("Shutdown complete")
|
gmx.Log.Info().Msg("Shutdown complete")
|
||||||
|
|
|
@ -235,6 +235,9 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
|
||||||
if gmx.DisableAuth {
|
if gmx.DisableAuth {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
|
} else if gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
jsonOutput := r.URL.Query().Get("output") == "json"
|
jsonOutput := r.URL.Query().Get("output") == "json"
|
||||||
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
|
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
|
||||||
|
|
89
tui/autocomplete.go
Normal file
89
tui/autocomplete.go
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func autocompleteFile(cmd *CommandAutocomplete) (completions []string, newText string) {
|
||||||
|
inputPath, err := filepath.Abs(cmd.RawArgs)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchNamePrefix, searchDir string
|
||||||
|
if strings.HasSuffix(cmd.RawArgs, "/") {
|
||||||
|
searchDir = inputPath
|
||||||
|
} else {
|
||||||
|
searchNamePrefix = filepath.Base(inputPath)
|
||||||
|
searchDir = filepath.Dir(inputPath)
|
||||||
|
}
|
||||||
|
files, err := os.ReadDir(searchDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
name := file.Name()
|
||||||
|
if !strings.HasPrefix(name, searchNamePrefix) || (name[0] == '.' && searchNamePrefix == "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(searchDir, name)
|
||||||
|
if file.IsDir() {
|
||||||
|
fullPath += "/"
|
||||||
|
}
|
||||||
|
completions = append(completions, fullPath)
|
||||||
|
}
|
||||||
|
if len(completions) == 1 {
|
||||||
|
newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func autocompleteToggle(cmd *CommandAutocomplete) (completions []string, newText string) {
|
||||||
|
//??
|
||||||
|
completions = make([]string, 0, len(toggleMsg))
|
||||||
|
for k := range toggleMsg {
|
||||||
|
if strings.HasPrefix(k, cmd.RawArgs) {
|
||||||
|
completions = append(completions, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(completions) == 1 {
|
||||||
|
newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var staticPowerLevelKeys = []string{"ban", "kick", "redact", "invite", "state_default", "events_default", "users_default"}
|
||||||
|
|
||||||
|
func autocompletePowerLevel(cmd *CommandAutocomplete) (completions []string, newText string) {
|
||||||
|
if len(cmd.Args) > 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, staticKey := range staticPowerLevelKeys {
|
||||||
|
if strings.HasPrefix(staticKey, cmd.RawArgs) {
|
||||||
|
completions = append(completions, staticKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, cpl := range cmd.Room.AutocompleteUser(cmd.RawArgs) {
|
||||||
|
completions = append(completions, cpl.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
281
tui/command-processor.go
Normal file
281
tui/command-processor.go
Normal file
|
@ -0,0 +1,281 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
type gomuksPointerContainer struct {
|
||||||
|
MainView *MainView
|
||||||
|
TUI *GomuksTUI
|
||||||
|
}
|
||||||
|
|
||||||
|
type Command struct {
|
||||||
|
gomuksPointerContainer
|
||||||
|
Handler *CommandProcessor
|
||||||
|
|
||||||
|
Room *RoomView
|
||||||
|
Command string
|
||||||
|
OrigCommand string
|
||||||
|
Args []string
|
||||||
|
RawArgs string
|
||||||
|
OrigText string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandAutocomplete Command
|
||||||
|
|
||||||
|
func (cmd *Command) Reply(message string, args ...interface{}) {
|
||||||
|
if len(args) > 0 {
|
||||||
|
message = fmt.Sprintf(message, args...)
|
||||||
|
}
|
||||||
|
cmd.Room.AddServiceMessage(message)
|
||||||
|
cmd.TUI.App.Redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Alias struct {
|
||||||
|
NewCommand string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (alias *Alias) Process(cmd *Command) *Command {
|
||||||
|
cmd.Command = alias.NewCommand
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandHandler func(cmd *Command)
|
||||||
|
type CommandAutocompleter func(cmd *CommandAutocomplete) (completions []string, newText string)
|
||||||
|
|
||||||
|
type CommandProcessor struct {
|
||||||
|
gomuksPointerContainer
|
||||||
|
|
||||||
|
aliases map[string]*Alias
|
||||||
|
commands map[string]CommandHandler
|
||||||
|
|
||||||
|
autocompleters map[string]CommandAutocompleter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCommandProcessor(parent *MainView) *CommandProcessor {
|
||||||
|
return &CommandProcessor{
|
||||||
|
gomuksPointerContainer: gomuksPointerContainer{
|
||||||
|
MainView: parent,
|
||||||
|
TUI: parent.parent,
|
||||||
|
},
|
||||||
|
aliases: map[string]*Alias{
|
||||||
|
"part": {"leave"},
|
||||||
|
"send": {"sendevent"},
|
||||||
|
"msend": {"msendevent"},
|
||||||
|
"state": {"setstate"},
|
||||||
|
"mstate": {"msetstate"},
|
||||||
|
"rb": {"rainbow"},
|
||||||
|
"rbme": {"rainbowme"},
|
||||||
|
"rbn": {"rainbownotice"},
|
||||||
|
"myroomnick": {"roomnick"},
|
||||||
|
"createroom": {"create"},
|
||||||
|
"dm": {"pm"},
|
||||||
|
"query": {"pm"},
|
||||||
|
"r": {"reply"},
|
||||||
|
"delete": {"redact"},
|
||||||
|
"remove": {"redact"},
|
||||||
|
"rm": {"redact"},
|
||||||
|
"del": {"redact"},
|
||||||
|
"e": {"edit"},
|
||||||
|
"dl": {"download"},
|
||||||
|
"o": {"open"},
|
||||||
|
"4s": {"ssss"},
|
||||||
|
"s4": {"ssss"},
|
||||||
|
"cs": {"cross-signing"},
|
||||||
|
"power": {"powerlevel"},
|
||||||
|
"pl": {"powerlevel"},
|
||||||
|
},
|
||||||
|
autocompleters: map[string]CommandAutocompleter{
|
||||||
|
"devices": autocompleteUser,
|
||||||
|
"device": autocompleteDevice,
|
||||||
|
"verify": autocompleteUser,
|
||||||
|
"verify-device": autocompleteDevice,
|
||||||
|
"unverify": autocompleteDevice,
|
||||||
|
"blacklist": autocompleteDevice,
|
||||||
|
"upload": autocompleteFile,
|
||||||
|
"download": autocompleteFile,
|
||||||
|
"open": autocompleteFile,
|
||||||
|
"import": autocompleteFile,
|
||||||
|
"export": autocompleteFile,
|
||||||
|
"export-room": autocompleteFile,
|
||||||
|
"toggle": autocompleteToggle,
|
||||||
|
"powerlevel": autocompletePowerLevel,
|
||||||
|
},
|
||||||
|
commands: map[string]CommandHandler{
|
||||||
|
"unknown-command": cmdUnknownCommand,
|
||||||
|
|
||||||
|
"id": cmdID,
|
||||||
|
"help": cmdHelp,
|
||||||
|
"me": cmdMe,
|
||||||
|
"quit": cmdQuit,
|
||||||
|
"clearcache": cmdClearCache,
|
||||||
|
"leave": cmdLeave,
|
||||||
|
"create": cmdCreateRoom,
|
||||||
|
"pm": cmdPrivateMessage,
|
||||||
|
"join": cmdJoin,
|
||||||
|
"kick": cmdKick,
|
||||||
|
"ban": cmdBan,
|
||||||
|
"unban": cmdUnban,
|
||||||
|
"powerlevel": cmdPowerLevel,
|
||||||
|
"toggle": cmdToggle,
|
||||||
|
"logout": cmdLogout,
|
||||||
|
"accept": cmdAccept,
|
||||||
|
"reject": cmdReject,
|
||||||
|
"reply": cmdReply,
|
||||||
|
"redact": cmdRedact,
|
||||||
|
"react": cmdReact,
|
||||||
|
"edit": cmdEdit,
|
||||||
|
"external": cmdExternalEditor,
|
||||||
|
"download": cmdDownload,
|
||||||
|
"upload": cmdUpload,
|
||||||
|
"open": cmdOpen,
|
||||||
|
"copy": cmdCopy,
|
||||||
|
"sendevent": cmdSendEvent,
|
||||||
|
"msendevent": cmdMSendEvent,
|
||||||
|
"setstate": cmdSetState,
|
||||||
|
"msetstate": cmdMSetState,
|
||||||
|
"roomnick": cmdRoomNick,
|
||||||
|
"rainbow": cmdRainbow,
|
||||||
|
"rainbowme": cmdRainbowMe,
|
||||||
|
"notice": cmdNotice,
|
||||||
|
"alias": cmdAlias,
|
||||||
|
"tags": cmdTags,
|
||||||
|
"tag": cmdTag,
|
||||||
|
"untag": cmdUntag,
|
||||||
|
"invite": cmdInvite,
|
||||||
|
"hprof": cmdHeapProfile,
|
||||||
|
"cprof": cmdCPUProfile,
|
||||||
|
"trace": cmdTrace,
|
||||||
|
"panic": func(cmd *Command) {
|
||||||
|
panic("hello world")
|
||||||
|
},
|
||||||
|
|
||||||
|
"rainbownotice": cmdRainbowNotice,
|
||||||
|
|
||||||
|
"fingerprint": cmdFingerprint,
|
||||||
|
"devices": cmdDevices,
|
||||||
|
"verify-device": cmdVerifyDevice,
|
||||||
|
"verify": cmdVerify,
|
||||||
|
"device": cmdDevice,
|
||||||
|
"unverify": cmdUnverify,
|
||||||
|
"blacklist": cmdBlacklist,
|
||||||
|
"reset-session": cmdResetSession,
|
||||||
|
"import": cmdImportKeys,
|
||||||
|
"export": cmdExportKeys,
|
||||||
|
"export-room": cmdExportRoomKeys,
|
||||||
|
"ssss": cmdSSSS,
|
||||||
|
"cross-signing": cmdCrossSigning,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch *CommandProcessor) ParseCommand(roomView *RoomView, text string) *Command {
|
||||||
|
if text[0] != '/' || len(text) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
text = text[1:]
|
||||||
|
split := strings.Fields(text)
|
||||||
|
command := split[0]
|
||||||
|
args := split[1:]
|
||||||
|
var rawArgs string
|
||||||
|
if len(text) > len(command)+1 {
|
||||||
|
rawArgs = text[len(command)+1:]
|
||||||
|
}
|
||||||
|
return &Command{
|
||||||
|
gomuksPointerContainer: ch.gomuksPointerContainer,
|
||||||
|
Handler: ch,
|
||||||
|
|
||||||
|
Room: roomView,
|
||||||
|
Command: strings.ToLower(command),
|
||||||
|
OrigCommand: command,
|
||||||
|
Args: args,
|
||||||
|
RawArgs: rawArgs,
|
||||||
|
OrigText: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch *CommandProcessor) Autocomplete(roomView *RoomView, text string, cursorOffset int) ([]string, string, bool) {
|
||||||
|
var completions []string
|
||||||
|
if cursorOffset != runewidth.StringWidth(text) {
|
||||||
|
return completions, text, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *Command
|
||||||
|
if cmd = ch.ParseCommand(roomView, text); cmd == nil {
|
||||||
|
return completions, text, false
|
||||||
|
} else if alias, ok := ch.aliases[cmd.Command]; ok {
|
||||||
|
cmd = alias.Process(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, ok := ch.autocompleters[cmd.Command]
|
||||||
|
if ok {
|
||||||
|
var newText string
|
||||||
|
completions, newText = handler((*CommandAutocomplete)(cmd))
|
||||||
|
if newText != "" {
|
||||||
|
text = newText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completions, text, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch *CommandProcessor) AutocompleteCommand(word string) (completions []string) {
|
||||||
|
if word[0] != '/' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
word = word[1:]
|
||||||
|
for alias := range ch.aliases {
|
||||||
|
if alias == word {
|
||||||
|
return []string{"/" + alias}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(alias, word) {
|
||||||
|
completions = append(completions, "/"+alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for command := range ch.commands {
|
||||||
|
if command == word {
|
||||||
|
return []string{"/" + command}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(command, word) {
|
||||||
|
completions = append(completions, "/"+command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch *CommandProcessor) HandleCommand(cmd *Command) {
|
||||||
|
defer debug.Recover()
|
||||||
|
if cmd == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alias, ok := ch.aliases[cmd.Command]; ok {
|
||||||
|
cmd = alias.Process(cmd)
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if handler, ok := ch.commands[cmd.Command]; ok {
|
||||||
|
handler(cmd)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmdUnknownCommand(cmd)
|
||||||
|
}
|
1058
tui/commands.go
Normal file
1058
tui/commands.go
Normal file
File diff suppressed because it is too large
Load diff
699
tui/crypto-commands.go
Normal file
699
tui/crypto-commands.go
Normal file
|
@ -0,0 +1,699 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build cgo
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/crypto"
|
||||||
|
"maunium.net/go/mautrix/crypto/ssss"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func autocompleteDeviceUserID(cmd *CommandAutocomplete) (completions []string, newText string) {
|
||||||
|
userCompletions := cmd.Room.AutocompleteUser(cmd.Args[0])
|
||||||
|
if len(userCompletions) == 1 {
|
||||||
|
newText = fmt.Sprintf("/%s %s ", cmd.OrigCommand, userCompletions[0].id)
|
||||||
|
} else {
|
||||||
|
completions = make([]string, len(userCompletions))
|
||||||
|
for i, completion := range userCompletions {
|
||||||
|
completions[i] = completion.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func autocompleteDeviceDeviceID(cmd *CommandAutocomplete) (completions []string, newText string) {
|
||||||
|
//????
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
devices, err := mach.CryptoStore.GetDevices(id.UserID(cmd.Args[0]))
|
||||||
|
if len(devices) == 0 || err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var completedDeviceID id.DeviceID
|
||||||
|
if len(cmd.Args) > 1 {
|
||||||
|
existingID := strings.ToUpper(cmd.Args[1])
|
||||||
|
for _, device := range devices {
|
||||||
|
deviceIDStr := string(device.DeviceID)
|
||||||
|
if deviceIDStr == existingID {
|
||||||
|
// We don't want to do any autocompletion if there's already a full device ID there.
|
||||||
|
return []string{}, ""
|
||||||
|
} else if strings.HasPrefix(strings.ToUpper(device.Name), existingID) || strings.HasPrefix(deviceIDStr, existingID) {
|
||||||
|
completedDeviceID = device.DeviceID
|
||||||
|
completions = append(completions, fmt.Sprintf("%s (%s)", device.DeviceID, device.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
completions = make([]string, len(devices))
|
||||||
|
i := 0
|
||||||
|
for _, device := range devices {
|
||||||
|
completedDeviceID = device.DeviceID
|
||||||
|
completions[i] = fmt.Sprintf("%s (%s)", device.DeviceID, device.Name)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(completions) == 1 {
|
||||||
|
newText = fmt.Sprintf("/%s %s %s ", cmd.OrigCommand, cmd.Args[0], completedDeviceID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) {
|
||||||
|
if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) {
|
||||||
|
return autocompleteDeviceUserID(cmd)
|
||||||
|
}
|
||||||
|
return []string{}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) {
|
||||||
|
if len(cmd.Args) == 0 {
|
||||||
|
return []string{}, ""
|
||||||
|
} else if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) {
|
||||||
|
return autocompleteDeviceUserID(cmd)
|
||||||
|
}
|
||||||
|
return autocompleteDeviceDeviceID(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDevice(cmd *Command) *crypto.DeviceIdentity {
|
||||||
|
if len(cmd.Args) < 2 {
|
||||||
|
cmd.Reply("Usage: /%s <user id> <device id> [fingerprint]", cmd.Command)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
device, err := mach.GetOrFetchDevice(id.UserID(cmd.Args[0]), id.DeviceID(cmd.Args[1]))
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to get device: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
func putDevice(cmd *Command, device *crypto.DeviceIdentity, action string) {
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
err := mach.CryptoStore.PutDevice(device.UserID, device)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to save device: %v", err)
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Successfully %s %s/%s (%s)", action, device.UserID, device.DeviceID, device.Name)
|
||||||
|
}
|
||||||
|
mach.OnDevicesChanged(device.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdDevices(cmd *Command) {
|
||||||
|
if len(cmd.Args) == 0 {
|
||||||
|
cmd.Reply("Usage: /devices <user id>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := id.UserID(cmd.Args[0])
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
devices, err := mach.CryptoStore.GetDevices(userID)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to get device list: %v", err)
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
cmd.Reply("Fetching device list from server...")
|
||||||
|
devices = mach.LoadDevices(userID)
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
cmd.Reply("No devices found for %s", userID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
for _, device := range devices {
|
||||||
|
trust := device.Trust.String()
|
||||||
|
if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) {
|
||||||
|
trust = "verified (transitive)"
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(&buf, "%s (%s) - %s\n Fingerprint: %s\n", device.DeviceID, device.Name, trust, device.Fingerprint())
|
||||||
|
}
|
||||||
|
resp := buf.String()
|
||||||
|
cmd.Reply("%s", resp[:len(resp)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdDevice(cmd *Command) {
|
||||||
|
device := getDevice(cmd)
|
||||||
|
if device == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceType := "Device"
|
||||||
|
if device.Deleted {
|
||||||
|
deviceType = "Deleted device"
|
||||||
|
}
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
trustState := device.Trust.String()
|
||||||
|
if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) {
|
||||||
|
trustState = "verified (transitive)"
|
||||||
|
}
|
||||||
|
cmd.Reply("%s %s of %s\nFingerprint: %s\nIdentity key: %s\nDevice name: %s\nTrust state: %s",
|
||||||
|
deviceType, device.DeviceID, device.UserID,
|
||||||
|
device.Fingerprint(), device.IdentityKey,
|
||||||
|
device.Name, trustState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func crossSignDevice(cmd *Command, device *crypto.DeviceIdentity) {
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
err := mach.SignOwnDevice(device)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to upload cross-signing signature: %v", err)
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Successfully cross-signed %s (%s)", device.DeviceID, device.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdVerifyDevice(cmd *Command) {
|
||||||
|
device := getDevice(cmd)
|
||||||
|
if device == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if device.Trust == crypto.TrustStateVerified {
|
||||||
|
cmd.Reply("That device is already verified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(cmd.Args) == 2 {
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
mach.DefaultSASTimeout = 120 * time.Second
|
||||||
|
modal := NewVerificationModal(cmd.MainView, device, mach.DefaultSASTimeout)
|
||||||
|
cmd.MainView.ShowModal(modal)
|
||||||
|
_, err := mach.NewSimpleSASVerificationWith(device, modal)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to start interactive verification: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fingerprint := strings.Join(cmd.Args[2:], "")
|
||||||
|
if string(device.SigningKey) != fingerprint {
|
||||||
|
cmd.Reply("Mismatching fingerprint")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := "verified"
|
||||||
|
if device.Trust == crypto.TrustStateBlacklisted {
|
||||||
|
action = "unblacklisted and verified"
|
||||||
|
}
|
||||||
|
if device.UserID == cmd.Matrix.Client().UserID {
|
||||||
|
crossSignDevice(cmd, device)
|
||||||
|
device.Trust = crypto.TrustStateVerified
|
||||||
|
putDevice(cmd, device, action)
|
||||||
|
} else {
|
||||||
|
putDevice(cmd, device, action)
|
||||||
|
cmd.Reply("Warning: verifying individual devices of other users is not synced with cross-signing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdVerify(cmd *Command) {
|
||||||
|
if len(cmd.Args) < 1 {
|
||||||
|
cmd.Reply("Usage: /%s <user ID> [--force]", cmd.OrigCommand)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
force := len(cmd.Args) >= 2 && strings.ToLower(cmd.Args[1]) == "--force"
|
||||||
|
userID := id.UserID(cmd.Args[0])
|
||||||
|
room := cmd.Room.Room
|
||||||
|
if !room.Encrypted {
|
||||||
|
cmd.Reply("In-room verification is only supported in encrypted rooms")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!room.IsDirect || room.OtherUser != userID) && !force {
|
||||||
|
cmd.Reply("This doesn't seem to be a direct chat. Either switch to a direct chat with %s, "+
|
||||||
|
"or use `--force` to start the verification anyway.", userID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
if mach.CrossSigningKeys == nil && !force {
|
||||||
|
cmd.Reply("Cross-signing private keys not cached. Generate or fetch cross-signing keys with `/cross-signing`, " +
|
||||||
|
"or use `--force` to start the verification anyway")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := NewVerificationModal(cmd.MainView, &crypto.DeviceIdentity{UserID: userID}, mach.DefaultSASTimeout)
|
||||||
|
_, err := mach.NewInRoomSASVerificationWith(cmd.Room.Room.ID, userID, modal, 120*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to start in-room verification: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.MainView.ShowModal(modal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdUnverify(cmd *Command) {
|
||||||
|
device := getDevice(cmd)
|
||||||
|
if device == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if device.Trust == crypto.TrustStateUnset {
|
||||||
|
cmd.Reply("That device is already not verified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := "unverified"
|
||||||
|
if device.Trust == crypto.TrustStateBlacklisted {
|
||||||
|
action = "unblacklisted"
|
||||||
|
}
|
||||||
|
device.Trust = crypto.TrustStateUnset
|
||||||
|
putDevice(cmd, device, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdBlacklist(cmd *Command) {
|
||||||
|
device := getDevice(cmd)
|
||||||
|
if device == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if device.Trust == crypto.TrustStateBlacklisted {
|
||||||
|
cmd.Reply("That device is already blacklisted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := "blacklisted"
|
||||||
|
if device.Trust == crypto.TrustStateVerified {
|
||||||
|
action = "unverified and blacklisted"
|
||||||
|
}
|
||||||
|
device.Trust = crypto.TrustStateBlacklisted
|
||||||
|
putDevice(cmd, device, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdResetSession(cmd *Command) {
|
||||||
|
err := cmd.Matrix.Crypto().(*crypto.OlmMachine).CryptoStore.RemoveOutboundGroupSession(cmd.Room.Room.ID)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to remove outbound group session: %v", err)
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Removed outbound group session for this room")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdImportKeys(cmd *Command) {
|
||||||
|
path, err := filepath.Abs(cmd.RawArgs)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to get absolute path: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to read %s: %v", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passphrase, ok := cmd.MainView.AskPassword("Key import", "passphrase", "", false)
|
||||||
|
if !ok {
|
||||||
|
cmd.Reply("Passphrase entry cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
imported, total, err := mach.ImportKeys(passphrase, data)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to import sessions: %v", err)
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Successfully imported %d/%d sessions", imported, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportKeys(cmd *Command, sessions []*crypto.InboundGroupSession) {
|
||||||
|
path, err := filepath.Abs(cmd.RawArgs)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to get absolute path: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passphrase, ok := cmd.MainView.AskPassword("Key export", "passphrase", "", true)
|
||||||
|
if !ok {
|
||||||
|
cmd.Reply("Passphrase entry cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
export, err := crypto.ExportKeys(passphrase, sessions)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to export sessions: %v", err)
|
||||||
|
}
|
||||||
|
err = ioutil.WriteFile(path, export, 0400)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to write sessions to %s: %v", path, err)
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Successfully exported %d sessions to %s", len(sessions), path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdExportKeys(cmd *Command) {
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
sessions, err := mach.CryptoStore.GetAllGroupSessions()
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to get sessions to export: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exportKeys(cmd, sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdExportRoomKeys(cmd *Command) {
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
sessions, err := mach.CryptoStore.GetGroupSessionsForRoom(cmd.Room.MxRoom().ID)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to get sessions to export: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exportKeys(cmd, sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssssHelp = `Usage: /%s <subcommand> [...]
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
* status [key ID] - Check the status of your SSSS.
|
||||||
|
* generate [--set-default] - Generate a SSSS key and optionally set it as the default.
|
||||||
|
* set-default <key ID> - Set a SSSS key as the default.`
|
||||||
|
|
||||||
|
func cmdSSSS(cmd *Command) {
|
||||||
|
if len(cmd.Args) == 0 {
|
||||||
|
cmd.Reply(ssssHelp, cmd.OrigCommand)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
|
||||||
|
switch strings.ToLower(cmd.Args[0]) {
|
||||||
|
case "status":
|
||||||
|
keyID := ""
|
||||||
|
if len(cmd.Args) > 1 {
|
||||||
|
keyID = cmd.Args[1]
|
||||||
|
}
|
||||||
|
cmdS4Status(cmd, mach, keyID)
|
||||||
|
case "generate":
|
||||||
|
setDefault := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--set-default"
|
||||||
|
cmdS4Generate(cmd, mach, setDefault)
|
||||||
|
case "set-default":
|
||||||
|
if len(cmd.Args) < 2 {
|
||||||
|
cmd.Reply("Usage: /%s set-default <key ID>", cmd.OrigCommand)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmdS4SetDefault(cmd, mach, cmd.Args[1])
|
||||||
|
default:
|
||||||
|
cmd.Reply(ssssHelp, cmd.OrigCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdS4Status(cmd *Command, mach *crypto.OlmMachine, keyID string) {
|
||||||
|
var keyData *ssss.KeyMetadata
|
||||||
|
var err error
|
||||||
|
if len(keyID) == 0 {
|
||||||
|
keyID, keyData, err = mach.SSSS.GetDefaultKeyData(context.TODO)
|
||||||
|
} else {
|
||||||
|
keyData, err = mach.SSSS.GetKeyData(context.TODO, keyID)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ssss.ErrNoDefaultKeyAccountDataEvent) {
|
||||||
|
cmd.Reply("SSSS is not set up: no default key set")
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
cmd.Reply("Failed to get key data: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasPassphrase := "no"
|
||||||
|
if keyData.Passphrase != nil {
|
||||||
|
hasPassphrase = fmt.Sprintf("yes (alg=%s,bits=%d,iter=%d)", keyData.Passphrase.Algorithm, keyData.Passphrase.Bits, keyData.Passphrase.Iterations)
|
||||||
|
}
|
||||||
|
algorithm := keyData.Algorithm
|
||||||
|
if algorithm != ssss.AlgorithmAESHMACSHA2 {
|
||||||
|
algorithm += " (not supported!)"
|
||||||
|
}
|
||||||
|
cmd.Reply("Default key is set.\n Key ID: %s\n Has passphrase: %s\n Algorithm: %s", keyID, hasPassphrase, algorithm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdS4Generate(cmd *Command, mach *crypto.OlmMachine, setDefault bool) {
|
||||||
|
passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "", true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := ssss.NewKey(passphrase)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to generate new key: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mach.SSSS.SetKeyData(context.TODO(), key.ID, key.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to upload key metadata: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO if we start persisting command replies, the recovery key needs to be moved into a popup
|
||||||
|
cmd.Reply("Successfully generated key %s\nRecovery key: %s", key.ID, key.RecoveryKey())
|
||||||
|
|
||||||
|
if setDefault {
|
||||||
|
err = mach.SSSS.SetDefaultKeyID(context.TODO(), key.ID)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to set key as default: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd.Reply("You can use `/%s set-default %s` to set it as the default", cmd.OrigCommand, key.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdS4SetDefault(cmd *Command, mach *crypto.OlmMachine, keyID string) {
|
||||||
|
_, err := mach.SSSS.GetKeyData(context.TODO(), keyID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, mautrix.MNotFound) {
|
||||||
|
cmd.Reply("Couldn't find key data on server")
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Failed to fetch key data: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mach.SSSS.SetDefaultKeyID(context.TODO(), keyID)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to set key as default: %v", err)
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Successfully set key %s as default", keyID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossSigningHelp = `Usage: /%s <subcommand> [...]
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
* status
|
||||||
|
Check the status of your own cross-signing keys.
|
||||||
|
* generate [--force]
|
||||||
|
Generate and upload new cross-signing keys.
|
||||||
|
This will prompt you to enter your account password.
|
||||||
|
If you already have existing keys, --force is required.
|
||||||
|
* self-sign
|
||||||
|
Sign the current device with cached cross-signing keys.
|
||||||
|
* fetch [--save-to-disk]
|
||||||
|
Fetch your cross-signing keys from SSSS and decrypt them.
|
||||||
|
If --save-to-disk is specified, the keys are saved to disk.
|
||||||
|
* upload
|
||||||
|
Upload your cross-signing keys to SSSS.`
|
||||||
|
|
||||||
|
func cmdCrossSigning(cmd *Command) {
|
||||||
|
if len(cmd.Args) == 0 {
|
||||||
|
cmd.Reply(crossSigningHelp, cmd.OrigCommand)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := cmd.Matrix.Client()
|
||||||
|
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||||
|
|
||||||
|
switch strings.ToLower(cmd.Args[0]) {
|
||||||
|
case "status":
|
||||||
|
cmdCrossSigningStatus(cmd, mach)
|
||||||
|
case "generate":
|
||||||
|
force := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--force"
|
||||||
|
cmdCrossSigningGenerate(cmd, cmd.Matrix, mach, client, force)
|
||||||
|
case "fetch":
|
||||||
|
saveToDisk := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--save-to-disk"
|
||||||
|
cmdCrossSigningFetch(cmd, mach, saveToDisk)
|
||||||
|
case "upload":
|
||||||
|
cmdCrossSigningUpload(cmd, mach)
|
||||||
|
case "self-sign":
|
||||||
|
cmdCrossSigningSelfSign(cmd, mach)
|
||||||
|
default:
|
||||||
|
cmd.Reply(crossSigningHelp, cmd.OrigCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdCrossSigningStatus(cmd *Command, mach *crypto.OlmMachine) {
|
||||||
|
keys := mach.GetOwnCrossSigningPublicKeys(context.TODO())
|
||||||
|
if keys == nil {
|
||||||
|
if mach.CrossSigningKeys != nil {
|
||||||
|
cmd.Reply("Cross-signing keys are cached, but not published")
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Didn't find published cross-signing keys")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mach.CrossSigningKeys != nil {
|
||||||
|
cmd.Reply("Cross-signing keys are published and private keys are cached")
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Cross-signing keys are published, but private keys are not cached")
|
||||||
|
}
|
||||||
|
cmd.Reply("Master key: %s", keys.MasterKey)
|
||||||
|
cmd.Reply("User signing key: %s", keys.UserSigningKey)
|
||||||
|
cmd.Reply("Self-signing key: %s", keys.SelfSigningKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdCrossSigningFetch(cmd *Command, mach *crypto.OlmMachine, saveToDisk bool) {
|
||||||
|
key := getSSSS(cmd, mach)
|
||||||
|
if key == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := mach.FetchCrossSigningKeysFromSSSS(context.TODO(), key)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Error fetching cross-signing keys: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if saveToDisk {
|
||||||
|
cmd.Reply("Saving keys to disk is not yet implemented")
|
||||||
|
}
|
||||||
|
cmd.Reply("Successfully unlocked cross-signing keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
// korjaa
|
||||||
|
func cmdCrossSigningGenerate(cmd *Command, container ifc.MatrixContainer, mach *crypto.OlmMachine, client *mautrix.Client, force bool) {
|
||||||
|
if !force {
|
||||||
|
existingKeys := mach.GetOwnCrossSigningPublicKeys(context.TODO())
|
||||||
|
if existingKeys != nil {
|
||||||
|
cmd.Reply("Found existing cross-signing keys. Use `--force` if you want to overwrite them.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := mach.GenerateCrossSigningKeys()
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to generate cross-signing keys: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mach.PublishCrossSigningKeys(context.TODO(), keys, func(uia *mautrix.RespUserInteractive) interface{} {
|
||||||
|
if !uia.HasSingleStageFlow(mautrix.AuthTypePassword) {
|
||||||
|
for _, flow := range uia.Flows {
|
||||||
|
if len(flow.Stages) != 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd.Reply("Opening browser for authentication")
|
||||||
|
err := container.UIAFallback(flow.Stages[0], uia.Session)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Authentication failed: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &mautrix.ReqUIAuthFallback{
|
||||||
|
Session: uia.Session,
|
||||||
|
User: mach.Client.UserID.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.Reply("No supported authentication mechanisms found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
password, ok := cmd.MainView.AskPassword("Account password", "", "correct horse battery staple", false)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &mautrix.ReqUIAuthLogin{
|
||||||
|
BaseAuthData: mautrix.BaseAuthData{
|
||||||
|
Type: mautrix.AuthTypePassword,
|
||||||
|
Session: uia.Session,
|
||||||
|
},
|
||||||
|
User: mach.Client.UserID.String(),
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to publish cross-signing keys: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.Reply("Successfully generated and published cross-signing keys")
|
||||||
|
|
||||||
|
err = mach.SignOwnMasterKey(context.TODO())
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to sign master key with device key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSSSS(cmd *Command, mach *crypto.OlmMachine) *ssss.Key {
|
||||||
|
_, keyData, err := mach.SSSS.GetDefaultKeyData(context.TODO())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, mautrix.MNotFound) {
|
||||||
|
cmd.Reply("SSSS not set up, use `!ssss generate --set-default` first")
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Failed to fetch default SSSS key data: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var key *ssss.Key
|
||||||
|
if keyData.Passphrase != nil && keyData.Passphrase.Algorithm == ssss.PassphraseAlgorithmPBKDF2 {
|
||||||
|
passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "correct horse battery staple", false)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key, err = keyData.VerifyPassphrase(passphrase)
|
||||||
|
if errors.Is(err, ssss.ErrIncorrectSSSSKey) {
|
||||||
|
cmd.Reply("Incorrect passphrase")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recoveryKey, ok := cmd.MainView.AskPassword("Recovery key", "", "tDAK LMRH PiYE bdzi maCe xLX5 wV6P Nmfd c5mC wLef 15Fs VVSc", false)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key, err = keyData.VerifyRecoveryKey(recoveryKey)
|
||||||
|
if errors.Is(err, ssss.ErrInvalidRecoveryKey) {
|
||||||
|
cmd.Reply("Malformed recovery key")
|
||||||
|
return nil
|
||||||
|
} else if errors.Is(err, ssss.ErrIncorrectSSSSKey) {
|
||||||
|
cmd.Reply("Incorrect recovery key")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All the errors should already be handled above, this is just for backup
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to get SSSS key: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdCrossSigningUpload(cmd *Command, mach *crypto.OlmMachine) {
|
||||||
|
if mach.CrossSigningKeys == nil {
|
||||||
|
cmd.Reply("Cross-signing keys not cached, use `!%s generate` first", cmd.OrigCommand)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := getSSSS(cmd, mach)
|
||||||
|
if key == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := mach.UploadCrossSigningKeysToSSSS(context.TODO(), key, mach.CrossSigningKeys)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to upload keys to SSSS: %v", err)
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Successfully uploaded cross-signing keys to SSSS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdCrossSigningSelfSign(cmd *Command, mach *crypto.OlmMachine) {
|
||||||
|
if mach.CrossSigningKeys == nil {
|
||||||
|
cmd.Reply("Cross-signing keys not cached")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := mach.SignOwnDevice(context.TODO(), mach.OwnIdentity())
|
||||||
|
if err != nil {
|
||||||
|
cmd.Reply("Failed to self-sign: %v", err)
|
||||||
|
} else {
|
||||||
|
cmd.Reply("Successfully self-signed. This device is now trusted by other devices")
|
||||||
|
}
|
||||||
|
}
|
2
tui/doc.go
Normal file
2
tui/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Package tui contains the main gomuks TUI.
|
||||||
|
package tui
|
161
tui/fuzzy-search-modal.go
Normal file
161
tui/fuzzy-search-modal.go
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FuzzySearchModal struct {
|
||||||
|
mauview.Component
|
||||||
|
|
||||||
|
container *mauview.Box
|
||||||
|
|
||||||
|
search *mauview.InputArea
|
||||||
|
results *mauview.TextView
|
||||||
|
|
||||||
|
matches fuzzy.Ranks
|
||||||
|
selected int
|
||||||
|
|
||||||
|
roomList []*rooms.Room
|
||||||
|
roomTitles []string
|
||||||
|
|
||||||
|
parent *MainView
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFuzzySearchModal(mainView *MainView, width int, height int) *FuzzySearchModal {
|
||||||
|
fs := &FuzzySearchModal{
|
||||||
|
parent: mainView,
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.InitList(mainView.rooms)
|
||||||
|
|
||||||
|
fs.results = mauview.NewTextView().SetRegions(true)
|
||||||
|
fs.search = mauview.NewInputArea().
|
||||||
|
SetChangedFunc(fs.changeHandler).
|
||||||
|
SetTextColor(tcell.ColorWhite).
|
||||||
|
SetBackgroundColor(tcell.ColorDarkCyan)
|
||||||
|
fs.search.Focus()
|
||||||
|
|
||||||
|
flex := mauview.NewFlex().
|
||||||
|
SetDirection(mauview.FlexRow).
|
||||||
|
AddFixedComponent(fs.search, 1).
|
||||||
|
AddProportionalComponent(fs.results, 1)
|
||||||
|
|
||||||
|
fs.container = mauview.NewBox(flex).
|
||||||
|
SetBorder(true).
|
||||||
|
SetTitle("Quick Room Switcher").
|
||||||
|
SetBlurCaptureFunc(func() bool {
|
||||||
|
fs.parent.HideModal()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
fs.Component = mauview.Center(fs.container, width, height).SetAlwaysFocusChild(true)
|
||||||
|
|
||||||
|
return fs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FuzzySearchModal) Focus() {
|
||||||
|
fs.container.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FuzzySearchModal) Blur() {
|
||||||
|
fs.container.Blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FuzzySearchModal) InitList(rooms map[id.RoomID]*RoomView) {
|
||||||
|
for _, room := range rooms {
|
||||||
|
if room.Room.IsReplaced() {
|
||||||
|
//if _, ok := rooms[room.Room.ReplacedBy()]; ok
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs.roomList = append(fs.roomList, room.Room)
|
||||||
|
fs.roomTitles = append(fs.roomTitles, room.Room.GetTitle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FuzzySearchModal) changeHandler(str string) {
|
||||||
|
// Get matches and display in result box
|
||||||
|
fs.matches = fuzzy.RankFindFold(str, fs.roomTitles)
|
||||||
|
if len(str) > 0 && len(fs.matches) > 0 {
|
||||||
|
sort.Sort(fs.matches)
|
||||||
|
fs.results.Clear()
|
||||||
|
for _, match := range fs.matches {
|
||||||
|
fmt.Fprintf(fs.results, `["%d"]%s[""]%s`, match.OriginalIndex, match.Target, "\n")
|
||||||
|
}
|
||||||
|
//fs.parent.parent.Render()
|
||||||
|
fs.results.Highlight(strconv.Itoa(fs.matches[0].OriginalIndex))
|
||||||
|
fs.selected = 0
|
||||||
|
fs.results.ScrollToBeginning()
|
||||||
|
} else {
|
||||||
|
fs.results.Clear()
|
||||||
|
fs.results.Highlight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FuzzySearchModal) OnKeyEvent(event mauview.KeyEvent) bool {
|
||||||
|
highlights := fs.results.GetHighlights()
|
||||||
|
kb := config.Keybind{
|
||||||
|
Key: event.Key(),
|
||||||
|
Ch: event.Rune(),
|
||||||
|
Mod: event.Modifiers(),
|
||||||
|
}
|
||||||
|
switch fs.parent.config.Keybindings.Modal[kb] {
|
||||||
|
case "cancel":
|
||||||
|
// Close room finder
|
||||||
|
fs.parent.HideModal()
|
||||||
|
return true
|
||||||
|
case "select_next":
|
||||||
|
// Cycle highlighted area to next match
|
||||||
|
if len(highlights) > 0 {
|
||||||
|
fs.selected = (fs.selected + 1) % len(fs.matches)
|
||||||
|
fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex))
|
||||||
|
fs.results.ScrollToHighlight()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "select_prev":
|
||||||
|
if len(highlights) > 0 {
|
||||||
|
fs.selected = (fs.selected - 1) % len(fs.matches)
|
||||||
|
if fs.selected < 0 {
|
||||||
|
fs.selected += len(fs.matches)
|
||||||
|
}
|
||||||
|
fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex))
|
||||||
|
fs.results.ScrollToHighlight()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "confirm":
|
||||||
|
// Switch room to currently selected room
|
||||||
|
if len(highlights) > 0 {
|
||||||
|
debug.Print("Fuzzy Selected Room:", fs.roomList[fs.matches[fs.selected].OriginalIndex].GetTitle())
|
||||||
|
fs.parent.SwitchRoom(fs.roomList[fs.matches[fs.selected].OriginalIndex].Tags()[0].Tag, fs.roomList[fs.matches[fs.selected].OriginalIndex])
|
||||||
|
}
|
||||||
|
fs.parent.HideModal()
|
||||||
|
fs.results.Clear()
|
||||||
|
fs.search.SetText("")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return fs.search.OnKeyEvent(event)
|
||||||
|
}
|
115
tui/help-modal.go
Normal file
115
tui/help-modal.go
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
const helpText = `# General
|
||||||
|
/help - Show this help dialog.
|
||||||
|
/quit - Quit gomuks.
|
||||||
|
/clearcache - Clear cache and quit gomuks.
|
||||||
|
/logout - Log out of Matrix.
|
||||||
|
/toggle <thing> - Temporary command to toggle various UI features.
|
||||||
|
Run /toggle without arguments to see the list of toggles.
|
||||||
|
|
||||||
|
# Media
|
||||||
|
/download [path] - Downloads file from selected message.
|
||||||
|
/open [path] - Download file from selected message and open it with xdg-open.
|
||||||
|
/upload <path> - Upload the file at the given path to the current room.
|
||||||
|
|
||||||
|
# Sending special messages
|
||||||
|
/me <message> - Send an emote message.
|
||||||
|
/notice <message> - Send a notice (generally used for bot messages).
|
||||||
|
/rainbow <message> - Send rainbow text.
|
||||||
|
/rainbowme <message> - Send rainbow text in an emote.
|
||||||
|
/reply [text] - Reply to the selected message.
|
||||||
|
/react <reaction> - React to the selected message.
|
||||||
|
/redact [reason] - Redact the selected message.
|
||||||
|
/edit - Edit the selected message.
|
||||||
|
|
||||||
|
# Encryption
|
||||||
|
/fingerprint - View the fingerprint of your device.
|
||||||
|
|
||||||
|
/devices <user id> - View the device list of a user.
|
||||||
|
/device <user id> <device id> - Show info about a specific device.
|
||||||
|
/unverify <user id> <device id> - Un-verify a device.
|
||||||
|
/blacklist <user id> <device id> - Blacklist a device.
|
||||||
|
/verify <user id> - Verify a user with in-room verification. Probably broken.
|
||||||
|
/verify-device <user id> <device id> [fingerprint]
|
||||||
|
- Verify a device. If the fingerprint is not provided,
|
||||||
|
interactive emoji verification will be started.
|
||||||
|
/reset-session - Reset the outbound Megolm session in the current room.
|
||||||
|
|
||||||
|
/import <file> - Import encryption keys
|
||||||
|
/export <file> - Export encryption keys
|
||||||
|
/export-room <file> - Export encryption keys for the current room.
|
||||||
|
|
||||||
|
/cross-signing <subcommand> [...]
|
||||||
|
- Cross-signing commands. Somewhat experimental.
|
||||||
|
Run without arguments for help. (alias: /cs)
|
||||||
|
/ssss <subcommand> [...]
|
||||||
|
- Secure Secret Storage (and Sharing) commands. Very experimental.
|
||||||
|
Run without arguments for help.
|
||||||
|
|
||||||
|
# Rooms
|
||||||
|
/pm <user id> <...> - Create a private chat with the given user(s).
|
||||||
|
/create [room name] - Create a room.
|
||||||
|
|
||||||
|
/join <room> [server] - Join a room.
|
||||||
|
/accept - Accept the invite.
|
||||||
|
/reject - Reject the invite.
|
||||||
|
|
||||||
|
/invite <user id> - Invite the given user to the room.
|
||||||
|
/roomnick <name> - Change your per-room displayname.
|
||||||
|
/tag <tag> <priority> - Add the room to <tag>.
|
||||||
|
/untag <tag> - Remove the room from <tag>.
|
||||||
|
/tags - List the tags the room is in.
|
||||||
|
/alias <act> <name> - Add or remove local addresses.
|
||||||
|
|
||||||
|
/leave - Leave the current room.
|
||||||
|
/kick <user id> [reason] - Kick a user.
|
||||||
|
/ban <user id> [reason] - Ban a user.
|
||||||
|
/unban <user id> - Unban a user.`
|
||||||
|
|
||||||
|
type HelpModal struct {
|
||||||
|
mauview.FocusableComponent
|
||||||
|
parent *MainView
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHelpModal(parent *MainView) *HelpModal {
|
||||||
|
hm := &HelpModal{parent: parent}
|
||||||
|
|
||||||
|
text := mauview.NewTextView().
|
||||||
|
SetText(helpText).
|
||||||
|
SetScrollable(true).
|
||||||
|
SetWrap(false).
|
||||||
|
SetTextColor(tcell.ColorDefault)
|
||||||
|
|
||||||
|
box := mauview.NewBox(text).
|
||||||
|
SetBorder(true).
|
||||||
|
SetTitle("Help").
|
||||||
|
SetBlurCaptureFunc(func() bool {
|
||||||
|
hm.parent.HideModal()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
box.Focus()
|
||||||
|
|
||||||
|
hm.FocusableComponent = mauview.FractionalCenter(box, 42, 10, 0.5, 0.5)
|
||||||
|
|
||||||
|
return hm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hm *HelpModal) OnKeyEvent(event mauview.KeyEvent) bool {
|
||||||
|
kb := config.Keybind{
|
||||||
|
Key: event.Key(),
|
||||||
|
Ch: event.Rune(),
|
||||||
|
Mod: event.Modifiers(),
|
||||||
|
}
|
||||||
|
// TODO unhardcode q
|
||||||
|
if hm.parent.config.Keybindings.Modal[kb] == "cancel" || event.Rune() == 'q' {
|
||||||
|
hm.parent.HideModal()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hm.FocusableComponent.OnKeyEvent(event)
|
||||||
|
}
|
125
tui/member-list.go
Normal file
125
tui/member-list.go
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemberList struct {
|
||||||
|
list roomMemberList
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemberList() *MemberList {
|
||||||
|
return &MemberList{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memberListItem struct {
|
||||||
|
rooms.Member
|
||||||
|
PowerLevel int
|
||||||
|
Sigil rune
|
||||||
|
UserID id.UserID
|
||||||
|
Color tcell.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomMemberList []*memberListItem
|
||||||
|
|
||||||
|
func (rml roomMemberList) Len() int {
|
||||||
|
return len(rml)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rml roomMemberList) Less(i, j int) bool {
|
||||||
|
if rml[i].PowerLevel != rml[j].PowerLevel {
|
||||||
|
return rml[i].PowerLevel > rml[j].PowerLevel
|
||||||
|
}
|
||||||
|
return strings.Compare(strings.ToLower(rml[i].Displayname), strings.ToLower(rml[j].Displayname)) < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rml roomMemberList) Swap(i, j int) {
|
||||||
|
rml[i], rml[j] = rml[j], rml[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MemberList) Update(data map[id.UserID]*rooms.Member, levels *event.PowerLevelsEventContent) *MemberList {
|
||||||
|
ml.list = make(roomMemberList, len(data))
|
||||||
|
i := 0
|
||||||
|
highestLevel := math.MinInt32
|
||||||
|
count := 0
|
||||||
|
for _, level := range levels.Users {
|
||||||
|
if level > highestLevel {
|
||||||
|
highestLevel = level
|
||||||
|
count = 1
|
||||||
|
} else if level == highestLevel {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for userID, member := range data {
|
||||||
|
level := levels.GetUserLevel(userID)
|
||||||
|
sigil := ' '
|
||||||
|
if level == highestLevel && count == 1 {
|
||||||
|
sigil = '~'
|
||||||
|
} else if level > levels.StateDefault() {
|
||||||
|
sigil = '&'
|
||||||
|
} else if level >= levels.Ban() {
|
||||||
|
sigil = '@'
|
||||||
|
} else if level >= levels.Kick() || level >= levels.Redact() {
|
||||||
|
sigil = '%'
|
||||||
|
} else if level > levels.UsersDefault {
|
||||||
|
sigil = '+'
|
||||||
|
}
|
||||||
|
ml.list[i] = &memberListItem{
|
||||||
|
Member: *member,
|
||||||
|
UserID: userID,
|
||||||
|
PowerLevel: level,
|
||||||
|
Sigil: sigil,
|
||||||
|
Color: widget.GetHashColor(userID),
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
sort.Sort(ml.list)
|
||||||
|
return ml
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml *MemberList) Draw(screen mauview.Screen) {
|
||||||
|
width, _ := screen.Size()
|
||||||
|
sigilStyle := tcell.StyleDefault.Background(tcell.ColorGreen).Foreground(tcell.ColorDefault)
|
||||||
|
for y, member := range ml.list {
|
||||||
|
if member.Sigil != ' ' {
|
||||||
|
screen.SetCell(0, y, sigilStyle, member.Sigil)
|
||||||
|
}
|
||||||
|
if member.Membership == "invite" {
|
||||||
|
widget.WriteLineSimpleColor(screen, member.Displayname, 2, y, member.Color)
|
||||||
|
screen.SetCell(1, y, tcell.StyleDefault, '(')
|
||||||
|
if sw := runewidth.StringWidth(member.Displayname); sw+2 < width {
|
||||||
|
screen.SetCell(sw+2, y, tcell.StyleDefault, ')')
|
||||||
|
} else {
|
||||||
|
screen.SetCell(width-1, y, tcell.StyleDefault, ')')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
widget.WriteLineSimpleColor(screen, member.Displayname, 1, y, member.Color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
675
tui/message-view.go
Normal file
675
tui/message-view.go
Normal file
|
@ -0,0 +1,675 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageView struct {
|
||||||
|
parent *RoomView
|
||||||
|
config *config.Config
|
||||||
|
|
||||||
|
ScrollOffset int
|
||||||
|
MaxSenderWidth int
|
||||||
|
DateFormat string
|
||||||
|
TimestampFormat string
|
||||||
|
TimestampWidth int
|
||||||
|
|
||||||
|
// Used for locking
|
||||||
|
loadingMessages int32
|
||||||
|
historyLoadPtr uint64
|
||||||
|
|
||||||
|
_widestSender uint32
|
||||||
|
_prevWidestSender uint32
|
||||||
|
|
||||||
|
_width uint32
|
||||||
|
_height uint32
|
||||||
|
_prevWidth uint32
|
||||||
|
_prevHeight uint32
|
||||||
|
|
||||||
|
prevMsgCount int
|
||||||
|
prevPrefs config.UserPreferences
|
||||||
|
|
||||||
|
messageIDLock sync.RWMutex
|
||||||
|
messageIDs map[id.EventID]*messages.UIMessage
|
||||||
|
messagesLock sync.RWMutex
|
||||||
|
messages []*messages.UIMessage
|
||||||
|
msgBufferLock sync.RWMutex
|
||||||
|
msgBuffer []*messages.UIMessage
|
||||||
|
selected *messages.UIMessage
|
||||||
|
|
||||||
|
initialHistoryLoaded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMessageView(parent *RoomView) *MessageView {
|
||||||
|
return &MessageView{
|
||||||
|
parent: parent,
|
||||||
|
config: parent.config,
|
||||||
|
|
||||||
|
MaxSenderWidth: 15,
|
||||||
|
TimestampWidth: len(messages.TimeFormat),
|
||||||
|
ScrollOffset: 0,
|
||||||
|
|
||||||
|
messages: make([]*messages.UIMessage, 0),
|
||||||
|
messageIDs: make(map[id.EventID]*messages.UIMessage),
|
||||||
|
msgBuffer: make([]*messages.UIMessage, 0),
|
||||||
|
|
||||||
|
_widestSender: 5,
|
||||||
|
_prevWidestSender: 0,
|
||||||
|
|
||||||
|
_width: 80,
|
||||||
|
_prevWidth: 0,
|
||||||
|
_prevHeight: 0,
|
||||||
|
prevMsgCount: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) Unload() {
|
||||||
|
debug.Print("Unloading message view", view.parent.Room.ID)
|
||||||
|
view.messagesLock.Lock()
|
||||||
|
view.msgBufferLock.Lock()
|
||||||
|
view.messageIDLock.Lock()
|
||||||
|
view.messageIDs = make(map[id.EventID]*messages.UIMessage)
|
||||||
|
view.msgBuffer = make([]*messages.UIMessage, 0)
|
||||||
|
view.messages = make([]*messages.UIMessage, 0)
|
||||||
|
view.initialHistoryLoaded = false
|
||||||
|
view.ScrollOffset = 0
|
||||||
|
view._widestSender = 5
|
||||||
|
view.prevMsgCount = -1
|
||||||
|
view.historyLoadPtr = 0
|
||||||
|
view.messagesLock.Unlock()
|
||||||
|
view.msgBufferLock.Unlock()
|
||||||
|
view.messageIDLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) updateWidestSender(sender string) {
|
||||||
|
if len(sender) > int(view._widestSender) {
|
||||||
|
if len(sender) > view.MaxSenderWidth {
|
||||||
|
atomic.StoreUint32(&view._widestSender, uint32(view.MaxSenderWidth))
|
||||||
|
} else {
|
||||||
|
atomic.StoreUint32(&view._widestSender, uint32(len(sender)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageDirection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AppendMessage MessageDirection = iota
|
||||||
|
PrependMessage
|
||||||
|
IgnoreMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
func (view *MessageView) AddMessage(ifcMessage ifc.Message, direction MessageDirection) {
|
||||||
|
if ifcMessage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message, ok := ifcMessage.(*messages.UIMessage)
|
||||||
|
if !ok || message == nil {
|
||||||
|
debug.Print("[Warning] Passed non-UIMessage ifc.Message object to AddMessage().")
|
||||||
|
debug.PrintStack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldMsg *messages.UIMessage
|
||||||
|
if oldMsg = view.getMessageByID(message.EventID); oldMsg != nil {
|
||||||
|
view.replaceMessage(oldMsg, message)
|
||||||
|
direction = IgnoreMessage
|
||||||
|
} else if oldMsg = view.getMessageByID(id.EventID(message.TxnID)); oldMsg != nil {
|
||||||
|
view.replaceMessage(oldMsg, message)
|
||||||
|
view.deleteMessageID(id.EventID(message.TxnID))
|
||||||
|
direction = IgnoreMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
view.updateWidestSender(message.Sender())
|
||||||
|
|
||||||
|
width := view.width()
|
||||||
|
bare := view.config.Preferences.BareMessageView
|
||||||
|
if !bare {
|
||||||
|
width -= view.widestSender() + SenderMessageGap
|
||||||
|
if !view.config.Preferences.HideTimestamp {
|
||||||
|
width -= view.TimestampWidth + TimestampSenderGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.CalculateBuffer(view.config.Preferences, width)
|
||||||
|
|
||||||
|
makeDateChange := func(msg *messages.UIMessage) *messages.UIMessage {
|
||||||
|
dateChange := messages.NewDateChangeMessage(
|
||||||
|
fmt.Sprintf("Date changed to %s", msg.FormatDate()))
|
||||||
|
dateChange.CalculateBuffer(view.config.Preferences, width)
|
||||||
|
view.appendBuffer(dateChange)
|
||||||
|
return dateChange
|
||||||
|
}
|
||||||
|
|
||||||
|
if direction == AppendMessage {
|
||||||
|
if view.ScrollOffset > 0 {
|
||||||
|
view.ScrollOffset += message.Height()
|
||||||
|
}
|
||||||
|
view.messagesLock.Lock()
|
||||||
|
if len(view.messages) > 0 && !view.messages[len(view.messages)-1].SameDate(message) {
|
||||||
|
view.messages = append(view.messages, makeDateChange(message), message)
|
||||||
|
} else {
|
||||||
|
view.messages = append(view.messages, message)
|
||||||
|
}
|
||||||
|
view.messagesLock.Unlock()
|
||||||
|
view.appendBuffer(message)
|
||||||
|
} else if direction == PrependMessage {
|
||||||
|
view.messagesLock.Lock()
|
||||||
|
if len(view.messages) > 0 && !view.messages[0].SameDate(message) {
|
||||||
|
view.messages = append([]*messages.UIMessage{message, makeDateChange(view.messages[0])}, view.messages...)
|
||||||
|
} else {
|
||||||
|
view.messages = append([]*messages.UIMessage{message}, view.messages...)
|
||||||
|
}
|
||||||
|
view.messagesLock.Unlock()
|
||||||
|
} else if oldMsg != nil {
|
||||||
|
view.replaceBuffer(oldMsg, message)
|
||||||
|
} else {
|
||||||
|
debug.Print("Unexpected AddMessage() call: Direction is not append or prepend, but message is new.")
|
||||||
|
debug.PrintStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(message.ID()) > 0 {
|
||||||
|
view.setMessageID(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) replaceMessage(original *messages.UIMessage, new *messages.UIMessage) {
|
||||||
|
if len(new.ID()) > 0 {
|
||||||
|
view.setMessageID(new)
|
||||||
|
}
|
||||||
|
view.messagesLock.Lock()
|
||||||
|
for index, msg := range view.messages {
|
||||||
|
if msg == original {
|
||||||
|
view.messages[index] = new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.messagesLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) getMessageByID(id id.EventID) *messages.UIMessage {
|
||||||
|
if id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
view.messageIDLock.RLock()
|
||||||
|
defer view.messageIDLock.RUnlock()
|
||||||
|
msg, ok := view.messageIDs[id]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) deleteMessageID(id id.EventID) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.messageIDLock.Lock()
|
||||||
|
delete(view.messageIDs, id)
|
||||||
|
view.messageIDLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) setMessageID(message *messages.UIMessage) {
|
||||||
|
if message.ID() == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.messageIDLock.Lock()
|
||||||
|
view.messageIDs[message.ID()] = message
|
||||||
|
view.messageIDLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) appendBuffer(message *messages.UIMessage) {
|
||||||
|
view.msgBufferLock.Lock()
|
||||||
|
view.appendBufferUnlocked(message)
|
||||||
|
view.msgBufferLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) appendBufferUnlocked(message *messages.UIMessage) {
|
||||||
|
for i := 0; i < message.Height(); i++ {
|
||||||
|
view.msgBuffer = append(view.msgBuffer, message)
|
||||||
|
}
|
||||||
|
view.prevMsgCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) replaceBuffer(original *messages.UIMessage, new *messages.UIMessage) {
|
||||||
|
start := -1
|
||||||
|
end := -1
|
||||||
|
view.msgBufferLock.RLock()
|
||||||
|
for index, meta := range view.msgBuffer {
|
||||||
|
if meta == original {
|
||||||
|
if start == -1 {
|
||||||
|
start = index
|
||||||
|
}
|
||||||
|
end = index
|
||||||
|
} else if start != -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.msgBufferLock.RUnlock()
|
||||||
|
|
||||||
|
if start == -1 {
|
||||||
|
debug.Print("Called replaceBuffer() with message that was not in the buffer:", original)
|
||||||
|
//debug.PrintStack()
|
||||||
|
view.appendBuffer(new)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(view.msgBuffer) > end {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
|
||||||
|
if new.Height() == 0 {
|
||||||
|
new.CalculateBuffer(view.prevPrefs, view.prevWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
view.msgBufferLock.Lock()
|
||||||
|
if new.Height() != end-start {
|
||||||
|
height := new.Height()
|
||||||
|
|
||||||
|
newBuffer := make([]*messages.UIMessage, height+len(view.msgBuffer)-end)
|
||||||
|
for i := 0; i < height; i++ {
|
||||||
|
newBuffer[i] = new
|
||||||
|
}
|
||||||
|
for i := height; i < len(newBuffer); i++ {
|
||||||
|
newBuffer[i] = view.msgBuffer[end+(i-height)]
|
||||||
|
}
|
||||||
|
view.msgBuffer = append(view.msgBuffer[0:start], newBuffer...)
|
||||||
|
} else {
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
view.msgBuffer[i] = new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.msgBufferLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) recalculateBuffers() {
|
||||||
|
prefs := view.config.Preferences
|
||||||
|
recalculateMessageBuffers := view.width() != view.prevWidth() ||
|
||||||
|
view.widestSender() != view.prevWidestSender() ||
|
||||||
|
view.prevPrefs.BareMessageView != prefs.BareMessageView ||
|
||||||
|
view.prevPrefs.DisableImages != prefs.DisableImages
|
||||||
|
view.messagesLock.RLock()
|
||||||
|
view.msgBufferLock.Lock()
|
||||||
|
if recalculateMessageBuffers || len(view.messages) != view.prevMsgCount {
|
||||||
|
width := view.width()
|
||||||
|
if !prefs.BareMessageView {
|
||||||
|
width -= view.widestSender() + SenderMessageGap
|
||||||
|
if !prefs.HideTimestamp {
|
||||||
|
width -= view.TimestampWidth + TimestampSenderGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.msgBuffer = []*messages.UIMessage{}
|
||||||
|
view.prevMsgCount = 0
|
||||||
|
for i, message := range view.messages {
|
||||||
|
if message == nil {
|
||||||
|
debug.Print("O.o found nil message at", i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if recalculateMessageBuffers {
|
||||||
|
message.CalculateBuffer(prefs, width)
|
||||||
|
}
|
||||||
|
view.appendBufferUnlocked(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.msgBufferLock.Unlock()
|
||||||
|
view.messagesLock.RUnlock()
|
||||||
|
view.updatePrevSize()
|
||||||
|
view.prevPrefs = prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) SetSelected(message *messages.UIMessage) {
|
||||||
|
if view.selected != nil {
|
||||||
|
view.selected.IsSelected = false
|
||||||
|
}
|
||||||
|
if message != nil && (view.selected == message || message.IsService) {
|
||||||
|
view.selected = nil
|
||||||
|
} else {
|
||||||
|
view.selected = message
|
||||||
|
}
|
||||||
|
if view.selected != nil {
|
||||||
|
view.selected.IsSelected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) handleMessageClick(message *messages.UIMessage, mod tcell.ModMask) bool {
|
||||||
|
if msg, ok := message.Renderer.(*messages.FileMessage); ok && mod > 0 && !msg.Thumbnail.IsEmpty() {
|
||||||
|
debug.Print("Opening thumbnail", msg.ThumbnailPath())
|
||||||
|
open.Open(msg.ThumbnailPath())
|
||||||
|
// No need to re-render
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
view.SetSelected(message)
|
||||||
|
view.parent.OnSelect(view.selected)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) handleUsernameClick(message *messages.UIMessage, prevMessage *messages.UIMessage) bool {
|
||||||
|
// TODO this is needed if senders are hidden for messages from the same sender (see Draw method)
|
||||||
|
//if prevMessage != nil && prevMessage.SenderName == message.SenderName {
|
||||||
|
// return false
|
||||||
|
//}
|
||||||
|
|
||||||
|
if message.SenderName == "---" || message.SenderName == "-->" || message.SenderName == "<--" || message.Type == event.MsgEmote {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := fmt.Sprintf("[%s](https://matrix.to/#/%s)", message.SenderName, message.SenderID)
|
||||||
|
|
||||||
|
cursorPos := view.parent.input.GetCursorOffset()
|
||||||
|
text := view.parent.input.GetText()
|
||||||
|
var buf strings.Builder
|
||||||
|
if cursorPos == 0 {
|
||||||
|
buf.WriteString(sender)
|
||||||
|
buf.WriteRune(':')
|
||||||
|
buf.WriteRune(' ')
|
||||||
|
buf.WriteString(text)
|
||||||
|
} else {
|
||||||
|
textBefore := runewidth.Truncate(text, cursorPos, "")
|
||||||
|
textAfter := text[len(textBefore):]
|
||||||
|
buf.WriteString(textBefore)
|
||||||
|
buf.WriteString(sender)
|
||||||
|
buf.WriteRune(' ')
|
||||||
|
buf.WriteString(textAfter)
|
||||||
|
}
|
||||||
|
newText := buf.String()
|
||||||
|
view.parent.input.SetText(string(newText))
|
||||||
|
view.parent.input.SetCursorOffset(cursorPos + len(newText) - len(text))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) OnMouseEvent(event mauview.MouseEvent) bool {
|
||||||
|
if event.HasMotion() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch event.Buttons() {
|
||||||
|
case tcell.WheelUp:
|
||||||
|
if view.IsAtTop() {
|
||||||
|
go view.parent.parent.LoadHistory(view.parent.Room.ID)
|
||||||
|
} else {
|
||||||
|
view.AddScrollOffset(WheelScrollOffsetDiff)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case tcell.WheelDown:
|
||||||
|
view.AddScrollOffset(-WheelScrollOffsetDiff)
|
||||||
|
view.parent.parent.MarkRead(view.parent)
|
||||||
|
return true
|
||||||
|
case tcell.Button1:
|
||||||
|
x, y := event.Position()
|
||||||
|
line := view.TotalHeight() - view.ScrollOffset - view.Height() + y
|
||||||
|
if line < 0 || line >= view.TotalHeight() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
view.msgBufferLock.RLock()
|
||||||
|
message := view.msgBuffer[line]
|
||||||
|
var prevMessage *messages.UIMessage
|
||||||
|
if y != 0 && line > 0 {
|
||||||
|
prevMessage = view.msgBuffer[line-1]
|
||||||
|
}
|
||||||
|
view.msgBufferLock.RUnlock()
|
||||||
|
|
||||||
|
usernameX := 0
|
||||||
|
if !view.config.Preferences.HideTimestamp {
|
||||||
|
usernameX += view.TimestampWidth + TimestampSenderGap
|
||||||
|
}
|
||||||
|
messageX := usernameX + view.widestSender() + SenderMessageGap
|
||||||
|
|
||||||
|
if x >= messageX {
|
||||||
|
return view.handleMessageClick(message, event.Modifiers())
|
||||||
|
} else if x >= usernameX {
|
||||||
|
return view.handleUsernameClick(message, prevMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaddingAtTop = 5
|
||||||
|
|
||||||
|
func (view *MessageView) AddScrollOffset(diff int) {
|
||||||
|
totalHeight := view.TotalHeight()
|
||||||
|
height := view.Height()
|
||||||
|
if diff >= 0 && view.ScrollOffset+diff >= totalHeight-height+PaddingAtTop {
|
||||||
|
view.ScrollOffset = totalHeight - height + PaddingAtTop
|
||||||
|
} else {
|
||||||
|
view.ScrollOffset += diff
|
||||||
|
}
|
||||||
|
|
||||||
|
if view.ScrollOffset > totalHeight-height+PaddingAtTop {
|
||||||
|
view.ScrollOffset = totalHeight - height + PaddingAtTop
|
||||||
|
}
|
||||||
|
if view.ScrollOffset < 0 {
|
||||||
|
view.ScrollOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) setSize(width, height int) {
|
||||||
|
atomic.StoreUint32(&view._width, uint32(width))
|
||||||
|
atomic.StoreUint32(&view._height, uint32(height))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) updatePrevSize() {
|
||||||
|
atomic.StoreUint32(&view._prevWidth, atomic.LoadUint32(&view._width))
|
||||||
|
atomic.StoreUint32(&view._prevHeight, atomic.LoadUint32(&view._height))
|
||||||
|
atomic.StoreUint32(&view._prevWidestSender, atomic.LoadUint32(&view._widestSender))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) prevHeight() int {
|
||||||
|
return int(atomic.LoadUint32(&view._prevHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) prevWidth() int {
|
||||||
|
return int(atomic.LoadUint32(&view._prevWidth))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) prevWidestSender() int {
|
||||||
|
return int(atomic.LoadUint32(&view._prevWidestSender))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) widestSender() int {
|
||||||
|
return int(atomic.LoadUint32(&view._widestSender))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) Height() int {
|
||||||
|
return int(atomic.LoadUint32(&view._height))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) width() int {
|
||||||
|
return int(atomic.LoadUint32(&view._width))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) TotalHeight() int {
|
||||||
|
view.msgBufferLock.RLock()
|
||||||
|
defer view.msgBufferLock.RUnlock()
|
||||||
|
return len(view.msgBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) IsAtTop() bool {
|
||||||
|
return view.ScrollOffset >= view.TotalHeight()-view.Height()+PaddingAtTop
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
TimestampSenderGap = 1
|
||||||
|
SenderSeparatorGap = 1
|
||||||
|
SenderMessageGap = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
func getScrollbarStyle(scrollbarHere, isTop, isBottom bool) (char rune, style tcell.Style) {
|
||||||
|
char = '│'
|
||||||
|
style = tcell.StyleDefault
|
||||||
|
if scrollbarHere {
|
||||||
|
style = style.Foreground(tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
if isTop {
|
||||||
|
if scrollbarHere {
|
||||||
|
char = '╥'
|
||||||
|
} else {
|
||||||
|
char = '┬'
|
||||||
|
}
|
||||||
|
} else if isBottom {
|
||||||
|
if scrollbarHere {
|
||||||
|
char = '╨'
|
||||||
|
} else {
|
||||||
|
char = '┴'
|
||||||
|
}
|
||||||
|
} else if scrollbarHere {
|
||||||
|
char = '║'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) calculateScrollBar(height int) (scrollBarHeight, scrollBarPos int) {
|
||||||
|
viewportHeight := float64(height)
|
||||||
|
contentHeight := float64(view.TotalHeight())
|
||||||
|
|
||||||
|
scrollBarHeight = int(math.Ceil(viewportHeight / (contentHeight / viewportHeight)))
|
||||||
|
|
||||||
|
scrollBarPos = height - int(math.Round(float64(view.ScrollOffset)/contentHeight*viewportHeight))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) getIndexOffset(screen mauview.Screen, height, messageX int) (indexOffset int) {
|
||||||
|
indexOffset = view.TotalHeight() - view.ScrollOffset - height
|
||||||
|
if indexOffset <= -PaddingAtTop {
|
||||||
|
message := "Scroll up to load more messages."
|
||||||
|
if atomic.LoadInt32(&view.loadingMessages) == 1 {
|
||||||
|
message = "Loading more messages..."
|
||||||
|
}
|
||||||
|
widget.WriteLineSimpleColor(screen, message, messageX, 0, tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) CapturePlaintext(height int) string {
|
||||||
|
var buf strings.Builder
|
||||||
|
indexOffset := view.TotalHeight() - view.ScrollOffset - height
|
||||||
|
var prevMessage *messages.UIMessage
|
||||||
|
view.msgBufferLock.RLock()
|
||||||
|
for line := 0; line < height; line++ {
|
||||||
|
index := indexOffset + line
|
||||||
|
if index < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
message := view.msgBuffer[index]
|
||||||
|
if message != prevMessage {
|
||||||
|
var sender string
|
||||||
|
if len(message.Sender()) > 0 {
|
||||||
|
sender = fmt.Sprintf(" <%s>", message.Sender())
|
||||||
|
} else if message.Type == event.MsgEmote {
|
||||||
|
sender = fmt.Sprintf(" * %s", message.SenderName)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&buf, "%s%s %s\n", message.FormatTime(), sender, message.PlainText())
|
||||||
|
prevMessage = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.msgBufferLock.RUnlock()
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) Draw(screen mauview.Screen) {
|
||||||
|
view.setSize(screen.Size())
|
||||||
|
view.recalculateBuffers()
|
||||||
|
|
||||||
|
height := view.Height()
|
||||||
|
if view.TotalHeight() == 0 {
|
||||||
|
widget.WriteLineSimple(screen, "It's quite empty in here.", 0, height)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameX := 0
|
||||||
|
if !view.config.Preferences.HideTimestamp {
|
||||||
|
usernameX += view.TimestampWidth + TimestampSenderGap
|
||||||
|
}
|
||||||
|
messageX := usernameX + view.widestSender() + SenderMessageGap
|
||||||
|
|
||||||
|
bareMode := view.config.Preferences.BareMessageView
|
||||||
|
if bareMode {
|
||||||
|
messageX = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
indexOffset := view.getIndexOffset(screen, height, messageX)
|
||||||
|
|
||||||
|
viewStart := 0
|
||||||
|
if indexOffset < 0 {
|
||||||
|
viewStart = -indexOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bareMode {
|
||||||
|
separatorX := usernameX + view.widestSender() + SenderSeparatorGap
|
||||||
|
scrollBarHeight, scrollBarPos := view.calculateScrollBar(height)
|
||||||
|
|
||||||
|
for line := viewStart; line < height; line++ {
|
||||||
|
showScrollbar := line-viewStart >= scrollBarPos-scrollBarHeight && line-viewStart < scrollBarPos
|
||||||
|
isTop := line == viewStart && view.ScrollOffset+height >= view.TotalHeight()
|
||||||
|
isBottom := line == height-1 && view.ScrollOffset == 0
|
||||||
|
|
||||||
|
borderChar, borderStyle := getScrollbarStyle(showScrollbar, isTop, isBottom)
|
||||||
|
|
||||||
|
screen.SetContent(separatorX, line, borderChar, nil, borderStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var prevMsg *messages.UIMessage
|
||||||
|
view.msgBufferLock.RLock()
|
||||||
|
for line := viewStart; line < height && indexOffset+line < len(view.msgBuffer); {
|
||||||
|
index := indexOffset + line
|
||||||
|
|
||||||
|
msg := view.msgBuffer[index]
|
||||||
|
if msg == prevMsg {
|
||||||
|
debug.Print("Unexpected re-encounter of", msg, msg.Height(), "at", line, index)
|
||||||
|
line++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.FormatTime()) > 0 && !view.config.Preferences.HideTimestamp {
|
||||||
|
widget.WriteLineSimpleColor(screen, msg.FormatTime(), 0, line, msg.TimestampColor())
|
||||||
|
}
|
||||||
|
// TODO hiding senders might not be that nice after all, maybe an option? (disabled for now)
|
||||||
|
//if !bareMode && (prevMsg == nil || meta.Sender() != prevMsg.Sender()) {
|
||||||
|
widget.WriteLineColor(
|
||||||
|
screen, mauview.AlignRight, msg.Sender(),
|
||||||
|
usernameX, line, view.widestSender(),
|
||||||
|
msg.SenderColor())
|
||||||
|
//}
|
||||||
|
if msg.Edited {
|
||||||
|
// TODO add better indicator for edits
|
||||||
|
screen.SetCell(usernameX+view.widestSender(), line, tcell.StyleDefault.Foreground(tcell.ColorDarkRed), '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := index - 1; i >= 0 && view.msgBuffer[i] == msg; i-- {
|
||||||
|
line--
|
||||||
|
}
|
||||||
|
msg.Draw(mauview.NewProxyScreen(screen, messageX, line, view.width()-messageX, msg.Height()))
|
||||||
|
line += msg.Height()
|
||||||
|
|
||||||
|
prevMsg = msg
|
||||||
|
}
|
||||||
|
view.msgBufferLock.RUnlock()
|
||||||
|
}
|
397
tui/messages/base.go
Normal file
397
tui/messages/base.go
Normal file
|
@ -0,0 +1,397 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
/*
|
||||||
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/matrix/muksevt"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageRenderer interface {
|
||||||
|
Draw(screen mauview.Screen, msg *UIMessage)
|
||||||
|
NotificationContent() string
|
||||||
|
PlainText() string
|
||||||
|
CalculateBuffer(prefs config.UserPreferences, width int, msg *UIMessage)
|
||||||
|
Height() int
|
||||||
|
Clone() MessageRenderer
|
||||||
|
String() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReactionItem struct {
|
||||||
|
Key string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ri ReactionItem) String() string {
|
||||||
|
return fmt.Sprintf("%d×%s", ri.Count, ri.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReactionSlice []ReactionItem
|
||||||
|
|
||||||
|
func (rs ReactionSlice) Len() int {
|
||||||
|
return len(rs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs ReactionSlice) Less(i, j int) bool {
|
||||||
|
return rs[i].Key < rs[j].Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs ReactionSlice) Swap(i, j int) {
|
||||||
|
rs[i], rs[j] = rs[j], rs[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
type UIMessage struct {
|
||||||
|
EventID id.EventID
|
||||||
|
TxnID string
|
||||||
|
Relation event.RelatesTo
|
||||||
|
Type event.MessageType
|
||||||
|
SenderID id.UserID
|
||||||
|
SenderName string
|
||||||
|
DefaultSenderColor tcell.Color
|
||||||
|
Timestamp time.Time
|
||||||
|
State muksevt.OutgoingState
|
||||||
|
IsHighlight bool
|
||||||
|
IsService bool
|
||||||
|
IsSelected bool
|
||||||
|
Edited bool
|
||||||
|
Event *muksevt.Event
|
||||||
|
ReplyTo *UIMessage
|
||||||
|
Reactions ReactionSlice
|
||||||
|
Renderer MessageRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) GetEvent() *muksevt.Event {
|
||||||
|
if msg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return msg.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateFormat = "January _2, 2006"
|
||||||
|
const TimeFormat = "15:04:05"
|
||||||
|
|
||||||
|
func newUIMessage(evt *muksevt.Event, displayname string, renderer MessageRenderer) *UIMessage {
|
||||||
|
msgContent := evt.Content.AsMessage()
|
||||||
|
msgtype := msgContent.MsgType
|
||||||
|
if len(msgtype) == 0 {
|
||||||
|
msgtype = event.MessageType(evt.Type.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
reactions := make(ReactionSlice, 0, len(evt.Unsigned.Relations.Annotations.Map))
|
||||||
|
for key, count := range evt.Unsigned.Relations.Annotations.Map {
|
||||||
|
reactions = append(reactions, ReactionItem{
|
||||||
|
Key: key,
|
||||||
|
Count: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Sort(reactions)
|
||||||
|
|
||||||
|
return &UIMessage{
|
||||||
|
SenderID: evt.Sender,
|
||||||
|
SenderName: displayname,
|
||||||
|
Timestamp: unixToTime(evt.Timestamp),
|
||||||
|
DefaultSenderColor: widget.GetHashColor(evt.Sender),
|
||||||
|
Type: msgtype,
|
||||||
|
EventID: evt.ID,
|
||||||
|
TxnID: evt.Unsigned.TransactionID,
|
||||||
|
Relation: *msgContent.GetRelatesTo(),
|
||||||
|
State: evt.Gomuks.OutgoingState,
|
||||||
|
IsHighlight: false,
|
||||||
|
IsService: false,
|
||||||
|
Edited: len(evt.Gomuks.Edits) > 0,
|
||||||
|
Reactions: reactions,
|
||||||
|
Event: evt,
|
||||||
|
Renderer: renderer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) AddReaction(key string) {
|
||||||
|
found := false
|
||||||
|
for i, rs := range msg.Reactions {
|
||||||
|
if rs.Key == key {
|
||||||
|
rs.Count++
|
||||||
|
msg.Reactions[i] = rs
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
msg.Reactions = append(msg.Reactions, ReactionItem{
|
||||||
|
Key: key,
|
||||||
|
Count: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Sort(msg.Reactions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unixToTime(unix int64) time.Time {
|
||||||
|
timestamp := time.Now()
|
||||||
|
if unix != 0 {
|
||||||
|
timestamp = time.Unix(unix/1000, unix%1000*1000)
|
||||||
|
}
|
||||||
|
return timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender gets the string that should be displayed as the sender of this message.
|
||||||
|
//
|
||||||
|
// If the message is being sent, the sender is "Sending...".
|
||||||
|
// If sending has failed, the sender is "Error".
|
||||||
|
// If the message is an emote, the sender is blank.
|
||||||
|
// In any other case, the sender is the display name of the user who sent the message.
|
||||||
|
func (msg *UIMessage) Sender() string {
|
||||||
|
switch msg.State {
|
||||||
|
case muksevt.StateLocalEcho:
|
||||||
|
return "Sending..."
|
||||||
|
case muksevt.StateSendFail:
|
||||||
|
return "Error"
|
||||||
|
}
|
||||||
|
switch msg.Type {
|
||||||
|
case "m.emote":
|
||||||
|
// Emotes don't show a separate sender, it's included in the buffer.
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return msg.SenderName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) NotificationSenderName() string {
|
||||||
|
return msg.SenderName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) NotificationContent() string {
|
||||||
|
return msg.Renderer.NotificationContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) getStateSpecificColor() tcell.Color {
|
||||||
|
switch msg.State {
|
||||||
|
case muksevt.StateLocalEcho:
|
||||||
|
return tcell.ColorGray
|
||||||
|
case muksevt.StateSendFail:
|
||||||
|
return tcell.ColorRed
|
||||||
|
case muksevt.StateDefault:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
return tcell.ColorDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SenderColor returns the color the name of the sender should be shown in.
|
||||||
|
//
|
||||||
|
// If the message is being sent, the color is gray.
|
||||||
|
// If sending has failed, the color is red.
|
||||||
|
//
|
||||||
|
// In any other case, the color is whatever is specified in the Message struct.
|
||||||
|
// Usually that means it is the hash-based color of the sender (see ui/widget/color.go)
|
||||||
|
func (msg *UIMessage) SenderColor() tcell.Color {
|
||||||
|
stateColor := msg.getStateSpecificColor()
|
||||||
|
switch {
|
||||||
|
case stateColor != tcell.ColorDefault:
|
||||||
|
return stateColor
|
||||||
|
case msg.Type == "m.room.member":
|
||||||
|
return widget.GetHashColor(msg.SenderName)
|
||||||
|
case msg.IsService:
|
||||||
|
return tcell.ColorGray
|
||||||
|
default:
|
||||||
|
return msg.DefaultSenderColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextColor returns the color the actual content of the message should be shown in.
|
||||||
|
func (msg *UIMessage) TextColor() tcell.Color {
|
||||||
|
stateColor := msg.getStateSpecificColor()
|
||||||
|
switch {
|
||||||
|
case stateColor != tcell.ColorDefault:
|
||||||
|
return stateColor
|
||||||
|
case msg.IsService, msg.Type == "m.notice":
|
||||||
|
return tcell.ColorGray
|
||||||
|
case msg.IsHighlight:
|
||||||
|
return tcell.ColorYellow
|
||||||
|
case msg.Type == "m.room.member":
|
||||||
|
return tcell.ColorGreen
|
||||||
|
default:
|
||||||
|
return tcell.ColorDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimestampColor returns the color the timestamp should be shown in.
|
||||||
|
//
|
||||||
|
// As with SenderColor(), messages being sent and messages that failed to be sent are
|
||||||
|
// gray and red respectively.
|
||||||
|
//
|
||||||
|
// However, other messages are the default color instead of a color stored in the struct.
|
||||||
|
func (msg *UIMessage) TimestampColor() tcell.Color {
|
||||||
|
if msg.IsService {
|
||||||
|
return tcell.ColorGray
|
||||||
|
}
|
||||||
|
return msg.getStateSpecificColor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) ReplyHeight() int {
|
||||||
|
if msg.ReplyTo != nil {
|
||||||
|
return 1 + msg.ReplyTo.Height()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) ReactionHeight() int {
|
||||||
|
if len(msg.Reactions) > 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height returns the number of rows in the computed buffer (see Buffer()).
|
||||||
|
func (msg *UIMessage) Height() int {
|
||||||
|
return msg.ReplyHeight() + msg.Renderer.Height() + msg.ReactionHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) Time() time.Time {
|
||||||
|
return msg.Timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTime returns the formatted time when the message was sent.
|
||||||
|
func (msg *UIMessage) FormatTime() string {
|
||||||
|
return msg.Timestamp.Format(TimeFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDate returns the formatted date when the message was sent.
|
||||||
|
func (msg *UIMessage) FormatDate() string {
|
||||||
|
return msg.Timestamp.Format(DateFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) SameDate(message *UIMessage) bool {
|
||||||
|
year1, month1, day1 := msg.Timestamp.Date()
|
||||||
|
year2, month2, day2 := message.Timestamp.Date()
|
||||||
|
return day1 == day2 && month1 == month2 && year1 == year2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) ID() id.EventID {
|
||||||
|
if len(msg.EventID) == 0 {
|
||||||
|
return id.EventID(msg.TxnID)
|
||||||
|
}
|
||||||
|
return msg.EventID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) SetID(id id.EventID) {
|
||||||
|
msg.EventID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) SetIsHighlight(isHighlight bool) {
|
||||||
|
msg.IsHighlight = isHighlight
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) DrawReactions(screen mauview.Screen) {
|
||||||
|
if len(msg.Reactions) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
width, height := screen.Size()
|
||||||
|
screen = mauview.NewProxyScreen(screen, 0, height-1, width, 1)
|
||||||
|
|
||||||
|
x := 0
|
||||||
|
for _, reaction := range msg.Reactions {
|
||||||
|
_, drawn := mauview.PrintWithStyle(screen, reaction.String(), x, 0, width-x, mauview.AlignLeft, tcell.StyleDefault.Foreground(mauview.Styles.PrimaryTextColor).Background(tcell.ColorDarkGreen))
|
||||||
|
x += drawn + 1
|
||||||
|
if x >= width {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) Draw(screen mauview.Screen) {
|
||||||
|
proxyScreen := msg.DrawReply(screen)
|
||||||
|
msg.Renderer.Draw(proxyScreen, msg)
|
||||||
|
msg.DrawReactions(proxyScreen)
|
||||||
|
if msg.IsSelected {
|
||||||
|
w, h := screen.Size()
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
mainc, combc, style, _ := screen.GetContent(x, y)
|
||||||
|
_, bg, _ := style.Decompose()
|
||||||
|
if bg == tcell.ColorDefault {
|
||||||
|
screen.SetContent(x, y, mainc, combc, style.Background(tcell.ColorDarkGreen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) Clone() *UIMessage {
|
||||||
|
clone := *msg
|
||||||
|
clone.ReplyTo = nil
|
||||||
|
clone.Reactions = nil
|
||||||
|
clone.Renderer = clone.Renderer.Clone()
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) CalculateReplyBuffer(preferences config.UserPreferences, width int) {
|
||||||
|
if msg.ReplyTo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.ReplyTo.CalculateBuffer(preferences, width-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) CalculateBuffer(preferences config.UserPreferences, width int) {
|
||||||
|
msg.Renderer.CalculateBuffer(preferences, width, msg)
|
||||||
|
msg.CalculateReplyBuffer(preferences, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) DrawReply(screen mauview.Screen) mauview.Screen {
|
||||||
|
if msg.ReplyTo == nil {
|
||||||
|
return screen
|
||||||
|
}
|
||||||
|
width, height := screen.Size()
|
||||||
|
replyHeight := msg.ReplyTo.Height()
|
||||||
|
widget.WriteLineSimpleColor(screen, "In reply to", 1, 0, tcell.ColorGreen)
|
||||||
|
widget.WriteLineSimpleColor(screen, msg.ReplyTo.SenderName, 13, 0, msg.ReplyTo.SenderColor())
|
||||||
|
for y := 0; y < 1+replyHeight; y++ {
|
||||||
|
screen.SetCell(0, y, tcell.StyleDefault, '▊')
|
||||||
|
}
|
||||||
|
replyScreen := mauview.NewProxyScreen(screen, 1, 1, width-1, replyHeight)
|
||||||
|
msg.ReplyTo.Draw(replyScreen)
|
||||||
|
return mauview.NewProxyScreen(screen, 0, replyHeight+1, width, height-replyHeight-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) String() string {
|
||||||
|
return fmt.Sprintf(`&messages.UIMessage{
|
||||||
|
ID="%s", TxnID="%s",
|
||||||
|
Type="%s", Timestamp=%s,
|
||||||
|
Sender={ID="%s", Name="%s", Color=#%X},
|
||||||
|
IsService=%t, IsHighlight=%t,
|
||||||
|
Renderer=%s,
|
||||||
|
}`,
|
||||||
|
msg.EventID, msg.TxnID,
|
||||||
|
msg.Type, msg.Timestamp.String(),
|
||||||
|
msg.SenderID, msg.SenderName, msg.DefaultSenderColor.Hex(),
|
||||||
|
msg.IsService, msg.IsHighlight, msg.Renderer.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *UIMessage) PlainText() string {
|
||||||
|
return msg.Renderer.PlainText()
|
||||||
|
}
|
2
tui/messages/doc.go
Normal file
2
tui/messages/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Package messages contains different message types and code to generate and render them.
|
||||||
|
package messages
|
102
tui/messages/expandedtextmessage.go
Normal file
102
tui/messages/expandedtextmessage.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
/*
|
||||||
|
"maunium.net/go/gomuks/matrix/muksevt"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
*/)
|
||||||
|
|
||||||
|
type ExpandedTextMessage struct {
|
||||||
|
Text tstring.TString
|
||||||
|
buffer []tstring.TString
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExpandedTextMessage creates a new ExpandedTextMessage object with the provided values and the default state.
|
||||||
|
func NewExpandedTextMessage(evt *muksevt.Event, displayname string, text tstring.TString) *UIMessage {
|
||||||
|
return newUIMessage(evt, displayname, &ExpandedTextMessage{
|
||||||
|
Text: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServiceMessage(text string) *UIMessage {
|
||||||
|
return &UIMessage{
|
||||||
|
SenderID: "*",
|
||||||
|
SenderName: "*",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
IsService: true,
|
||||||
|
Renderer: &ExpandedTextMessage{
|
||||||
|
Text: tstring.NewTString(text),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDateChangeMessage(text string) *UIMessage {
|
||||||
|
midnight := time.Now()
|
||||||
|
midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(),
|
||||||
|
0, 0, 0, 0,
|
||||||
|
midnight.Location())
|
||||||
|
return &UIMessage{
|
||||||
|
SenderID: "*",
|
||||||
|
SenderName: "*",
|
||||||
|
Timestamp: midnight,
|
||||||
|
IsService: true,
|
||||||
|
Renderer: &ExpandedTextMessage{
|
||||||
|
Text: tstring.NewColorTString(text, tcell.ColorGreen),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) Clone() MessageRenderer {
|
||||||
|
return &ExpandedTextMessage{
|
||||||
|
Text: msg.Text.Clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) NotificationContent() string {
|
||||||
|
return msg.Text.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) PlainText() string {
|
||||||
|
return msg.Text.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) String() string {
|
||||||
|
return fmt.Sprintf(`&messages.ExpandedTextMessage{Text="%s"}`, msg.Text.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) {
|
||||||
|
msg.buffer = calculateBufferWithText(prefs, msg.Text, width, uiMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) Height() int {
|
||||||
|
return len(msg.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) Draw(screen mauview.Screen, _ *UIMessage) {
|
||||||
|
for y, line := range msg.buffer {
|
||||||
|
line.Draw(screen, 0, y)
|
||||||
|
}
|
||||||
|
}
|
190
tui/messages/filemessage.go
Normal file
190
tui/messages/filemessage.go
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/crypto/attachment"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
/*
|
||||||
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
ifc "maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/lib/ansimage"
|
||||||
|
"maunium.net/go/gomuks/matrix/muksevt"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
*/)
|
||||||
|
|
||||||
|
type FileMessage struct {
|
||||||
|
Type event.MessageType
|
||||||
|
Body string
|
||||||
|
|
||||||
|
URL id.ContentURI
|
||||||
|
File *attachment.EncryptedFile
|
||||||
|
Thumbnail id.ContentURI
|
||||||
|
ThumbnailFile *attachment.EncryptedFile
|
||||||
|
|
||||||
|
eventID id.EventID
|
||||||
|
|
||||||
|
imageData []byte
|
||||||
|
buffer []tstring.TString
|
||||||
|
|
||||||
|
matrix ifc.MatrixContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileMessage creates a new FileMessage object with the provided values and the default state.
|
||||||
|
func NewFileMessage(matrix ifc.MatrixContainer, evt *muksevt.Event, displayname string) *UIMessage {
|
||||||
|
content := evt.Content.AsMessage()
|
||||||
|
var file, thumbnailFile *attachment.EncryptedFile
|
||||||
|
if content.File != nil {
|
||||||
|
file = &content.File.EncryptedFile
|
||||||
|
content.URL = content.File.URL
|
||||||
|
}
|
||||||
|
if content.GetInfo().ThumbnailFile != nil {
|
||||||
|
thumbnailFile = &content.Info.ThumbnailFile.EncryptedFile
|
||||||
|
content.Info.ThumbnailURL = content.Info.ThumbnailFile.URL
|
||||||
|
}
|
||||||
|
return newUIMessage(evt, displayname, &FileMessage{
|
||||||
|
Type: content.MsgType,
|
||||||
|
Body: content.Body,
|
||||||
|
URL: content.URL.ParseOrIgnore(),
|
||||||
|
File: file,
|
||||||
|
Thumbnail: content.GetInfo().ThumbnailURL.ParseOrIgnore(),
|
||||||
|
ThumbnailFile: thumbnailFile,
|
||||||
|
eventID: evt.ID,
|
||||||
|
matrix: matrix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *FileMessage) Clone() MessageRenderer {
|
||||||
|
data := make([]byte, len(msg.imageData))
|
||||||
|
copy(data, msg.imageData)
|
||||||
|
return &FileMessage{
|
||||||
|
Body: msg.Body,
|
||||||
|
URL: msg.URL,
|
||||||
|
Thumbnail: msg.Thumbnail,
|
||||||
|
imageData: data,
|
||||||
|
matrix: msg.matrix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *FileMessage) NotificationContent() string {
|
||||||
|
switch msg.Type {
|
||||||
|
case event.MsgImage:
|
||||||
|
return "Sent an image"
|
||||||
|
case event.MsgAudio:
|
||||||
|
return "Sent an audio file"
|
||||||
|
case event.MsgVideo:
|
||||||
|
return "Sent a video"
|
||||||
|
case event.MsgFile:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
return "Sent a file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *FileMessage) PlainText() string {
|
||||||
|
return fmt.Sprintf("%s: %s", msg.Body, msg.matrix.GetDownloadURL(msg.URL, msg.File))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *FileMessage) String() string {
|
||||||
|
return fmt.Sprintf(`&messages.FileMessage{Body="%s", URL="%s", Thumbnail="%s"}`, msg.Body, msg.URL, msg.Thumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *FileMessage) DownloadPreview() {
|
||||||
|
var url id.ContentURI
|
||||||
|
var file *attachment.EncryptedFile
|
||||||
|
if !msg.Thumbnail.IsEmpty() {
|
||||||
|
url = msg.Thumbnail
|
||||||
|
file = msg.ThumbnailFile
|
||||||
|
} else if msg.Type == event.MsgImage && !msg.URL.IsEmpty() {
|
||||||
|
msg.Thumbnail = msg.URL
|
||||||
|
url = msg.URL
|
||||||
|
file = msg.File
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debug.Print("Loading file:", url)
|
||||||
|
data, err := msg.matrix.Download(url, file)
|
||||||
|
if err != nil {
|
||||||
|
debug.Printf("Failed to download file %s: %v", url, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debug.Print("File", url, "loaded.")
|
||||||
|
msg.imageData = data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *FileMessage) ThumbnailPath() string {
|
||||||
|
return msg.matrix.GetCachePath(msg.Thumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *FileMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) {
|
||||||
|
if width < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefs.BareMessageView || prefs.DisableImages || len(msg.imageData) == 0 {
|
||||||
|
url := msg.matrix.GetDownloadURL(msg.URL, msg.File)
|
||||||
|
var urlTString tstring.TString
|
||||||
|
if prefs.EnableInlineURLs() {
|
||||||
|
urlTString = tstring.NewStyleTString(url, tcell.StyleDefault.Url(url).UrlId(msg.eventID.String()))
|
||||||
|
} else {
|
||||||
|
urlTString = tstring.NewTString(url)
|
||||||
|
}
|
||||||
|
text := tstring.NewTString(msg.Body).
|
||||||
|
Append(": ").
|
||||||
|
AppendTString(urlTString)
|
||||||
|
msg.buffer = calculateBufferWithText(prefs, text, width, uiMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
img, _, err := image.DecodeConfig(bytes.NewReader(msg.imageData))
|
||||||
|
if err != nil {
|
||||||
|
debug.Print("File could not be decoded:", err)
|
||||||
|
}
|
||||||
|
imgWidth := img.Width
|
||||||
|
if img.Width > width {
|
||||||
|
imgWidth = width / 3
|
||||||
|
}
|
||||||
|
|
||||||
|
ansFile, err := ansimage.NewScaledFromReader(bytes.NewReader(msg.imageData), 0, imgWidth, color.Black)
|
||||||
|
if err != nil {
|
||||||
|
msg.buffer = []tstring.TString{tstring.NewColorTString("Failed to display image", tcell.ColorRed)}
|
||||||
|
debug.Print("Failed to display image:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.buffer = ansFile.Render()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *FileMessage) Height() int {
|
||||||
|
return len(msg.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *FileMessage) Draw(screen mauview.Screen, _ *UIMessage) {
|
||||||
|
for y, line := range msg.buffer {
|
||||||
|
line.Draw(screen, 0, y)
|
||||||
|
}
|
||||||
|
}
|
101
tui/messages/html/base.go
Normal file
101
tui/messages/html/base.go
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BaseEntity struct {
|
||||||
|
// The HTML tag of this entity.
|
||||||
|
Tag string
|
||||||
|
// Style for this entity.
|
||||||
|
Style tcell.Style
|
||||||
|
// Whether or not this is a block-type entity.
|
||||||
|
Block bool
|
||||||
|
// Height to use for entity if both text and children are empty.
|
||||||
|
DefaultHeight int
|
||||||
|
|
||||||
|
prevWidth int
|
||||||
|
startX int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustStyle changes the style of this text entity.
|
||||||
|
func (be *BaseEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||||
|
be.Style = fn(be.Style)
|
||||||
|
return be
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BaseEntity) IsEmpty() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlock returns whether or not this is a block-type entity.
|
||||||
|
func (be *BaseEntity) IsBlock() bool {
|
||||||
|
return be.Block
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTag returns the HTML tag of this entity.
|
||||||
|
func (be *BaseEntity) GetTag() string {
|
||||||
|
return be.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height returns the render height of this entity.
|
||||||
|
func (be *BaseEntity) Height() int {
|
||||||
|
return be.height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BaseEntity) getStartX() int {
|
||||||
|
return be.startX
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone creates a copy of this base entity.
|
||||||
|
func (be *BaseEntity) Clone() Entity {
|
||||||
|
return &BaseEntity{
|
||||||
|
Tag: be.Tag,
|
||||||
|
Style: be.Style,
|
||||||
|
Block: be.Block,
|
||||||
|
DefaultHeight: be.DefaultHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BaseEntity) PlainText() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a textual representation of this BaseEntity struct.
|
||||||
|
func (be *BaseEntity) String() string {
|
||||||
|
return fmt.Sprintf(`&html.BaseEntity{Tag="%s", Style=%#v, Block=%t, startX=%d, height=%d}`,
|
||||||
|
be.Tag, be.Style, be.Block, be.startX, be.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateBuffer prepares this entity for rendering with the given parameters.
|
||||||
|
func (be *BaseEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
|
||||||
|
be.height = be.DefaultHeight
|
||||||
|
be.startX = startX
|
||||||
|
if be.Block {
|
||||||
|
be.startX = 0
|
||||||
|
}
|
||||||
|
return be.startX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BaseEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||||
|
panic("Called Draw() of BaseEntity")
|
||||||
|
}
|
88
tui/messages/html/blockquote.go
Normal file
88
tui/messages/html/blockquote.go
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BlockquoteEntity struct {
|
||||||
|
*ContainerEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlockQuoteChar = '>'
|
||||||
|
|
||||||
|
func NewBlockquoteEntity(children []Entity) *BlockquoteEntity {
|
||||||
|
return &BlockquoteEntity{&ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: "blockquote",
|
||||||
|
Block: true,
|
||||||
|
},
|
||||||
|
Children: children,
|
||||||
|
Indent: 2,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BlockquoteEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||||
|
be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
|
||||||
|
return be
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BlockquoteEntity) Clone() Entity {
|
||||||
|
return &BlockquoteEntity{ContainerEntity: be.ContainerEntity.Clone().(*ContainerEntity)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BlockquoteEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||||
|
be.ContainerEntity.Draw(screen, ctx)
|
||||||
|
for y := 0; y < be.height; y++ {
|
||||||
|
screen.SetContent(0, y, BlockQuoteChar, nil, be.Style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BlockquoteEntity) PlainText() string {
|
||||||
|
if len(be.Children) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
newlined := false
|
||||||
|
for i, child := range be.Children {
|
||||||
|
if i != 0 && child.IsBlock() && !newlined {
|
||||||
|
buf.WriteRune('\n')
|
||||||
|
}
|
||||||
|
newlined = false
|
||||||
|
for i, row := range strings.Split(child.PlainText(), "\n") {
|
||||||
|
if i != 0 {
|
||||||
|
buf.WriteRune('\n')
|
||||||
|
}
|
||||||
|
buf.WriteRune('>')
|
||||||
|
buf.WriteRune(' ')
|
||||||
|
buf.WriteString(row)
|
||||||
|
}
|
||||||
|
if child.IsBlock() {
|
||||||
|
buf.WriteRune('\n')
|
||||||
|
newlined = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BlockquoteEntity) String() string {
|
||||||
|
return fmt.Sprintf("&html.BlockquoteEntity{%s},\n", be.BaseEntity)
|
||||||
|
}
|
54
tui/messages/html/break.go
Normal file
54
tui/messages/html/break.go
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BreakEntity struct {
|
||||||
|
*BaseEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBreakEntity() *BreakEntity {
|
||||||
|
return &BreakEntity{&BaseEntity{
|
||||||
|
Tag: "br",
|
||||||
|
Block: true,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustStyle changes the style of this text entity.
|
||||||
|
func (be *BreakEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||||
|
be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
|
||||||
|
return be
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BreakEntity) Clone() Entity {
|
||||||
|
return NewBreakEntity()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BreakEntity) PlainText() string {
|
||||||
|
return "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BreakEntity) String() string {
|
||||||
|
return "&html.BreakEntity{},\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *BreakEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||||
|
// No-op, the logic happens in containers
|
||||||
|
}
|
59
tui/messages/html/codeblock.go
Normal file
59
tui/messages/html/codeblock.go
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CodeBlockEntity struct {
|
||||||
|
*ContainerEntity
|
||||||
|
Background tcell.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCodeBlockEntity(children []Entity, background tcell.Style) *CodeBlockEntity {
|
||||||
|
return &CodeBlockEntity{
|
||||||
|
ContainerEntity: &ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: "pre",
|
||||||
|
Block: true,
|
||||||
|
},
|
||||||
|
Children: children,
|
||||||
|
},
|
||||||
|
Background: background,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ce *CodeBlockEntity) Clone() Entity {
|
||||||
|
return &CodeBlockEntity{
|
||||||
|
ContainerEntity: ce.ContainerEntity.Clone().(*ContainerEntity),
|
||||||
|
Background: ce.Background,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ce *CodeBlockEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||||
|
screen.Fill(' ', ce.Background)
|
||||||
|
ce.ContainerEntity.Draw(screen, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ce *CodeBlockEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||||
|
if reason != AdjustStyleReasonNormal {
|
||||||
|
ce.ContainerEntity.AdjustStyle(fn, reason)
|
||||||
|
}
|
||||||
|
return ce
|
||||||
|
}
|
156
tui/messages/html/colormap.go
Normal file
156
tui/messages/html/colormap.go
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
// From https://github.com/golang/image/blob/master/colornames/colornames.go
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
var colorMap = map[string]color.RGBA{
|
||||||
|
"aliceblue": {0xf0, 0xf8, 0xff, 0xff}, // rgb(240, 248, 255)
|
||||||
|
"antiquewhite": {0xfa, 0xeb, 0xd7, 0xff}, // rgb(250, 235, 215)
|
||||||
|
"aqua": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255)
|
||||||
|
"aquamarine": {0x7f, 0xff, 0xd4, 0xff}, // rgb(127, 255, 212)
|
||||||
|
"azure": {0xf0, 0xff, 0xff, 0xff}, // rgb(240, 255, 255)
|
||||||
|
"beige": {0xf5, 0xf5, 0xdc, 0xff}, // rgb(245, 245, 220)
|
||||||
|
"bisque": {0xff, 0xe4, 0xc4, 0xff}, // rgb(255, 228, 196)
|
||||||
|
"black": {0x00, 0x00, 0x00, 0xff}, // rgb(0, 0, 0)
|
||||||
|
"blanchedalmond": {0xff, 0xeb, 0xcd, 0xff}, // rgb(255, 235, 205)
|
||||||
|
"blue": {0x00, 0x00, 0xff, 0xff}, // rgb(0, 0, 255)
|
||||||
|
"blueviolet": {0x8a, 0x2b, 0xe2, 0xff}, // rgb(138, 43, 226)
|
||||||
|
"brown": {0xa5, 0x2a, 0x2a, 0xff}, // rgb(165, 42, 42)
|
||||||
|
"burlywood": {0xde, 0xb8, 0x87, 0xff}, // rgb(222, 184, 135)
|
||||||
|
"cadetblue": {0x5f, 0x9e, 0xa0, 0xff}, // rgb(95, 158, 160)
|
||||||
|
"chartreuse": {0x7f, 0xff, 0x00, 0xff}, // rgb(127, 255, 0)
|
||||||
|
"chocolate": {0xd2, 0x69, 0x1e, 0xff}, // rgb(210, 105, 30)
|
||||||
|
"coral": {0xff, 0x7f, 0x50, 0xff}, // rgb(255, 127, 80)
|
||||||
|
"cornflowerblue": {0x64, 0x95, 0xed, 0xff}, // rgb(100, 149, 237)
|
||||||
|
"cornsilk": {0xff, 0xf8, 0xdc, 0xff}, // rgb(255, 248, 220)
|
||||||
|
"crimson": {0xdc, 0x14, 0x3c, 0xff}, // rgb(220, 20, 60)
|
||||||
|
"cyan": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255)
|
||||||
|
"darkblue": {0x00, 0x00, 0x8b, 0xff}, // rgb(0, 0, 139)
|
||||||
|
"darkcyan": {0x00, 0x8b, 0x8b, 0xff}, // rgb(0, 139, 139)
|
||||||
|
"darkgoldenrod": {0xb8, 0x86, 0x0b, 0xff}, // rgb(184, 134, 11)
|
||||||
|
"darkgray": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169)
|
||||||
|
"darkgreen": {0x00, 0x64, 0x00, 0xff}, // rgb(0, 100, 0)
|
||||||
|
"darkgrey": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169)
|
||||||
|
"darkkhaki": {0xbd, 0xb7, 0x6b, 0xff}, // rgb(189, 183, 107)
|
||||||
|
"darkmagenta": {0x8b, 0x00, 0x8b, 0xff}, // rgb(139, 0, 139)
|
||||||
|
"darkolivegreen": {0x55, 0x6b, 0x2f, 0xff}, // rgb(85, 107, 47)
|
||||||
|
"darkorange": {0xff, 0x8c, 0x00, 0xff}, // rgb(255, 140, 0)
|
||||||
|
"darkorchid": {0x99, 0x32, 0xcc, 0xff}, // rgb(153, 50, 204)
|
||||||
|
"darkred": {0x8b, 0x00, 0x00, 0xff}, // rgb(139, 0, 0)
|
||||||
|
"darksalmon": {0xe9, 0x96, 0x7a, 0xff}, // rgb(233, 150, 122)
|
||||||
|
"darkseagreen": {0x8f, 0xbc, 0x8f, 0xff}, // rgb(143, 188, 143)
|
||||||
|
"darkslateblue": {0x48, 0x3d, 0x8b, 0xff}, // rgb(72, 61, 139)
|
||||||
|
"darkslategray": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79)
|
||||||
|
"darkslategrey": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79)
|
||||||
|
"darkturquoise": {0x00, 0xce, 0xd1, 0xff}, // rgb(0, 206, 209)
|
||||||
|
"darkviolet": {0x94, 0x00, 0xd3, 0xff}, // rgb(148, 0, 211)
|
||||||
|
"deeppink": {0xff, 0x14, 0x93, 0xff}, // rgb(255, 20, 147)
|
||||||
|
"deepskyblue": {0x00, 0xbf, 0xff, 0xff}, // rgb(0, 191, 255)
|
||||||
|
"dimgray": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105)
|
||||||
|
"dimgrey": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105)
|
||||||
|
"dodgerblue": {0x1e, 0x90, 0xff, 0xff}, // rgb(30, 144, 255)
|
||||||
|
"firebrick": {0xb2, 0x22, 0x22, 0xff}, // rgb(178, 34, 34)
|
||||||
|
"floralwhite": {0xff, 0xfa, 0xf0, 0xff}, // rgb(255, 250, 240)
|
||||||
|
"forestgreen": {0x22, 0x8b, 0x22, 0xff}, // rgb(34, 139, 34)
|
||||||
|
"fuchsia": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255)
|
||||||
|
"gainsboro": {0xdc, 0xdc, 0xdc, 0xff}, // rgb(220, 220, 220)
|
||||||
|
"ghostwhite": {0xf8, 0xf8, 0xff, 0xff}, // rgb(248, 248, 255)
|
||||||
|
"gold": {0xff, 0xd7, 0x00, 0xff}, // rgb(255, 215, 0)
|
||||||
|
"goldenrod": {0xda, 0xa5, 0x20, 0xff}, // rgb(218, 165, 32)
|
||||||
|
"gray": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128)
|
||||||
|
"green": {0x00, 0x80, 0x00, 0xff}, // rgb(0, 128, 0)
|
||||||
|
"greenyellow": {0xad, 0xff, 0x2f, 0xff}, // rgb(173, 255, 47)
|
||||||
|
"grey": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128)
|
||||||
|
"honeydew": {0xf0, 0xff, 0xf0, 0xff}, // rgb(240, 255, 240)
|
||||||
|
"hotpink": {0xff, 0x69, 0xb4, 0xff}, // rgb(255, 105, 180)
|
||||||
|
"indianred": {0xcd, 0x5c, 0x5c, 0xff}, // rgb(205, 92, 92)
|
||||||
|
"indigo": {0x4b, 0x00, 0x82, 0xff}, // rgb(75, 0, 130)
|
||||||
|
"ivory": {0xff, 0xff, 0xf0, 0xff}, // rgb(255, 255, 240)
|
||||||
|
"khaki": {0xf0, 0xe6, 0x8c, 0xff}, // rgb(240, 230, 140)
|
||||||
|
"lavender": {0xe6, 0xe6, 0xfa, 0xff}, // rgb(230, 230, 250)
|
||||||
|
"lavenderblush": {0xff, 0xf0, 0xf5, 0xff}, // rgb(255, 240, 245)
|
||||||
|
"lawngreen": {0x7c, 0xfc, 0x00, 0xff}, // rgb(124, 252, 0)
|
||||||
|
"lemonchiffon": {0xff, 0xfa, 0xcd, 0xff}, // rgb(255, 250, 205)
|
||||||
|
"lightblue": {0xad, 0xd8, 0xe6, 0xff}, // rgb(173, 216, 230)
|
||||||
|
"lightcoral": {0xf0, 0x80, 0x80, 0xff}, // rgb(240, 128, 128)
|
||||||
|
"lightcyan": {0xe0, 0xff, 0xff, 0xff}, // rgb(224, 255, 255)
|
||||||
|
"lightgoldenrodyellow": {0xfa, 0xfa, 0xd2, 0xff}, // rgb(250, 250, 210)
|
||||||
|
"lightgray": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211)
|
||||||
|
"lightgreen": {0x90, 0xee, 0x90, 0xff}, // rgb(144, 238, 144)
|
||||||
|
"lightgrey": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211)
|
||||||
|
"lightpink": {0xff, 0xb6, 0xc1, 0xff}, // rgb(255, 182, 193)
|
||||||
|
"lightsalmon": {0xff, 0xa0, 0x7a, 0xff}, // rgb(255, 160, 122)
|
||||||
|
"lightseagreen": {0x20, 0xb2, 0xaa, 0xff}, // rgb(32, 178, 170)
|
||||||
|
"lightskyblue": {0x87, 0xce, 0xfa, 0xff}, // rgb(135, 206, 250)
|
||||||
|
"lightslategray": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153)
|
||||||
|
"lightslategrey": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153)
|
||||||
|
"lightsteelblue": {0xb0, 0xc4, 0xde, 0xff}, // rgb(176, 196, 222)
|
||||||
|
"lightyellow": {0xff, 0xff, 0xe0, 0xff}, // rgb(255, 255, 224)
|
||||||
|
"lime": {0x00, 0xff, 0x00, 0xff}, // rgb(0, 255, 0)
|
||||||
|
"limegreen": {0x32, 0xcd, 0x32, 0xff}, // rgb(50, 205, 50)
|
||||||
|
"linen": {0xfa, 0xf0, 0xe6, 0xff}, // rgb(250, 240, 230)
|
||||||
|
"magenta": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255)
|
||||||
|
"maroon": {0x80, 0x00, 0x00, 0xff}, // rgb(128, 0, 0)
|
||||||
|
"mediumaquamarine": {0x66, 0xcd, 0xaa, 0xff}, // rgb(102, 205, 170)
|
||||||
|
"mediumblue": {0x00, 0x00, 0xcd, 0xff}, // rgb(0, 0, 205)
|
||||||
|
"mediumorchid": {0xba, 0x55, 0xd3, 0xff}, // rgb(186, 85, 211)
|
||||||
|
"mediumpurple": {0x93, 0x70, 0xdb, 0xff}, // rgb(147, 112, 219)
|
||||||
|
"mediumseagreen": {0x3c, 0xb3, 0x71, 0xff}, // rgb(60, 179, 113)
|
||||||
|
"mediumslateblue": {0x7b, 0x68, 0xee, 0xff}, // rgb(123, 104, 238)
|
||||||
|
"mediumspringgreen": {0x00, 0xfa, 0x9a, 0xff}, // rgb(0, 250, 154)
|
||||||
|
"mediumturquoise": {0x48, 0xd1, 0xcc, 0xff}, // rgb(72, 209, 204)
|
||||||
|
"mediumvioletred": {0xc7, 0x15, 0x85, 0xff}, // rgb(199, 21, 133)
|
||||||
|
"midnightblue": {0x19, 0x19, 0x70, 0xff}, // rgb(25, 25, 112)
|
||||||
|
"mintcream": {0xf5, 0xff, 0xfa, 0xff}, // rgb(245, 255, 250)
|
||||||
|
"mistyrose": {0xff, 0xe4, 0xe1, 0xff}, // rgb(255, 228, 225)
|
||||||
|
"moccasin": {0xff, 0xe4, 0xb5, 0xff}, // rgb(255, 228, 181)
|
||||||
|
"navajowhite": {0xff, 0xde, 0xad, 0xff}, // rgb(255, 222, 173)
|
||||||
|
"navy": {0x00, 0x00, 0x80, 0xff}, // rgb(0, 0, 128)
|
||||||
|
"oldlace": {0xfd, 0xf5, 0xe6, 0xff}, // rgb(253, 245, 230)
|
||||||
|
"olive": {0x80, 0x80, 0x00, 0xff}, // rgb(128, 128, 0)
|
||||||
|
"olivedrab": {0x6b, 0x8e, 0x23, 0xff}, // rgb(107, 142, 35)
|
||||||
|
"orange": {0xff, 0xa5, 0x00, 0xff}, // rgb(255, 165, 0)
|
||||||
|
"orangered": {0xff, 0x45, 0x00, 0xff}, // rgb(255, 69, 0)
|
||||||
|
"orchid": {0xda, 0x70, 0xd6, 0xff}, // rgb(218, 112, 214)
|
||||||
|
"palegoldenrod": {0xee, 0xe8, 0xaa, 0xff}, // rgb(238, 232, 170)
|
||||||
|
"palegreen": {0x98, 0xfb, 0x98, 0xff}, // rgb(152, 251, 152)
|
||||||
|
"paleturquoise": {0xaf, 0xee, 0xee, 0xff}, // rgb(175, 238, 238)
|
||||||
|
"palevioletred": {0xdb, 0x70, 0x93, 0xff}, // rgb(219, 112, 147)
|
||||||
|
"papayawhip": {0xff, 0xef, 0xd5, 0xff}, // rgb(255, 239, 213)
|
||||||
|
"peachpuff": {0xff, 0xda, 0xb9, 0xff}, // rgb(255, 218, 185)
|
||||||
|
"peru": {0xcd, 0x85, 0x3f, 0xff}, // rgb(205, 133, 63)
|
||||||
|
"pink": {0xff, 0xc0, 0xcb, 0xff}, // rgb(255, 192, 203)
|
||||||
|
"plum": {0xdd, 0xa0, 0xdd, 0xff}, // rgb(221, 160, 221)
|
||||||
|
"powderblue": {0xb0, 0xe0, 0xe6, 0xff}, // rgb(176, 224, 230)
|
||||||
|
"purple": {0x80, 0x00, 0x80, 0xff}, // rgb(128, 0, 128)
|
||||||
|
"red": {0xff, 0x00, 0x00, 0xff}, // rgb(255, 0, 0)
|
||||||
|
"rosybrown": {0xbc, 0x8f, 0x8f, 0xff}, // rgb(188, 143, 143)
|
||||||
|
"royalblue": {0x41, 0x69, 0xe1, 0xff}, // rgb(65, 105, 225)
|
||||||
|
"saddlebrown": {0x8b, 0x45, 0x13, 0xff}, // rgb(139, 69, 19)
|
||||||
|
"salmon": {0xfa, 0x80, 0x72, 0xff}, // rgb(250, 128, 114)
|
||||||
|
"sandybrown": {0xf4, 0xa4, 0x60, 0xff}, // rgb(244, 164, 96)
|
||||||
|
"seagreen": {0x2e, 0x8b, 0x57, 0xff}, // rgb(46, 139, 87)
|
||||||
|
"seashell": {0xff, 0xf5, 0xee, 0xff}, // rgb(255, 245, 238)
|
||||||
|
"sienna": {0xa0, 0x52, 0x2d, 0xff}, // rgb(160, 82, 45)
|
||||||
|
"silver": {0xc0, 0xc0, 0xc0, 0xff}, // rgb(192, 192, 192)
|
||||||
|
"skyblue": {0x87, 0xce, 0xeb, 0xff}, // rgb(135, 206, 235)
|
||||||
|
"slateblue": {0x6a, 0x5a, 0xcd, 0xff}, // rgb(106, 90, 205)
|
||||||
|
"slategray": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144)
|
||||||
|
"slategrey": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144)
|
||||||
|
"snow": {0xff, 0xfa, 0xfa, 0xff}, // rgb(255, 250, 250)
|
||||||
|
"springgreen": {0x00, 0xff, 0x7f, 0xff}, // rgb(0, 255, 127)
|
||||||
|
"steelblue": {0x46, 0x82, 0xb4, 0xff}, // rgb(70, 130, 180)
|
||||||
|
"tan": {0xd2, 0xb4, 0x8c, 0xff}, // rgb(210, 180, 140)
|
||||||
|
"teal": {0x00, 0x80, 0x80, 0xff}, // rgb(0, 128, 128)
|
||||||
|
"thistle": {0xd8, 0xbf, 0xd8, 0xff}, // rgb(216, 191, 216)
|
||||||
|
"tomato": {0xff, 0x63, 0x47, 0xff}, // rgb(255, 99, 71)
|
||||||
|
"turquoise": {0x40, 0xe0, 0xd0, 0xff}, // rgb(64, 224, 208)
|
||||||
|
"violet": {0xee, 0x82, 0xee, 0xff}, // rgb(238, 130, 238)
|
||||||
|
"wheat": {0xf5, 0xde, 0xb3, 0xff}, // rgb(245, 222, 179)
|
||||||
|
"white": {0xff, 0xff, 0xff, 0xff}, // rgb(255, 255, 255)
|
||||||
|
"whitesmoke": {0xf5, 0xf5, 0xf5, 0xff}, // rgb(245, 245, 245)
|
||||||
|
"yellow": {0xff, 0xff, 0x00, 0xff}, // rgb(255, 255, 0)
|
||||||
|
"yellowgreen": {0x9a, 0xcd, 0x32, 0xff}, // rgb(154, 205, 50)
|
||||||
|
}
|
148
tui/messages/html/container.go
Normal file
148
tui/messages/html/container.go
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContainerEntity struct {
|
||||||
|
*BaseEntity
|
||||||
|
|
||||||
|
// The children of this container entity.
|
||||||
|
Children []Entity
|
||||||
|
// Number of cells to indent children.
|
||||||
|
Indent int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ce *ContainerEntity) IsEmpty() bool {
|
||||||
|
return len(ce.Children) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlainText returns the plaintext content in this entity and all its children.
|
||||||
|
func (ce *ContainerEntity) PlainText() string {
|
||||||
|
if len(ce.Children) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
newlined := false
|
||||||
|
for _, child := range ce.Children {
|
||||||
|
text := child.PlainText()
|
||||||
|
if !strings.HasPrefix(text, "\n") && child.IsBlock() && !newlined {
|
||||||
|
buf.WriteRune('\n')
|
||||||
|
}
|
||||||
|
newlined = false
|
||||||
|
buf.WriteString(text)
|
||||||
|
if child.IsBlock() {
|
||||||
|
if !strings.HasSuffix(text, "\n") {
|
||||||
|
buf.WriteRune('\n')
|
||||||
|
}
|
||||||
|
newlined = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustStyle recursively changes the style of this entity and all its children.
|
||||||
|
func (ce *ContainerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||||
|
for _, child := range ce.Children {
|
||||||
|
child.AdjustStyle(fn, reason)
|
||||||
|
}
|
||||||
|
ce.Style = fn(ce.Style)
|
||||||
|
return ce
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone creates a deep copy of this base entity.
|
||||||
|
func (ce *ContainerEntity) Clone() Entity {
|
||||||
|
children := make([]Entity, len(ce.Children))
|
||||||
|
for i, child := range ce.Children {
|
||||||
|
children[i] = child.Clone()
|
||||||
|
}
|
||||||
|
return &ContainerEntity{
|
||||||
|
BaseEntity: ce.BaseEntity.Clone().(*BaseEntity),
|
||||||
|
Children: children,
|
||||||
|
Indent: ce.Indent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a textual representation of this BaseEntity struct.
|
||||||
|
func (ce *ContainerEntity) String() string {
|
||||||
|
if len(ce.Children) == 0 {
|
||||||
|
return fmt.Sprintf(`&html.ContainerEntity{Base=%s, Indent=%d, Children=[]}`, ce.BaseEntity, ce.Indent)
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
_, _ = fmt.Fprintf(&buf, `&html.ContainerEntity{Base=%s, Indent=%d, Children=[`, ce.BaseEntity, ce.Indent)
|
||||||
|
for _, child := range ce.Children {
|
||||||
|
buf.WriteString("\n ")
|
||||||
|
buf.WriteString(strings.Join(strings.Split(strings.TrimRight(child.String(), "\n"), "\n"), "\n "))
|
||||||
|
}
|
||||||
|
buf.WriteString("\n]},")
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw draws this entity onto the given mauview Screen.
|
||||||
|
func (ce *ContainerEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||||
|
if len(ce.Children) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
width, _ := screen.Size()
|
||||||
|
prevBreak := false
|
||||||
|
proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: ce.Indent, Width: width - ce.Indent, Style: ce.Style}
|
||||||
|
for i, entity := range ce.Children {
|
||||||
|
if i != 0 && entity.getStartX() == 0 {
|
||||||
|
proxyScreen.OffsetY++
|
||||||
|
}
|
||||||
|
proxyScreen.Height = entity.Height()
|
||||||
|
entity.Draw(proxyScreen, ctx)
|
||||||
|
proxyScreen.SetStyle(ce.Style)
|
||||||
|
proxyScreen.OffsetY += entity.Height() - 1
|
||||||
|
_, isBreak := entity.(*BreakEntity)
|
||||||
|
if prevBreak && isBreak {
|
||||||
|
proxyScreen.OffsetY++
|
||||||
|
}
|
||||||
|
prevBreak = isBreak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateBuffer prepares this entity and all its children for rendering with the given parameters
|
||||||
|
func (ce *ContainerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
|
||||||
|
ce.BaseEntity.CalculateBuffer(width, startX, ctx)
|
||||||
|
if len(ce.Children) > 0 {
|
||||||
|
ce.height = 0
|
||||||
|
childStartX := ce.startX
|
||||||
|
prevBreak := false
|
||||||
|
for _, entity := range ce.Children {
|
||||||
|
if entity.IsBlock() || childStartX == 0 || ce.height == 0 {
|
||||||
|
ce.height++
|
||||||
|
}
|
||||||
|
childStartX = entity.CalculateBuffer(width-ce.Indent, childStartX, ctx)
|
||||||
|
ce.height += entity.Height() - 1
|
||||||
|
_, isBreak := entity.(*BreakEntity)
|
||||||
|
if prevBreak && isBreak {
|
||||||
|
ce.height++
|
||||||
|
}
|
||||||
|
prevBreak = isBreak
|
||||||
|
}
|
||||||
|
if !ce.Block {
|
||||||
|
return childStartX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ce.startX
|
||||||
|
}
|
63
tui/messages/html/entity.go
Normal file
63
tui/messages/html/entity.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdjustStyleFunc is a lambda function type to edit an existing tcell Style.
|
||||||
|
type AdjustStyleFunc func(tcell.Style) tcell.Style
|
||||||
|
|
||||||
|
type AdjustStyleReason int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AdjustStyleReasonNormal AdjustStyleReason = iota
|
||||||
|
AdjustStyleReasonHideSpoiler
|
||||||
|
)
|
||||||
|
|
||||||
|
type DrawContext struct {
|
||||||
|
IsSelected bool
|
||||||
|
BareMessages bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entity interface {
|
||||||
|
// AdjustStyle recursively changes the style of the entity and all its children.
|
||||||
|
AdjustStyle(AdjustStyleFunc, AdjustStyleReason) Entity
|
||||||
|
// Draw draws the entity onto the given mauview Screen.
|
||||||
|
Draw(screen mauview.Screen, ctx DrawContext)
|
||||||
|
// IsBlock returns whether or not it's a block-type entity.
|
||||||
|
IsBlock() bool
|
||||||
|
// GetTag returns the HTML tag of the entity.
|
||||||
|
GetTag() string
|
||||||
|
// PlainText returns the plaintext content in the entity and all its children.
|
||||||
|
PlainText() string
|
||||||
|
// String returns a string representation of the entity struct.
|
||||||
|
String() string
|
||||||
|
// Clone creates a deep copy of the entity.
|
||||||
|
Clone() Entity
|
||||||
|
|
||||||
|
// Height returns the render height of the entity.
|
||||||
|
Height() int
|
||||||
|
// CalculateBuffer prepares the entity and all its children for rendering with the given parameters
|
||||||
|
CalculateBuffer(width, startX int, ctx DrawContext) int
|
||||||
|
|
||||||
|
getStartX() int
|
||||||
|
|
||||||
|
IsEmpty() bool
|
||||||
|
}
|
61
tui/messages/html/horizontalline.go
Normal file
61
tui/messages/html/horizontalline.go
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HorizontalLineEntity struct {
|
||||||
|
*BaseEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
const HorizontalLineChar = '━'
|
||||||
|
|
||||||
|
func NewHorizontalLineEntity() *HorizontalLineEntity {
|
||||||
|
return &HorizontalLineEntity{&BaseEntity{
|
||||||
|
Tag: "hr",
|
||||||
|
Block: true,
|
||||||
|
DefaultHeight: 1,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (he *HorizontalLineEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||||
|
he.BaseEntity = he.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
|
||||||
|
return he
|
||||||
|
}
|
||||||
|
|
||||||
|
func (he *HorizontalLineEntity) Clone() Entity {
|
||||||
|
return NewHorizontalLineEntity()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (he *HorizontalLineEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||||
|
width, _ := screen.Size()
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
screen.SetContent(x, 0, HorizontalLineChar, nil, he.Style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (he *HorizontalLineEntity) PlainText() string {
|
||||||
|
return strings.Repeat(string(HorizontalLineChar), 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (he *HorizontalLineEntity) String() string {
|
||||||
|
return "&html.HorizontalLineEntity{},\n"
|
||||||
|
}
|
123
tui/messages/html/list.go
Normal file
123
tui/messages/html/list.go
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/tui/widget"
|
||||||
|
"maunium.net/go/mautrix/format"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListEntity struct {
|
||||||
|
*ContainerEntity
|
||||||
|
Ordered bool
|
||||||
|
Start int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListEntity(ordered bool, start int, children []Entity) *ListEntity {
|
||||||
|
entity := &ListEntity{
|
||||||
|
ContainerEntity: &ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: "ul",
|
||||||
|
Block: true,
|
||||||
|
},
|
||||||
|
Indent: 2,
|
||||||
|
Children: children,
|
||||||
|
},
|
||||||
|
Ordered: ordered,
|
||||||
|
Start: start,
|
||||||
|
}
|
||||||
|
if ordered {
|
||||||
|
entity.Tag = "ol"
|
||||||
|
entity.Indent += format.Digits(start + len(children) - 1)
|
||||||
|
}
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (le *ListEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||||
|
le.BaseEntity = le.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
|
||||||
|
le.ContainerEntity.AdjustStyle(fn, reason)
|
||||||
|
return le
|
||||||
|
}
|
||||||
|
|
||||||
|
func (le *ListEntity) Clone() Entity {
|
||||||
|
return &ListEntity{
|
||||||
|
ContainerEntity: le.ContainerEntity.Clone().(*ContainerEntity),
|
||||||
|
Ordered: le.Ordered,
|
||||||
|
Start: le.Start,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (le *ListEntity) paddingFor(number int) string {
|
||||||
|
padding := le.Indent - 2 - format.Digits(number)
|
||||||
|
if padding <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Repeat(" ", padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (le *ListEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||||
|
width, _ := screen.Size()
|
||||||
|
|
||||||
|
proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: le.Indent, Width: width - le.Indent, Style: le.Style}
|
||||||
|
for i, entity := range le.Children {
|
||||||
|
proxyScreen.Height = entity.Height()
|
||||||
|
if le.Ordered {
|
||||||
|
number := le.Start + i
|
||||||
|
line := fmt.Sprintf("%d. %s", number, le.paddingFor(number))
|
||||||
|
widget.WriteLine(screen, mauview.AlignLeft, line, 0, proxyScreen.OffsetY, le.Indent, le.Style)
|
||||||
|
} else {
|
||||||
|
screen.SetContent(0, proxyScreen.OffsetY, '●', nil, le.Style)
|
||||||
|
}
|
||||||
|
entity.Draw(proxyScreen, ctx)
|
||||||
|
proxyScreen.SetStyle(le.Style)
|
||||||
|
proxyScreen.OffsetY += entity.Height()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (le *ListEntity) PlainText() string {
|
||||||
|
if len(le.Children) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
for i, child := range le.Children {
|
||||||
|
indent := strings.Repeat(" ", le.Indent)
|
||||||
|
if le.Ordered {
|
||||||
|
number := le.Start + i
|
||||||
|
_, _ = fmt.Fprintf(&buf, "%d. %s", number, le.paddingFor(number))
|
||||||
|
} else {
|
||||||
|
buf.WriteString("● ")
|
||||||
|
}
|
||||||
|
for j, row := range strings.Split(child.PlainText(), "\n") {
|
||||||
|
if j != 0 {
|
||||||
|
buf.WriteRune('\n')
|
||||||
|
buf.WriteString(indent)
|
||||||
|
}
|
||||||
|
buf.WriteString(row)
|
||||||
|
}
|
||||||
|
buf.WriteRune('\n')
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (le *ListEntity) String() string {
|
||||||
|
return fmt.Sprintf("&html.ListEntity{Ordered=%t, Start=%d, Base=%s},\n", le.Ordered, le.Start, le.BaseEntity)
|
||||||
|
}
|
555
tui/messages/html/parser.go
Normal file
555
tui/messages/html/parser.go
Normal file
|
@ -0,0 +1,555 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
/*
|
||||||
|
"github.com/alecthomas/chroma"
|
||||||
|
"github.com/alecthomas/chroma/lexers"
|
||||||
|
"github.com/alecthomas/chroma/styles"
|
||||||
|
*/
|
||||||
|
"github.com/lucasb-eyer/go-colorful"
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
"mvdan.cc/xurls/v2"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
/*
|
||||||
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/matrix/muksevt"
|
||||||
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
||||||
|
type htmlParser struct {
|
||||||
|
prefs *config.UserPreferences
|
||||||
|
room *rooms.Room
|
||||||
|
evt *muksevt.Event
|
||||||
|
|
||||||
|
preserveWhitespace bool
|
||||||
|
linkIDCounter int
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdjustStyleBold(style tcell.Style) tcell.Style {
|
||||||
|
return style.Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdjustStyleItalic(style tcell.Style) tcell.Style {
|
||||||
|
return style.Italic(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdjustStyleUnderline(style tcell.Style) tcell.Style {
|
||||||
|
return style.Underline(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdjustStyleStrikethrough(style tcell.Style) tcell.Style {
|
||||||
|
return style.StrikeThrough(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdjustStyleTextColor(color tcell.Color) AdjustStyleFunc {
|
||||||
|
return func(style tcell.Style) tcell.Style {
|
||||||
|
return style.Foreground(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdjustStyleBackgroundColor(color tcell.Color) AdjustStyleFunc {
|
||||||
|
return func(style tcell.Style) tcell.Style {
|
||||||
|
return style.Background(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdjustStyleLink(url, id string) AdjustStyleFunc {
|
||||||
|
return func(style tcell.Style) tcell.Style {
|
||||||
|
return style.Url(url).UrlId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) {
|
||||||
|
for _, attr := range node.Attr {
|
||||||
|
if attr.Key == attribute {
|
||||||
|
return attr.Val, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) getAttribute(node *html.Node, attribute string) string {
|
||||||
|
val, _ := parser.maybeGetAttribute(node, attribute)
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) hasAttribute(node *html.Node, attribute string) bool {
|
||||||
|
_, ok := parser.maybeGetAttribute(node, attribute)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) listToEntity(node *html.Node) Entity {
|
||||||
|
children := parser.nodeToEntities(node.FirstChild)
|
||||||
|
ordered := node.Data == "ol"
|
||||||
|
start := 1
|
||||||
|
if ordered {
|
||||||
|
if startRaw := parser.getAttribute(node, "start"); len(startRaw) > 0 {
|
||||||
|
var err error
|
||||||
|
start, err = strconv.Atoi(startRaw)
|
||||||
|
if err != nil {
|
||||||
|
start = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listItems := children[:0]
|
||||||
|
for _, child := range children {
|
||||||
|
if child.GetTag() == "li" {
|
||||||
|
listItems = append(listItems, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewListEntity(ordered, start, listItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) basicFormatToEntity(node *html.Node) Entity {
|
||||||
|
entity := &ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: node.Data,
|
||||||
|
},
|
||||||
|
Children: parser.nodeToEntities(node.FirstChild),
|
||||||
|
}
|
||||||
|
switch node.Data {
|
||||||
|
case "b", "strong":
|
||||||
|
entity.AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal)
|
||||||
|
case "i", "em":
|
||||||
|
entity.AdjustStyle(AdjustStyleItalic, AdjustStyleReasonNormal)
|
||||||
|
case "s", "del", "strike":
|
||||||
|
entity.AdjustStyle(AdjustStyleStrikethrough, AdjustStyleReasonNormal)
|
||||||
|
case "u", "ins":
|
||||||
|
entity.AdjustStyle(AdjustStyleUnderline, AdjustStyleReasonNormal)
|
||||||
|
case "code":
|
||||||
|
bgColor := tcell.ColorDarkSlateGray
|
||||||
|
fgColor := tcell.ColorWhite
|
||||||
|
entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal)
|
||||||
|
entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal)
|
||||||
|
case "font", "span":
|
||||||
|
fgColor, ok := parser.parseColor(node, "data-mx-color", "color")
|
||||||
|
if ok {
|
||||||
|
entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal)
|
||||||
|
}
|
||||||
|
bgColor, ok := parser.parseColor(node, "data-mx-bg-color", "background-color")
|
||||||
|
if ok {
|
||||||
|
entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal)
|
||||||
|
}
|
||||||
|
spoilerReason, isSpoiler := parser.maybeGetAttribute(node, "data-mx-spoiler")
|
||||||
|
if isSpoiler {
|
||||||
|
return NewSpoilerEntity(entity, spoilerReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) parseColor(node *html.Node, mainName, altName string) (color tcell.Color, ok bool) {
|
||||||
|
hex := parser.getAttribute(node, mainName)
|
||||||
|
if len(hex) == 0 {
|
||||||
|
hex = parser.getAttribute(node, altName)
|
||||||
|
if len(hex) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cful, err := colorful.Hex(hex)
|
||||||
|
if err != nil {
|
||||||
|
color2, found := colorMap[strings.ToLower(hex)]
|
||||||
|
if !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cful, _ = colorful.MakeColor(color2)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, g, b := cful.RGB255()
|
||||||
|
return tcell.NewRGBColor(int32(r), int32(g), int32(b)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) headerToEntity(node *html.Node) Entity {
|
||||||
|
return (&ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: node.Data,
|
||||||
|
},
|
||||||
|
Children: append(
|
||||||
|
[]Entity{NewTextEntity(strings.Repeat("#", int(node.Data[1]-'0')) + " ")},
|
||||||
|
parser.nodeToEntities(node.FirstChild)...,
|
||||||
|
),
|
||||||
|
}).AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) blockquoteToEntity(node *html.Node) Entity {
|
||||||
|
return NewBlockquoteEntity(parser.nodeToEntities(node.FirstChild))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) linkToEntity(node *html.Node) Entity {
|
||||||
|
sameURL := false
|
||||||
|
href := parser.getAttribute(node, "href")
|
||||||
|
|
||||||
|
entity := &ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: "a",
|
||||||
|
},
|
||||||
|
Children: parser.nodeToEntities(node.FirstChild),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(href) == 0 {
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entity.Children) == 1 {
|
||||||
|
entity, ok := entity.Children[0].(*TextEntity)
|
||||||
|
if ok && entity.Text == href {
|
||||||
|
sameURL = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matrixURI, _ := id.ParseMatrixURIOrMatrixToURL(href)
|
||||||
|
if matrixURI != nil && (matrixURI.Sigil1 == '@' || matrixURI.Sigil1 == '#') && matrixURI.Sigil2 == 0 {
|
||||||
|
text := NewTextEntity(matrixURI.PrimaryIdentifier())
|
||||||
|
if matrixURI.Sigil1 == '@' {
|
||||||
|
if member := parser.room.GetMember(matrixURI.UserID()); member != nil {
|
||||||
|
text.Text = member.Displayname
|
||||||
|
text.Style = text.Style.Foreground(widget.GetHashColor(matrixURI.UserID()))
|
||||||
|
}
|
||||||
|
entity.Children = []Entity{text}
|
||||||
|
} else if matrixURI.Sigil1 == '#' {
|
||||||
|
entity.Children = []Entity{text}
|
||||||
|
}
|
||||||
|
} else if parser.prefs.EnableInlineURLs() {
|
||||||
|
linkID := fmt.Sprintf("%s-%d", parser.evt.ID, parser.linkIDCounter)
|
||||||
|
parser.linkIDCounter++
|
||||||
|
entity.AdjustStyle(AdjustStyleLink(href, linkID), AdjustStyleReasonNormal)
|
||||||
|
} else if !sameURL && !parser.prefs.DisableShowURLs && !parser.hasAttribute(node, "data-mautrix-exclude-plaintext") {
|
||||||
|
entity.Children = append(entity.Children, NewTextEntity(fmt.Sprintf(" (%s)", href)))
|
||||||
|
}
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) imageToEntity(node *html.Node) Entity {
|
||||||
|
alt := parser.getAttribute(node, "alt")
|
||||||
|
if len(alt) == 0 {
|
||||||
|
alt = parser.getAttribute(node, "title")
|
||||||
|
if len(alt) == 0 {
|
||||||
|
alt = "[inline image]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity := &TextEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: "img",
|
||||||
|
},
|
||||||
|
Text: alt,
|
||||||
|
}
|
||||||
|
// TODO add click action and underline on hover for inline images
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
func colourToColor(colour chroma.Colour) tcell.Color {
|
||||||
|
if !colour.IsSet() {
|
||||||
|
return tcell.ColorDefault
|
||||||
|
}
|
||||||
|
return tcell.NewRGBColor(int32(colour.Red()), int32(colour.Green()), int32(colour.Blue()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func styleEntryToStyle(se chroma.StyleEntry) tcell.Style {
|
||||||
|
return tcell.StyleDefault.
|
||||||
|
Bold(se.Bold == chroma.Yes).
|
||||||
|
Italic(se.Italic == chroma.Yes).
|
||||||
|
Underline(se.Underline == chroma.Yes).
|
||||||
|
Foreground(colourToColor(se.Colour)).
|
||||||
|
Background(colourToColor(se.Background))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenToTextEntity(style *chroma.Style, token *chroma.Token) *TextEntity {
|
||||||
|
return &TextEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: token.Type.String(),
|
||||||
|
Style: styleEntryToStyle(style.Get(token.Type)),
|
||||||
|
DefaultHeight: 1,
|
||||||
|
},
|
||||||
|
Text: token.Value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) syntaxHighlight(text, language string) Entity {
|
||||||
|
lexer := lexers.Get(strings.ToLower(language))
|
||||||
|
if lexer == nil {
|
||||||
|
lexer = lexers.Get("plaintext")
|
||||||
|
}
|
||||||
|
iter, err := lexer.Tokenise(nil, text)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// TODO allow changing theme
|
||||||
|
style := styles.SolarizedDark
|
||||||
|
|
||||||
|
tokens := iter.Tokens()
|
||||||
|
|
||||||
|
var children []Entity
|
||||||
|
for _, token := range tokens {
|
||||||
|
lines := strings.SplitAfter(token.Value, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line_len := len(line)
|
||||||
|
if line_len == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := token.Clone()
|
||||||
|
|
||||||
|
if line[line_len-1:] == "\n" {
|
||||||
|
t.Value = line[:line_len-1]
|
||||||
|
children = append(children, tokenToTextEntity(style, &t), NewBreakEntity())
|
||||||
|
} else {
|
||||||
|
t.Value = line
|
||||||
|
children = append(children, tokenToTextEntity(style, &t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewCodeBlockEntity(children, styleEntryToStyle(style.Get(chroma.Background)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) codeblockToEntity(node *html.Node) Entity {
|
||||||
|
lang := "plaintext"
|
||||||
|
// TODO allow disabling syntax highlighting
|
||||||
|
if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" {
|
||||||
|
node = node.FirstChild
|
||||||
|
attr := parser.getAttribute(node, "class")
|
||||||
|
for _, class := range strings.Split(attr, " ") {
|
||||||
|
if strings.HasPrefix(class, "language-") {
|
||||||
|
lang = class[len("language-"):]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parser.preserveWhitespace = true
|
||||||
|
text := (&ContainerEntity{
|
||||||
|
Children: parser.nodeToEntities(node.FirstChild),
|
||||||
|
}).PlainText()
|
||||||
|
parser.preserveWhitespace = false
|
||||||
|
return parser.syntaxHighlight(text, lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) tagNodeToEntity(node *html.Node) Entity {
|
||||||
|
switch node.Data {
|
||||||
|
case "blockquote":
|
||||||
|
return parser.blockquoteToEntity(node)
|
||||||
|
case "ol", "ul":
|
||||||
|
return parser.listToEntity(node)
|
||||||
|
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||||
|
return parser.headerToEntity(node)
|
||||||
|
case "br":
|
||||||
|
return NewBreakEntity()
|
||||||
|
case "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "font", "span", "code":
|
||||||
|
return parser.basicFormatToEntity(node)
|
||||||
|
case "a":
|
||||||
|
return parser.linkToEntity(node)
|
||||||
|
case "img":
|
||||||
|
return parser.imageToEntity(node)
|
||||||
|
case "pre":
|
||||||
|
return parser.codeblockToEntity(node)
|
||||||
|
case "hr":
|
||||||
|
return NewHorizontalLineEntity()
|
||||||
|
case "mx-reply":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return &ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: node.Data,
|
||||||
|
Block: parser.isBlockTag(node.Data),
|
||||||
|
},
|
||||||
|
Children: parser.nodeToEntities(node.FirstChild),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var spaces = regexp.MustCompile("\\s+")
|
||||||
|
|
||||||
|
// textToHTMLEntity converts a plain text string into an HTML Entity while preserving newlines.
|
||||||
|
func textToHTMLEntity(text string) Entity {
|
||||||
|
if strings.Index(text, "\n") == -1 {
|
||||||
|
return NewTextEntity(text)
|
||||||
|
}
|
||||||
|
return &ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{Tag: "span"},
|
||||||
|
Children: textToHTMLEntities(text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textToHTMLEntities(text string) []Entity {
|
||||||
|
lines := strings.SplitAfter(text, "\n")
|
||||||
|
entities := make([]Entity, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
line_len := len(line)
|
||||||
|
if line_len == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line == "\n" {
|
||||||
|
entities = append(entities, NewBreakEntity())
|
||||||
|
} else if line[line_len-1:] == "\n" {
|
||||||
|
entities = append(entities, NewTextEntity(line[:line_len-1]), NewBreakEntity())
|
||||||
|
} else {
|
||||||
|
entities = append(entities, NewTextEntity(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
|
||||||
|
func TextToEntity(text string, eventID id.EventID, linkify bool) Entity {
|
||||||
|
if len(text) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !linkify {
|
||||||
|
return textToHTMLEntity(text)
|
||||||
|
}
|
||||||
|
indices := xurls.Strict().FindAllStringIndex(text, -1)
|
||||||
|
if len(indices) == 0 {
|
||||||
|
return textToHTMLEntity(text)
|
||||||
|
}
|
||||||
|
ent := &ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{Tag: "span"},
|
||||||
|
}
|
||||||
|
var lastEnd int
|
||||||
|
for i, item := range indices {
|
||||||
|
start, end := item[0], item[1]
|
||||||
|
if start > lastEnd {
|
||||||
|
ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:start])...)
|
||||||
|
}
|
||||||
|
link := text[start:end]
|
||||||
|
linkID := fmt.Sprintf("%s-%d", eventID, i)
|
||||||
|
ent.Children = append(ent.Children, NewTextEntity(link).AdjustStyle(AdjustStyleLink(link, linkID), AdjustStyleReasonNormal))
|
||||||
|
lastEnd = end
|
||||||
|
}
|
||||||
|
if lastEnd < len(text) {
|
||||||
|
ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:])...)
|
||||||
|
}
|
||||||
|
return ent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) singleNodeToEntity(node *html.Node) Entity {
|
||||||
|
switch node.Type {
|
||||||
|
case html.TextNode:
|
||||||
|
if !parser.preserveWhitespace {
|
||||||
|
node.Data = strings.ReplaceAll(node.Data, "\n", "")
|
||||||
|
node.Data = spaces.ReplaceAllLiteralString(node.Data, " ")
|
||||||
|
}
|
||||||
|
return TextToEntity(node.Data, parser.evt.ID, parser.prefs.EnableInlineURLs())
|
||||||
|
case html.ElementNode:
|
||||||
|
parsed := parser.tagNodeToEntity(node)
|
||||||
|
if parsed != nil && !parsed.IsBlock() && parsed.IsEmpty() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
case html.DocumentNode:
|
||||||
|
if node.FirstChild.Data == "html" && node.FirstChild.NextSibling == nil {
|
||||||
|
return parser.singleNodeToEntity(node.FirstChild)
|
||||||
|
}
|
||||||
|
return &ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: "html",
|
||||||
|
Block: true,
|
||||||
|
},
|
||||||
|
Children: parser.nodeToEntities(node.FirstChild),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) nodeToEntities(node *html.Node) (entities []Entity) {
|
||||||
|
for ; node != nil; node = node.NextSibling {
|
||||||
|
if entity := parser.singleNodeToEntity(node); entity != nil {
|
||||||
|
entities = append(entities, entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var BlockTags = []string{"p", "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul", "li", "pre", "blockquote", "div", "hr", "table"}
|
||||||
|
|
||||||
|
func (parser *htmlParser) isBlockTag(tag string) bool {
|
||||||
|
for _, blockTag := range BlockTags {
|
||||||
|
if tag == blockTag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *htmlParser) Parse(htmlData string) Entity {
|
||||||
|
node, _ := html.Parse(strings.NewReader(htmlData))
|
||||||
|
bodyNode := node.FirstChild.FirstChild
|
||||||
|
for bodyNode != nil && (bodyNode.Type != html.ElementNode || bodyNode.Data != "body") {
|
||||||
|
bodyNode = bodyNode.NextSibling
|
||||||
|
}
|
||||||
|
if bodyNode != nil {
|
||||||
|
return parser.singleNodeToEntity(bodyNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parser.singleNodeToEntity(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabLength = 4
|
||||||
|
|
||||||
|
// Parse parses a HTML-formatted Matrix event into a UIMessage.
|
||||||
|
func Parse(prefs *config.UserPreferences, room *rooms.Room, content *event.MessageEventContent, evt *muksevt.Event, senderDisplayname string) Entity {
|
||||||
|
htmlData := content.FormattedBody
|
||||||
|
|
||||||
|
if content.Format != event.FormatHTML {
|
||||||
|
htmlData = strings.Replace(html.EscapeString(content.Body), "\n", "<br/>", -1)
|
||||||
|
}
|
||||||
|
htmlData = strings.Replace(htmlData, "\t", strings.Repeat(" ", TabLength), -1)
|
||||||
|
|
||||||
|
parser := htmlParser{room: room, prefs: prefs, evt: evt}
|
||||||
|
root := parser.Parse(htmlData)
|
||||||
|
if root == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
beRoot, ok := root.(*ContainerEntity)
|
||||||
|
if ok {
|
||||||
|
beRoot.Block = false
|
||||||
|
if len(beRoot.Children) > 0 {
|
||||||
|
beChild, ok := beRoot.Children[0].(*ContainerEntity)
|
||||||
|
if ok && beChild.Tag == "p" {
|
||||||
|
// Hacky fix for m.emote
|
||||||
|
beChild.Block = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.MsgType == event.MsgEmote {
|
||||||
|
root = &ContainerEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: "emote",
|
||||||
|
},
|
||||||
|
Children: []Entity{
|
||||||
|
NewTextEntity("* "),
|
||||||
|
NewTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(evt.Sender)), AdjustStyleReasonNormal),
|
||||||
|
NewTextEntity(" "),
|
||||||
|
root,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
120
tui/messages/html/spoiler.go
Normal file
120
tui/messages/html/spoiler.go
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2022 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SpoilerEntity struct {
|
||||||
|
reason string
|
||||||
|
hidden *ContainerEntity
|
||||||
|
visible *ContainerEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpoilerColor = tcell.ColorYellow
|
||||||
|
|
||||||
|
func NewSpoilerEntity(visible *ContainerEntity, reason string) *SpoilerEntity {
|
||||||
|
hidden := visible.Clone().(*ContainerEntity)
|
||||||
|
hidden.AdjustStyle(func(style tcell.Style) tcell.Style {
|
||||||
|
return style.Foreground(SpoilerColor).Background(SpoilerColor)
|
||||||
|
}, AdjustStyleReasonHideSpoiler)
|
||||||
|
if len(reason) > 0 {
|
||||||
|
reasonEnt := NewTextEntity(fmt.Sprintf("(%s)", reason))
|
||||||
|
hidden.Children = append([]Entity{reasonEnt}, hidden.Children...)
|
||||||
|
visible.Children = append([]Entity{reasonEnt}, visible.Children...)
|
||||||
|
}
|
||||||
|
return &SpoilerEntity{
|
||||||
|
reason: reason,
|
||||||
|
hidden: hidden,
|
||||||
|
visible: visible,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) Clone() Entity {
|
||||||
|
return &SpoilerEntity{
|
||||||
|
reason: se.reason,
|
||||||
|
hidden: se.hidden.Clone().(*ContainerEntity),
|
||||||
|
visible: se.visible.Clone().(*ContainerEntity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) IsBlock() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) GetTag() string {
|
||||||
|
return "span"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||||
|
if ctx.IsSelected {
|
||||||
|
se.visible.Draw(screen, ctx)
|
||||||
|
} else {
|
||||||
|
se.hidden.Draw(screen, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||||
|
if reason != AdjustStyleReasonHideSpoiler {
|
||||||
|
se.hidden.AdjustStyle(func(style tcell.Style) tcell.Style {
|
||||||
|
return fn(style).Foreground(SpoilerColor).Background(SpoilerColor)
|
||||||
|
}, reason)
|
||||||
|
se.visible.AdjustStyle(fn, reason)
|
||||||
|
}
|
||||||
|
return se
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) PlainText() string {
|
||||||
|
if len(se.reason) > 0 {
|
||||||
|
return fmt.Sprintf("spoiler: %s", se.reason)
|
||||||
|
} else {
|
||||||
|
return "spoiler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) String() string {
|
||||||
|
var buf strings.Builder
|
||||||
|
_, _ = fmt.Fprintf(&buf, `&html.SpoilerEntity{reason=%s`, se.reason)
|
||||||
|
buf.WriteString("\n visible=")
|
||||||
|
buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.visible.String(), "\n"), "\n"), "\n "))
|
||||||
|
buf.WriteString("\n hidden=")
|
||||||
|
buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.hidden.String(), "\n"), "\n"), "\n "))
|
||||||
|
buf.WriteString("\n]},")
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) Height() int {
|
||||||
|
return se.visible.Height()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
|
||||||
|
se.hidden.CalculateBuffer(width, startX, ctx)
|
||||||
|
return se.visible.CalculateBuffer(width, startX, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) getStartX() int {
|
||||||
|
return se.visible.getStartX()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *SpoilerEntity) IsEmpty() bool {
|
||||||
|
return se.visible.IsEmpty()
|
||||||
|
}
|
156
tui/messages/html/text.go
Normal file
156
tui/messages/html/text.go
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/tui/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TextEntity struct {
|
||||||
|
*BaseEntity
|
||||||
|
// Text in this entity.
|
||||||
|
Text string
|
||||||
|
|
||||||
|
buffer []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTextEntity creates a new text-only Entity.
|
||||||
|
func NewTextEntity(text string) *TextEntity {
|
||||||
|
return &TextEntity{
|
||||||
|
BaseEntity: &BaseEntity{
|
||||||
|
Tag: "text",
|
||||||
|
},
|
||||||
|
Text: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TextEntity) IsEmpty() bool {
|
||||||
|
return len(te.Text) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TextEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||||
|
te.BaseEntity = te.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
|
||||||
|
return te
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TextEntity) Clone() Entity {
|
||||||
|
return &TextEntity{
|
||||||
|
BaseEntity: te.BaseEntity.Clone().(*BaseEntity),
|
||||||
|
Text: te.Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TextEntity) PlainText() string {
|
||||||
|
return te.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TextEntity) String() string {
|
||||||
|
return fmt.Sprintf("&html.TextEntity{Text=%s, Base=%s},\n", te.Text, te.BaseEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TextEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||||
|
width, _ := screen.Size()
|
||||||
|
x := te.startX
|
||||||
|
for y, line := range te.buffer {
|
||||||
|
widget.WriteLine(screen, mauview.AlignLeft, line, x, y, width, te.Style)
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (te *TextEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
|
||||||
|
te.BaseEntity.CalculateBuffer(width, startX, ctx)
|
||||||
|
if len(te.Text) == 0 {
|
||||||
|
return te.startX
|
||||||
|
}
|
||||||
|
te.height = 0
|
||||||
|
te.prevWidth = width
|
||||||
|
if te.buffer == nil {
|
||||||
|
te.buffer = []string{}
|
||||||
|
}
|
||||||
|
bufPtr := 0
|
||||||
|
text := te.Text
|
||||||
|
textStartX := te.startX
|
||||||
|
for {
|
||||||
|
// TODO add option no wrap and character wrap options
|
||||||
|
extract := runewidth.Truncate(text, width-textStartX, "")
|
||||||
|
extract, wordWrapped := trim(extract, text, ctx.BareMessages)
|
||||||
|
if !wordWrapped && textStartX > 0 {
|
||||||
|
if bufPtr < len(te.buffer) {
|
||||||
|
te.buffer[bufPtr] = ""
|
||||||
|
} else {
|
||||||
|
te.buffer = append(te.buffer, "")
|
||||||
|
}
|
||||||
|
bufPtr++
|
||||||
|
textStartX = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bufPtr < len(te.buffer) {
|
||||||
|
te.buffer[bufPtr] = extract
|
||||||
|
} else {
|
||||||
|
te.buffer = append(te.buffer, extract)
|
||||||
|
}
|
||||||
|
bufPtr++
|
||||||
|
text = text[len(extract):]
|
||||||
|
if len(text) == 0 {
|
||||||
|
te.buffer = te.buffer[:bufPtr]
|
||||||
|
te.height += len(te.buffer)
|
||||||
|
// This entity is over, return the startX for the next entity
|
||||||
|
if te.Block {
|
||||||
|
// ...except if it's a block entity
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return textStartX + runewidth.StringWidth(extract)
|
||||||
|
}
|
||||||
|
textStartX = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`)
|
||||||
|
bareBoundaryPattern = regexp.MustCompile(`(\s+)`)
|
||||||
|
spacePattern = regexp.MustCompile(`\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func trim(extract, full string, bare bool) (string, bool) {
|
||||||
|
if len(extract) == len(full) {
|
||||||
|
return extract, true
|
||||||
|
}
|
||||||
|
if spaces := spacePattern.FindStringIndex(full[len(extract):]); spaces != nil && spaces[0] == 0 {
|
||||||
|
extract = full[:len(extract)+spaces[1]]
|
||||||
|
}
|
||||||
|
regex := boundaryPattern
|
||||||
|
if bare {
|
||||||
|
regex = bareBoundaryPattern
|
||||||
|
}
|
||||||
|
matches := regex.FindAllStringIndex(extract, -1)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
if match := matches[len(matches)-1]; len(match) >= 2 {
|
||||||
|
if until := match[1]; until < len(extract) {
|
||||||
|
extract = extract[:until]
|
||||||
|
return extract, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extract, len(extract) > 0 && extract[len(extract)-1] == ' '
|
||||||
|
}
|
100
tui/messages/htmlmessage.go
Normal file
100
tui/messages/htmlmessage.go
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
/*
|
||||||
|
"maunium.net/go/gomuks/matrix/muksevt"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/tui/messages/html"
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTMLMessage struct {
|
||||||
|
Root html.Entity
|
||||||
|
TextColor tcell.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTMLMessage(evt *muksevt.Event, displayname string, root html.Entity) *UIMessage {
|
||||||
|
return newUIMessage(evt, displayname, &HTMLMessage{
|
||||||
|
Root: root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) Clone() MessageRenderer {
|
||||||
|
return &HTMLMessage{
|
||||||
|
Root: hw.Root.Clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) Draw(screen mauview.Screen, msg *UIMessage) {
|
||||||
|
if hw.TextColor != tcell.ColorDefault {
|
||||||
|
hw.Root.AdjustStyle(func(style tcell.Style) tcell.Style {
|
||||||
|
fg, _, _ := style.Decompose()
|
||||||
|
if fg == tcell.ColorDefault {
|
||||||
|
return style.Foreground(hw.TextColor)
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}, html.AdjustStyleReasonNormal)
|
||||||
|
}
|
||||||
|
screen.Clear()
|
||||||
|
hw.Root.Draw(screen, html.DrawContext{IsSelected: msg.IsSelected})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) OnKeyEvent(event mauview.KeyEvent) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) OnMouseEvent(event mauview.MouseEvent) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) OnPasteEvent(event mauview.PasteEvent) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) CalculateBuffer(preferences config.UserPreferences, width int, msg *UIMessage) {
|
||||||
|
if width < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO account for bare messages in initial startX
|
||||||
|
startX := 0
|
||||||
|
hw.TextColor = msg.TextColor()
|
||||||
|
hw.Root.CalculateBuffer(width, startX, html.DrawContext{
|
||||||
|
IsSelected: msg.IsSelected,
|
||||||
|
BareMessages: preferences.BareMessageView,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) Height() int {
|
||||||
|
return hw.Root.Height()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) PlainText() string {
|
||||||
|
return hw.Root.PlainText()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) NotificationContent() string {
|
||||||
|
return hw.Root.PlainText()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hw *HTMLMessage) String() string {
|
||||||
|
return hw.Root.String()
|
||||||
|
}
|
324
tui/messages/parser.go
Normal file
324
tui/messages/parser.go
Normal file
|
@ -0,0 +1,324 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
/* "maunium.net/go/gomuks/debug"
|
||||||
|
"maunium.net/go/gomuks/matrix/muksevt"
|
||||||
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
|
"maunium.net/go/gomuks/tui/messages/html"
|
||||||
|
"maunium.net/go/gomuks/tui/messages/tstring"
|
||||||
|
"maunium.net/go/gomuks/tui/widget"
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
||||||
|
func getCachedEvent(mainView ifc.MainView, roomID id.RoomID, eventID id.EventID) *UIMessage {
|
||||||
|
if roomView := mainView.GetRoom(roomID); roomView != nil {
|
||||||
|
if replyToIfcMsg := roomView.GetEvent(eventID); replyToIfcMsg != nil {
|
||||||
|
if replyToMsg, ok := replyToIfcMsg.(*UIMessage); ok && replyToMsg != nil {
|
||||||
|
return replyToMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseEvent(matrix ifc.MatrixContainer, mainView ifc.MainView, room *rooms.Room, evt *muksevt.Event) *UIMessage {
|
||||||
|
msg := directParseEvent(matrix, room, evt)
|
||||||
|
if msg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if content, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && len(content.GetReplyTo()) > 0 {
|
||||||
|
if replyToMsg := getCachedEvent(mainView, room.ID, content.GetReplyTo()); replyToMsg != nil {
|
||||||
|
msg.ReplyTo = replyToMsg.Clone()
|
||||||
|
} else if replyToEvt, _ := matrix.GetEvent(room, content.GetReplyTo()); replyToEvt != nil {
|
||||||
|
if replyToMsg = directParseEvent(matrix, room, replyToEvt); replyToMsg != nil {
|
||||||
|
msg.ReplyTo = replyToMsg
|
||||||
|
msg.ReplyTo.Reactions = nil
|
||||||
|
} else {
|
||||||
|
// TODO add unrenderable reply header
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO add unknown reply header
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func directParseEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event) *UIMessage {
|
||||||
|
displayname := string(evt.Sender)
|
||||||
|
member := room.GetMember(evt.Sender)
|
||||||
|
if member != nil {
|
||||||
|
displayname = member.Displayname
|
||||||
|
}
|
||||||
|
if evt.Unsigned.RedactedBecause != nil || evt.Type == event.EventRedaction {
|
||||||
|
return NewRedactedMessage(evt, displayname)
|
||||||
|
}
|
||||||
|
switch content := evt.Content.Parsed.(type) {
|
||||||
|
case *event.MessageEventContent:
|
||||||
|
if evt.Type == event.EventSticker {
|
||||||
|
content.MsgType = event.MsgImage
|
||||||
|
}
|
||||||
|
return ParseMessage(matrix, room, evt, displayname)
|
||||||
|
case *muksevt.BadEncryptedContent:
|
||||||
|
return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString(content.Reason, tcell.StyleDefault.Italic(true)))
|
||||||
|
case *muksevt.EncryptionUnsupportedContent:
|
||||||
|
return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString("gomuks not built with encryption support", tcell.StyleDefault.Italic(true)))
|
||||||
|
case *event.TopicEventContent, *event.RoomNameEventContent, *event.CanonicalAliasEventContent:
|
||||||
|
return ParseStateEvent(evt, displayname)
|
||||||
|
case *event.MemberEventContent:
|
||||||
|
return ParseMembershipEvent(room, evt)
|
||||||
|
default:
|
||||||
|
debug.Printf("Unknown event content type %T in directParseEvent", content)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAltAliasDifference(newList, oldList []id.RoomAlias) (addedStr, removedStr tstring.TString) {
|
||||||
|
var addedList, removedList []tstring.TString
|
||||||
|
OldLoop:
|
||||||
|
for _, oldAlias := range oldList {
|
||||||
|
for _, newAlias := range newList {
|
||||||
|
if oldAlias == newAlias {
|
||||||
|
continue OldLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removedList = append(removedList, tstring.NewStyleTString(string(oldAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(oldAlias)).Underline(true)))
|
||||||
|
}
|
||||||
|
NewLoop:
|
||||||
|
for _, newAlias := range newList {
|
||||||
|
for _, oldAlias := range oldList {
|
||||||
|
if newAlias == oldAlias {
|
||||||
|
continue NewLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addedList = append(addedList, tstring.NewStyleTString(string(newAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(newAlias)).Underline(true)))
|
||||||
|
}
|
||||||
|
if len(addedList) == 1 {
|
||||||
|
addedStr = tstring.NewColorTString("added alternative address ", tcell.ColorGreen).AppendTString(addedList[0])
|
||||||
|
} else if len(addedList) != 0 {
|
||||||
|
addedStr = tstring.
|
||||||
|
Join(addedList[:len(addedList)-1], ", ").
|
||||||
|
PrependColor("added alternative addresses ", tcell.ColorGreen).
|
||||||
|
AppendColor(" and ", tcell.ColorGreen).
|
||||||
|
AppendTString(addedList[len(addedList)-1])
|
||||||
|
}
|
||||||
|
if len(removedList) == 1 {
|
||||||
|
removedStr = tstring.NewColorTString("removed alternative address ", tcell.ColorGreen).AppendTString(removedList[0])
|
||||||
|
} else if len(removedList) != 0 {
|
||||||
|
removedStr = tstring.
|
||||||
|
Join(removedList[:len(removedList)-1], ", ").
|
||||||
|
PrependColor("removed alternative addresses ", tcell.ColorGreen).
|
||||||
|
AppendColor(" and ", tcell.ColorGreen).
|
||||||
|
AppendTString(removedList[len(removedList)-1])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseStateEvent(evt *muksevt.Event, displayname string) *UIMessage {
|
||||||
|
text := tstring.NewColorTString(displayname, widget.GetHashColor(evt.Sender)).Append(" ")
|
||||||
|
switch content := evt.Content.Parsed.(type) {
|
||||||
|
case *event.TopicEventContent:
|
||||||
|
if len(content.Topic) == 0 {
|
||||||
|
text = text.AppendColor("removed the topic.", tcell.ColorGreen)
|
||||||
|
} else {
|
||||||
|
text = text.AppendColor("changed the topic to ", tcell.ColorGreen).
|
||||||
|
AppendStyle(content.Topic, tcell.StyleDefault.Underline(true)).
|
||||||
|
AppendColor(".", tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
case *event.RoomNameEventContent:
|
||||||
|
if len(content.Name) == 0 {
|
||||||
|
text = text.AppendColor("removed the room name.", tcell.ColorGreen)
|
||||||
|
} else {
|
||||||
|
text = text.AppendColor("changed the room name to ", tcell.ColorGreen).
|
||||||
|
AppendStyle(content.Name, tcell.StyleDefault.Underline(true)).
|
||||||
|
AppendColor(".", tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
case *event.CanonicalAliasEventContent:
|
||||||
|
prevContent := &event.CanonicalAliasEventContent{}
|
||||||
|
if evt.Unsigned.PrevContent != nil {
|
||||||
|
_ = evt.Unsigned.PrevContent.ParseRaw(evt.Type)
|
||||||
|
prevContent = evt.Unsigned.PrevContent.AsCanonicalAlias()
|
||||||
|
}
|
||||||
|
debug.Printf("%+v -> %+v", prevContent, content)
|
||||||
|
if len(content.Alias) == 0 && len(prevContent.Alias) != 0 {
|
||||||
|
text = text.AppendColor("removed the main address of the room", tcell.ColorGreen)
|
||||||
|
} else if content.Alias != prevContent.Alias {
|
||||||
|
text = text.
|
||||||
|
AppendColor("changed the main address of the room to ", tcell.ColorGreen).
|
||||||
|
AppendStyle(string(content.Alias), tcell.StyleDefault.Underline(true))
|
||||||
|
} else {
|
||||||
|
added, removed := findAltAliasDifference(content.AltAliases, prevContent.AltAliases)
|
||||||
|
if len(added) > 0 {
|
||||||
|
if len(removed) > 0 {
|
||||||
|
text = text.
|
||||||
|
AppendTString(added).
|
||||||
|
AppendColor(" and ", tcell.ColorGreen).
|
||||||
|
AppendTString(removed)
|
||||||
|
} else {
|
||||||
|
text = text.AppendTString(added)
|
||||||
|
}
|
||||||
|
} else if len(removed) > 0 {
|
||||||
|
text = text.AppendTString(removed)
|
||||||
|
} else {
|
||||||
|
text = text.AppendColor("changed nothing", tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
text = text.AppendColor(" for this room", tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewExpandedTextMessage(evt, displayname, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMessage(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event, displayname string) *UIMessage {
|
||||||
|
content := evt.Content.AsMessage()
|
||||||
|
if len(content.GetReplyTo()) > 0 {
|
||||||
|
content.RemoveReplyFallback()
|
||||||
|
}
|
||||||
|
if len(evt.Gomuks.Edits) > 0 {
|
||||||
|
newContent := evt.Gomuks.Edits[len(evt.Gomuks.Edits)-1].Content.AsMessage().NewContent
|
||||||
|
if newContent != nil {
|
||||||
|
content = newContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch content.MsgType {
|
||||||
|
case event.MsgText, event.MsgNotice, event.MsgEmote:
|
||||||
|
var htmlEntity html.Entity
|
||||||
|
if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
|
||||||
|
htmlEntity = html.Parse(matrix.Preferences(), room, content, evt, displayname)
|
||||||
|
if htmlEntity == nil {
|
||||||
|
htmlEntity = html.NewTextEntity("Malformed message")
|
||||||
|
htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal)
|
||||||
|
}
|
||||||
|
} else if len(content.Body) > 0 {
|
||||||
|
content.Body = strings.Replace(content.Body, "\t", " ", -1)
|
||||||
|
htmlEntity = html.TextToEntity(content.Body, evt.ID, matrix.Preferences().EnableInlineURLs())
|
||||||
|
} else {
|
||||||
|
htmlEntity = html.NewTextEntity("Blank message")
|
||||||
|
htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal)
|
||||||
|
}
|
||||||
|
return NewHTMLMessage(evt, displayname, htmlEntity)
|
||||||
|
case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile:
|
||||||
|
msg := NewFileMessage(matrix, evt, displayname)
|
||||||
|
if !matrix.Preferences().DisableDownloads {
|
||||||
|
renderer := msg.Renderer.(*FileMessage)
|
||||||
|
renderer.DownloadPreview()
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMembershipChangeMessage(evt *muksevt.Event, content *event.MemberEventContent, prevMembership event.Membership, senderDisplayname, displayname, prevDisplayname string) (sender string, text tstring.TString) {
|
||||||
|
switch content.Membership {
|
||||||
|
case "invite":
|
||||||
|
sender = "---"
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s invited %s.", senderDisplayname, displayname), tcell.ColorGreen)
|
||||||
|
text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender))
|
||||||
|
text.Colorize(len(senderDisplayname)+len(" invited "), len(displayname), widget.GetHashColor(evt.StateKey))
|
||||||
|
case "join":
|
||||||
|
sender = "-->"
|
||||||
|
if prevMembership == event.MembershipInvite {
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s accepted the invite.", displayname), tcell.ColorGreen)
|
||||||
|
} else {
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s joined the room.", displayname), tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey))
|
||||||
|
case "leave":
|
||||||
|
sender = "<--"
|
||||||
|
if evt.Sender != id.UserID(*evt.StateKey) {
|
||||||
|
if prevMembership == event.MembershipBan {
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s unbanned %s", senderDisplayname, displayname), tcell.ColorGreen)
|
||||||
|
text.Colorize(len(senderDisplayname)+len(" unbanned "), len(displayname), widget.GetHashColor(evt.StateKey))
|
||||||
|
} else {
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s kicked %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed)
|
||||||
|
text.Colorize(len(senderDisplayname)+len(" kicked "), len(displayname), widget.GetHashColor(evt.StateKey))
|
||||||
|
}
|
||||||
|
text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender))
|
||||||
|
} else {
|
||||||
|
if displayname == *evt.StateKey {
|
||||||
|
displayname = prevDisplayname
|
||||||
|
}
|
||||||
|
if prevMembership == event.MembershipInvite {
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s rejected the invite.", displayname), tcell.ColorRed)
|
||||||
|
} else {
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s left the room.", displayname), tcell.ColorRed)
|
||||||
|
}
|
||||||
|
text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey))
|
||||||
|
}
|
||||||
|
case "ban":
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s banned %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed)
|
||||||
|
text.Colorize(len(senderDisplayname)+len(" banned "), len(displayname), widget.GetHashColor(evt.StateKey))
|
||||||
|
text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMembershipEventContent(room *rooms.Room, evt *muksevt.Event) (sender string, text tstring.TString) {
|
||||||
|
member := room.GetMember(evt.Sender)
|
||||||
|
senderDisplayname := string(evt.Sender)
|
||||||
|
if member != nil {
|
||||||
|
senderDisplayname = member.Displayname
|
||||||
|
}
|
||||||
|
|
||||||
|
content := evt.Content.AsMember()
|
||||||
|
displayname := content.Displayname
|
||||||
|
if len(displayname) == 0 {
|
||||||
|
displayname = *evt.StateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
prevMembership := event.MembershipLeave
|
||||||
|
prevDisplayname := *evt.StateKey
|
||||||
|
if evt.Unsigned.PrevContent != nil {
|
||||||
|
_ = evt.Unsigned.PrevContent.ParseRaw(evt.Type)
|
||||||
|
prevContent := evt.Unsigned.PrevContent.AsMember()
|
||||||
|
prevMembership = prevContent.Membership
|
||||||
|
prevDisplayname = prevContent.Displayname
|
||||||
|
if len(prevDisplayname) == 0 {
|
||||||
|
prevDisplayname = *evt.StateKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.Membership != prevMembership {
|
||||||
|
sender, text = getMembershipChangeMessage(evt, content, prevMembership, senderDisplayname, displayname, prevDisplayname)
|
||||||
|
} else if displayname != prevDisplayname {
|
||||||
|
sender = "---"
|
||||||
|
color := widget.GetHashColor(evt.StateKey)
|
||||||
|
text = tstring.NewBlankTString().
|
||||||
|
AppendColor(prevDisplayname, color).
|
||||||
|
AppendColor(" changed their display name to ", tcell.ColorGreen).
|
||||||
|
AppendColor(displayname, color).
|
||||||
|
AppendColor(".", tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMembershipEvent(room *rooms.Room, evt *muksevt.Event) *UIMessage {
|
||||||
|
displayname, text := getMembershipEventContent(room, evt)
|
||||||
|
if len(text) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewExpandedTextMessage(evt, displayname, text)
|
||||||
|
}
|
66
tui/messages/redactedmessage.go
Normal file
66
tui/messages/redactedmessage.go
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
/* "maunium.net/go/gomuks/matrix/muksevt"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/config"
|
||||||
|
*/)
|
||||||
|
|
||||||
|
type RedactedMessage struct{}
|
||||||
|
|
||||||
|
func NewRedactedMessage(evt *muksevt.Event, displayname string) *UIMessage {
|
||||||
|
return newUIMessage(evt, displayname, &RedactedMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *RedactedMessage) Clone() MessageRenderer {
|
||||||
|
return &RedactedMessage{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *RedactedMessage) NotificationContent() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *RedactedMessage) PlainText() string {
|
||||||
|
return "[redacted]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *RedactedMessage) String() string {
|
||||||
|
return "&messages.RedactedMessage{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *RedactedMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *RedactedMessage) Height() int {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const RedactionChar = '█'
|
||||||
|
const RedactionMaxWidth = 40
|
||||||
|
|
||||||
|
var RedactionStyle = tcell.StyleDefault.Foreground(tcell.NewRGBColor(50, 0, 0))
|
||||||
|
|
||||||
|
func (msg *RedactedMessage) Draw(screen mauview.Screen, _ *UIMessage) {
|
||||||
|
w, _ := screen.Size()
|
||||||
|
for x := 0; x < w && x < RedactionMaxWidth; x++ {
|
||||||
|
screen.SetContent(x, 0, RedactionChar, nil, RedactionStyle)
|
||||||
|
}
|
||||||
|
}
|
96
tui/messages/textbase.go
Normal file
96
tui/messages/textbase.go
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
/*
|
||||||
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/tui/messages/tstring"
|
||||||
|
*/)
|
||||||
|
|
||||||
|
// Regular expressions used to split lines when calculating the buffer.
|
||||||
|
//
|
||||||
|
// From tview/textview.go
|
||||||
|
var (
|
||||||
|
boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`)
|
||||||
|
bareBoundaryPattern = regexp.MustCompile(`(\s+)`)
|
||||||
|
spacePattern = regexp.MustCompile(`\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func matchBoundaryPattern(bare bool, extract tstring.TString) tstring.TString {
|
||||||
|
regex := boundaryPattern
|
||||||
|
if bare {
|
||||||
|
regex = bareBoundaryPattern
|
||||||
|
}
|
||||||
|
matches := regex.FindAllStringIndex(extract.String(), -1)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
if match := matches[len(matches)-1]; len(match) >= 2 {
|
||||||
|
if until := match[1]; until < len(extract) {
|
||||||
|
extract = extract[:until]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extract
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateBuffer generates the internal buffer for this message that consists
|
||||||
|
// of the text of this message split into lines at most as wide as the width
|
||||||
|
// parameter.
|
||||||
|
func calculateBufferWithText(prefs config.UserPreferences, text tstring.TString, width int, msg *UIMessage) []tstring.TString {
|
||||||
|
if width < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer []tstring.TString
|
||||||
|
|
||||||
|
if prefs.BareMessageView {
|
||||||
|
newText := tstring.NewTString(msg.FormatTime())
|
||||||
|
if len(msg.Sender()) > 0 {
|
||||||
|
newText = newText.AppendTString(tstring.NewColorTString(fmt.Sprintf(" <%s> ", msg.Sender()), msg.SenderColor()))
|
||||||
|
} else {
|
||||||
|
newText = newText.Append(" ")
|
||||||
|
}
|
||||||
|
newText = newText.AppendTString(text)
|
||||||
|
text = newText
|
||||||
|
}
|
||||||
|
|
||||||
|
forcedLinebreaks := text.Split('\n')
|
||||||
|
newlines := 0
|
||||||
|
for _, str := range forcedLinebreaks {
|
||||||
|
if len(str) == 0 && newlines < 1 {
|
||||||
|
buffer = append(buffer, tstring.TString{})
|
||||||
|
newlines++
|
||||||
|
} else {
|
||||||
|
newlines = 0
|
||||||
|
}
|
||||||
|
// Adapted from tview/textview.go#reindexBuffer()
|
||||||
|
for len(str) > 0 {
|
||||||
|
extract := str.Truncate(width)
|
||||||
|
if len(extract) < len(str) {
|
||||||
|
if spaces := spacePattern.FindStringIndex(str[len(extract):].String()); spaces != nil && spaces[0] == 0 {
|
||||||
|
extract = str[:len(extract)+spaces[1]]
|
||||||
|
}
|
||||||
|
extract = matchBoundaryPattern(prefs.BareMessageView, extract)
|
||||||
|
}
|
||||||
|
buffer = append(buffer, extract)
|
||||||
|
str = str[len(extract):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer
|
||||||
|
}
|
53
tui/messages/tstring/cell.go
Normal file
53
tui/messages/tstring/cell.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tstring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cell struct {
|
||||||
|
Char rune
|
||||||
|
Style tcell.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStyleCell(char rune, style tcell.Style) Cell {
|
||||||
|
return Cell{char, style}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewColorCell(char rune, color tcell.Color) Cell {
|
||||||
|
return Cell{char, tcell.StyleDefault.Foreground(color)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCell(char rune) Cell {
|
||||||
|
return Cell{char, tcell.StyleDefault}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cell Cell) RuneWidth() int {
|
||||||
|
return runewidth.RuneWidth(cell.Char)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cell Cell) Draw(screen mauview.Screen, x, y int) (chWidth int) {
|
||||||
|
chWidth = cell.RuneWidth()
|
||||||
|
for runeWidthOffset := 0; runeWidthOffset < chWidth; runeWidthOffset++ {
|
||||||
|
screen.SetContent(x+runeWidthOffset, y, cell.Char, nil, cell.Style)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
4
tui/messages/tstring/doc.go
Normal file
4
tui/messages/tstring/doc.go
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
// Package tstring contains a string type that stores style data for each
|
||||||
|
// character, allowing it to be rendered to a tcell screen essentially
|
||||||
|
// unmodified.
|
||||||
|
package tstring
|
270
tui/messages/tstring/string.go
Normal file
270
tui/messages/tstring/string.go
Normal file
|
@ -0,0 +1,270 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tstring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TString []Cell
|
||||||
|
|
||||||
|
func NewBlankTString() TString {
|
||||||
|
return make(TString, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTString(str string) TString {
|
||||||
|
newStr := make(TString, len(str))
|
||||||
|
for i, char := range str {
|
||||||
|
newStr[i] = NewCell(char)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewColorTString(str string, color tcell.Color) TString {
|
||||||
|
newStr := make(TString, len(str))
|
||||||
|
for i, char := range str {
|
||||||
|
newStr[i] = NewColorCell(char, color)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStyleTString(str string, style tcell.Style) TString {
|
||||||
|
newStr := make(TString, len(str))
|
||||||
|
for i, char := range str {
|
||||||
|
newStr[i] = NewStyleCell(char, style)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func Join(strings []TString, separator string) TString {
|
||||||
|
if len(strings) == 0 {
|
||||||
|
return NewBlankTString()
|
||||||
|
}
|
||||||
|
|
||||||
|
out := strings[0]
|
||||||
|
strings = strings[1:]
|
||||||
|
|
||||||
|
if len(separator) == 0 {
|
||||||
|
return out.AppendTString(strings...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, str := range strings {
|
||||||
|
out = append(out, str.Prepend(separator)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Clone() TString {
|
||||||
|
newStr := make(TString, len(str))
|
||||||
|
copy(newStr, str)
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) AppendTString(dataList ...TString) TString {
|
||||||
|
newStr := str
|
||||||
|
for _, data := range dataList {
|
||||||
|
newStr = append(newStr, data...)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) PrependTString(data TString) TString {
|
||||||
|
return append(data, str...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Append(data string) TString {
|
||||||
|
return str.AppendCustom(data, func(r rune) Cell {
|
||||||
|
return NewCell(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) TrimSpace() TString {
|
||||||
|
return str.Trim(unicode.IsSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Trim(fn func(rune) bool) TString {
|
||||||
|
return str.TrimLeft(fn).TrimRight(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) TrimLeft(fn func(rune) bool) TString {
|
||||||
|
for index, cell := range str {
|
||||||
|
if !fn(cell.Char) {
|
||||||
|
return append(NewBlankTString(), str[index:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewBlankTString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) TrimRight(fn func(rune) bool) TString {
|
||||||
|
for i := len(str) - 1; i >= 0; i-- {
|
||||||
|
if !fn(str[i].Char) {
|
||||||
|
return append(NewBlankTString(), str[:i+1]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewBlankTString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) AppendColor(data string, color tcell.Color) TString {
|
||||||
|
return str.AppendCustom(data, func(r rune) Cell {
|
||||||
|
return NewColorCell(r, color)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) AppendStyle(data string, style tcell.Style) TString {
|
||||||
|
return str.AppendCustom(data, func(r rune) Cell {
|
||||||
|
return NewStyleCell(r, style)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) AppendCustom(data string, cellCreator func(rune) Cell) TString {
|
||||||
|
newStr := make(TString, len(str)+len(data))
|
||||||
|
copy(newStr, str)
|
||||||
|
for i, char := range data {
|
||||||
|
newStr[i+len(str)] = cellCreator(char)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Prepend(data string) TString {
|
||||||
|
return str.PrependCustom(data, func(r rune) Cell {
|
||||||
|
return NewCell(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) PrependColor(data string, color tcell.Color) TString {
|
||||||
|
return str.PrependCustom(data, func(r rune) Cell {
|
||||||
|
return NewColorCell(r, color)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) PrependStyle(data string, style tcell.Style) TString {
|
||||||
|
return str.PrependCustom(data, func(r rune) Cell {
|
||||||
|
return NewStyleCell(r, style)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) PrependCustom(data string, cellCreator func(rune) Cell) TString {
|
||||||
|
newStr := make(TString, len(str)+len(data))
|
||||||
|
copy(newStr[len(data):], str)
|
||||||
|
for i, char := range data {
|
||||||
|
newStr[i] = cellCreator(char)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Colorize(from, length int, color tcell.Color) {
|
||||||
|
str.AdjustStyle(from, length, func(style tcell.Style) tcell.Style {
|
||||||
|
return style.Foreground(color)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) AdjustStyle(from, length int, fn func(tcell.Style) tcell.Style) {
|
||||||
|
for i := from; i < from+length; i++ {
|
||||||
|
str[i].Style = fn(str[i].Style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) AdjustStyleFull(fn func(tcell.Style) tcell.Style) {
|
||||||
|
str.AdjustStyle(0, len(str), fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Draw(screen mauview.Screen, x, y int) {
|
||||||
|
for _, cell := range str {
|
||||||
|
x += cell.Draw(screen, x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) RuneWidth() (width int) {
|
||||||
|
for _, cell := range str {
|
||||||
|
width += runewidth.RuneWidth(cell.Char)
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) String() string {
|
||||||
|
var buf strings.Builder
|
||||||
|
for _, cell := range str {
|
||||||
|
buf.WriteRune(cell.Char)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate return string truncated with w cells
|
||||||
|
func (str TString) Truncate(w int) TString {
|
||||||
|
if str.RuneWidth() <= w {
|
||||||
|
return str[:]
|
||||||
|
}
|
||||||
|
width := 0
|
||||||
|
i := 0
|
||||||
|
for ; i < len(str); i++ {
|
||||||
|
cw := runewidth.RuneWidth(str[i].Char)
|
||||||
|
if width+cw > w {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
width += cw
|
||||||
|
}
|
||||||
|
return str[0:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) IndexFrom(r rune, from int) int {
|
||||||
|
for i := from; i < len(str); i++ {
|
||||||
|
if str[i].Char == r {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Index(r rune) int {
|
||||||
|
return str.IndexFrom(r, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Count(r rune) (counter int) {
|
||||||
|
index := 0
|
||||||
|
for {
|
||||||
|
index = str.IndexFrom(r, index)
|
||||||
|
if index < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Split(sep rune) []TString {
|
||||||
|
a := make([]TString, str.Count(sep)+1)
|
||||||
|
i := 0
|
||||||
|
orig := str
|
||||||
|
for {
|
||||||
|
m := orig.Index(sep)
|
||||||
|
if m < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a[i] = orig[:m]
|
||||||
|
orig = orig[m+1:]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
a[i] = orig
|
||||||
|
return a[:i+1]
|
||||||
|
}
|
46
tui/no-crypto-commands.go
Normal file
46
tui/no-crypto-commands.go
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build !cgo
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) {
|
||||||
|
return []string{}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) {
|
||||||
|
return []string{}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdNoCrypto(cmd *Command) {
|
||||||
|
cmd.Reply("This gomuks was built without encryption support")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cmdDevices = cmdNoCrypto
|
||||||
|
cmdDevice = cmdNoCrypto
|
||||||
|
cmdVerifyDevice = cmdNoCrypto
|
||||||
|
cmdVerify = cmdNoCrypto
|
||||||
|
cmdUnverify = cmdNoCrypto
|
||||||
|
cmdBlacklist = cmdNoCrypto
|
||||||
|
cmdResetSession = cmdNoCrypto
|
||||||
|
cmdImportKeys = cmdNoCrypto
|
||||||
|
cmdExportKeys = cmdNoCrypto
|
||||||
|
cmdExportRoomKeys = cmdNoCrypto
|
||||||
|
cmdSSSS = cmdNoCrypto
|
||||||
|
cmdCrossSigning = cmdNoCrypto
|
||||||
|
)
|
143
tui/password-modal.go
Normal file
143
tui/password-modal.go
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PasswordModal struct {
|
||||||
|
mauview.Component
|
||||||
|
|
||||||
|
outputChan chan string
|
||||||
|
cancelChan chan struct{}
|
||||||
|
|
||||||
|
form *mauview.Form
|
||||||
|
|
||||||
|
text *mauview.TextField
|
||||||
|
confirmText *mauview.TextField
|
||||||
|
|
||||||
|
input *mauview.InputField
|
||||||
|
confirmInput *mauview.InputField
|
||||||
|
|
||||||
|
cancel *mauview.Button
|
||||||
|
submit *mauview.Button
|
||||||
|
|
||||||
|
parent *MainView
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) AskPassword(title, thing, placeholder string, isNew bool) (string, bool) {
|
||||||
|
pwm := NewPasswordModal(view, title, thing, placeholder, isNew)
|
||||||
|
view.ShowModal(pwm)
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
return pwm.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPasswordModal(parent *MainView, title, thing, placeholder string, isNew bool) *PasswordModal {
|
||||||
|
if placeholder == "" {
|
||||||
|
placeholder = "correct horse battery staple"
|
||||||
|
}
|
||||||
|
if thing == "" {
|
||||||
|
thing = strings.ToLower(title)
|
||||||
|
}
|
||||||
|
pwm := &PasswordModal{
|
||||||
|
parent: parent,
|
||||||
|
form: mauview.NewForm(),
|
||||||
|
outputChan: make(chan string, 1),
|
||||||
|
cancelChan: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
pwm.form.
|
||||||
|
SetColumns([]int{1, 20, 1, 20, 1}).
|
||||||
|
SetRows([]int{1, 1, 1, 0, 0, 0, 1, 1, 1})
|
||||||
|
|
||||||
|
width := 45
|
||||||
|
height := 8
|
||||||
|
|
||||||
|
pwm.text = mauview.NewTextField()
|
||||||
|
if isNew {
|
||||||
|
pwm.text.SetText(fmt.Sprintf("Create a %s", thing))
|
||||||
|
} else {
|
||||||
|
pwm.text.SetText(fmt.Sprintf("Enter the %s", thing))
|
||||||
|
}
|
||||||
|
pwm.input = mauview.NewInputField().
|
||||||
|
SetMaskCharacter('*').
|
||||||
|
SetPlaceholder(placeholder)
|
||||||
|
pwm.form.AddComponent(pwm.text, 1, 1, 3, 1)
|
||||||
|
pwm.form.AddFormItem(pwm.input, 1, 2, 3, 1)
|
||||||
|
|
||||||
|
if isNew {
|
||||||
|
height += 3
|
||||||
|
pwm.confirmInput = mauview.NewInputField().
|
||||||
|
SetMaskCharacter('*').
|
||||||
|
SetPlaceholder(placeholder).
|
||||||
|
SetChangedFunc(pwm.HandleChange)
|
||||||
|
pwm.input.SetChangedFunc(pwm.HandleChange)
|
||||||
|
pwm.confirmText = mauview.NewTextField().SetText(fmt.Sprintf("Confirm %s", thing))
|
||||||
|
|
||||||
|
pwm.form.SetRow(3, 1).SetRow(4, 1).SetRow(5, 1)
|
||||||
|
pwm.form.AddComponent(pwm.confirmText, 1, 4, 3, 1)
|
||||||
|
pwm.form.AddFormItem(pwm.confirmInput, 1, 5, 3, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pwm.cancel = mauview.NewButton("Cancel").SetOnClick(pwm.ClickCancel)
|
||||||
|
pwm.submit = mauview.NewButton("Submit").SetOnClick(pwm.ClickSubmit)
|
||||||
|
|
||||||
|
pwm.form.AddFormItem(pwm.submit, 3, 7, 1, 1)
|
||||||
|
pwm.form.AddFormItem(pwm.cancel, 1, 7, 1, 1)
|
||||||
|
|
||||||
|
box := mauview.NewBox(pwm.form).SetTitle(title)
|
||||||
|
center := mauview.Center(box, width, height).SetAlwaysFocusChild(true)
|
||||||
|
center.Focus()
|
||||||
|
pwm.form.FocusNextItem()
|
||||||
|
pwm.Component = center
|
||||||
|
|
||||||
|
return pwm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pwm *PasswordModal) HandleChange(_ string) {
|
||||||
|
if pwm.input.GetText() == pwm.confirmInput.GetText() {
|
||||||
|
pwm.submit.SetBackgroundColor(mauview.Styles.ContrastBackgroundColor)
|
||||||
|
} else {
|
||||||
|
pwm.submit.SetBackgroundColor(tcell.ColorDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pwm *PasswordModal) ClickCancel() {
|
||||||
|
pwm.parent.HideModal()
|
||||||
|
pwm.cancelChan <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pwm *PasswordModal) ClickSubmit() {
|
||||||
|
if pwm.confirmInput == nil || pwm.input.GetText() == pwm.confirmInput.GetText() {
|
||||||
|
pwm.parent.HideModal()
|
||||||
|
pwm.outputChan <- pwm.input.GetText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pwm *PasswordModal) Wait() (string, bool) {
|
||||||
|
select {
|
||||||
|
case result := <-pwm.outputChan:
|
||||||
|
return result, true
|
||||||
|
case <-pwm.cancelChan:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
135
tui/rainbow.go
Normal file
135
tui/rainbow.go
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2022 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"crypto/rand"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/rivo/uniseg"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
"github.com/yuin/goldmark/renderer"
|
||||||
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
"github.com/yuin/goldmark/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Rand(n int) (str string) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
rand.Read(b)
|
||||||
|
str = fmt.Sprintf("%x", b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type extRainbow struct{}
|
||||||
|
type rainbowRenderer struct {
|
||||||
|
HardWraps bool
|
||||||
|
ColorID string
|
||||||
|
}
|
||||||
|
|
||||||
|
var ExtensionRainbow = &extRainbow{}
|
||||||
|
var defaultRB = &rainbowRenderer{HardWraps: true, ColorID: Rand(16)}
|
||||||
|
|
||||||
|
func (er *extRainbow) Extend(m goldmark.Markdown) {
|
||||||
|
m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(defaultRB, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *rainbowRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||||
|
reg.Register(ast.KindText, rb.renderText)
|
||||||
|
reg.Register(ast.KindString, rb.renderString)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rainbowBufWriter struct {
|
||||||
|
util.BufWriter
|
||||||
|
ColorID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rbw rainbowBufWriter) WriteString(s string) (int, error) {
|
||||||
|
i := 0
|
||||||
|
graphemes := uniseg.NewGraphemes(s)
|
||||||
|
for graphemes.Next() {
|
||||||
|
runes := graphemes.Runes()
|
||||||
|
if len(runes) == 1 && unicode.IsSpace(runes[0]) {
|
||||||
|
i2, err := rbw.BufWriter.WriteRune(runes[0])
|
||||||
|
i += i2
|
||||||
|
if err != nil {
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i2, err := fmt.Fprintf(rbw.BufWriter, "<font color=\"%s\">%s</font>", rbw.ColorID, graphemes.Str())
|
||||||
|
i += i2
|
||||||
|
if err != nil {
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rbw rainbowBufWriter) Write(data []byte) (int, error) {
|
||||||
|
return rbw.WriteString(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rbw rainbowBufWriter) WriteByte(c byte) error {
|
||||||
|
_, err := rbw.WriteRune(rune(c))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rbw rainbowBufWriter) WriteRune(r rune) (int, error) {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
return rbw.BufWriter.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
return fmt.Fprintf(rbw.BufWriter, "<font color=\"%s\">%c</font>", rbw.ColorID, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *rainbowRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
n := node.(*ast.Text)
|
||||||
|
segment := n.Segment
|
||||||
|
if n.IsRaw() {
|
||||||
|
html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
|
||||||
|
} else {
|
||||||
|
html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
|
||||||
|
if n.HardLineBreak() || (n.SoftLineBreak() && rb.HardWraps) {
|
||||||
|
_, _ = w.WriteString("<br>\n")
|
||||||
|
} else if n.SoftLineBreak() {
|
||||||
|
_ = w.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *rainbowRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
n := node.(*ast.String)
|
||||||
|
if n.IsCode() {
|
||||||
|
_, _ = w.Write(n.Value)
|
||||||
|
} else {
|
||||||
|
if n.IsRaw() {
|
||||||
|
html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, n.Value)
|
||||||
|
} else {
|
||||||
|
html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, n.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
589
tui/room-list.go
Normal file
589
tui/room-list.go
Normal file
|
@ -0,0 +1,589 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tagOrder = map[string]int{
|
||||||
|
"net.maunium.gomuks.fake.invite": 4,
|
||||||
|
"m.favourite": 3,
|
||||||
|
"net.maunium.gomuks.fake.direct": 2,
|
||||||
|
"": 1,
|
||||||
|
"m.lowpriority": -1,
|
||||||
|
"m.server_notice": -2,
|
||||||
|
"net.maunium.gomuks.fake.leave": -3,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagNameList is a list of Matrix tag names where default names are sorted in a hardcoded way.
|
||||||
|
type TagNameList []string
|
||||||
|
|
||||||
|
func (tnl TagNameList) Len() int {
|
||||||
|
return len(tnl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tnl TagNameList) Less(i, j int) bool {
|
||||||
|
orderI, _ := tagOrder[tnl[i]]
|
||||||
|
orderJ, _ := tagOrder[tnl[j]]
|
||||||
|
if orderI != orderJ {
|
||||||
|
return orderI > orderJ
|
||||||
|
}
|
||||||
|
return strings.Compare(tnl[i], tnl[j]) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tnl TagNameList) Swap(i, j int) {
|
||||||
|
tnl[i], tnl[j] = tnl[j], tnl[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomList struct {
|
||||||
|
sync.RWMutex
|
||||||
|
|
||||||
|
parent *MainView
|
||||||
|
|
||||||
|
// The list of tags in display order.
|
||||||
|
tags TagNameList
|
||||||
|
// The list of rooms, in reverse order.
|
||||||
|
items map[string]*TagRoomList
|
||||||
|
// The selected room.
|
||||||
|
selected *rooms.Room
|
||||||
|
selectedTag string
|
||||||
|
|
||||||
|
scrollOffset int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
|
||||||
|
// The item main text color.
|
||||||
|
mainTextColor tcell.Color
|
||||||
|
// The text color for selected items.
|
||||||
|
selectedTextColor tcell.Color
|
||||||
|
// The background color for selected items.
|
||||||
|
selectedBackgroundColor tcell.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoomList(parent *MainView) *RoomList {
|
||||||
|
list := &RoomList{
|
||||||
|
parent: parent,
|
||||||
|
|
||||||
|
items: make(map[string]*TagRoomList),
|
||||||
|
tags: []string{},
|
||||||
|
|
||||||
|
scrollOffset: 0,
|
||||||
|
|
||||||
|
mainTextColor: tcell.ColorDefault,
|
||||||
|
selectedTextColor: tcell.ColorWhite,
|
||||||
|
selectedBackgroundColor: tcell.ColorDarkGreen,
|
||||||
|
}
|
||||||
|
for _, tag := range list.tags {
|
||||||
|
list.items[tag] = NewTagRoomList(list, tag)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Contains(roomID id.RoomID) bool {
|
||||||
|
list.RLock()
|
||||||
|
defer list.RUnlock()
|
||||||
|
for _, trl := range list.items {
|
||||||
|
for _, room := range trl.All() {
|
||||||
|
if room.ID == roomID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Add(room *rooms.Room) {
|
||||||
|
if room.IsReplaced() {
|
||||||
|
debug.Print(room.ID, "is replaced by", room.ReplacedBy(), "-> not adding to room list")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debug.Print("Adding room to list", room.ID, room.GetTitle(), room.IsDirect, room.ReplacedBy(), room.Tags())
|
||||||
|
for _, tag := range room.Tags() {
|
||||||
|
list.AddToTag(tag, room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) checkTag(tag string) {
|
||||||
|
index := list.indexTag(tag)
|
||||||
|
|
||||||
|
trl, ok := list.items[tag]
|
||||||
|
|
||||||
|
if ok && trl.IsEmpty() {
|
||||||
|
delete(list.items, tag)
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok && index == -1 {
|
||||||
|
list.tags = append(list.tags, tag)
|
||||||
|
sort.Sort(list.tags)
|
||||||
|
} else if !ok && index != -1 {
|
||||||
|
list.tags = append(list.tags[0:index], list.tags[index+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) AddToTag(tag rooms.RoomTag, room *rooms.Room) {
|
||||||
|
list.Lock()
|
||||||
|
defer list.Unlock()
|
||||||
|
trl, ok := list.items[tag.Tag]
|
||||||
|
if !ok {
|
||||||
|
list.items[tag.Tag] = NewTagRoomList(list, tag.Tag, NewOrderedRoom(tag.Order, room))
|
||||||
|
} else {
|
||||||
|
trl.Insert(tag.Order, room)
|
||||||
|
}
|
||||||
|
list.checkTag(tag.Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Remove(room *rooms.Room) {
|
||||||
|
for _, tag := range list.tags {
|
||||||
|
list.RemoveFromTag(tag, room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) RemoveFromTag(tag string, room *rooms.Room) {
|
||||||
|
list.Lock()
|
||||||
|
defer list.Unlock()
|
||||||
|
trl, ok := list.items[tag]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
index := trl.Index(room)
|
||||||
|
if index == -1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trl.RemoveIndex(index)
|
||||||
|
|
||||||
|
if trl.IsEmpty() {
|
||||||
|
// delete(list.items, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if room == list.selected {
|
||||||
|
if index > 0 {
|
||||||
|
list.selected = trl.All()[index-1].Room
|
||||||
|
} else if trl.Length() > 0 {
|
||||||
|
list.selected = trl.Visible()[0].Room
|
||||||
|
} else if len(list.items) > 0 {
|
||||||
|
for _, tag := range list.tags {
|
||||||
|
moreItems := list.items[tag]
|
||||||
|
if moreItems.Length() > 0 {
|
||||||
|
list.selected = moreItems.Visible()[0].Room
|
||||||
|
list.selectedTag = tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list.selected = nil
|
||||||
|
list.selectedTag = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.checkTag(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Bump(room *rooms.Room) {
|
||||||
|
list.RLock()
|
||||||
|
defer list.RUnlock()
|
||||||
|
for _, tag := range room.Tags() {
|
||||||
|
trl, ok := list.items[tag.Tag]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trl.Bump(room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Clear() {
|
||||||
|
list.Lock()
|
||||||
|
defer list.Unlock()
|
||||||
|
list.items = make(map[string]*TagRoomList)
|
||||||
|
list.tags = []string{}
|
||||||
|
for _, tag := range list.tags {
|
||||||
|
list.items[tag] = NewTagRoomList(list, tag)
|
||||||
|
}
|
||||||
|
list.selected = nil
|
||||||
|
list.selectedTag = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) SetSelected(tag string, room *rooms.Room) {
|
||||||
|
list.selected = room
|
||||||
|
list.selectedTag = tag
|
||||||
|
pos := list.index(tag, room)
|
||||||
|
if pos <= list.scrollOffset {
|
||||||
|
list.scrollOffset = pos - 1
|
||||||
|
} else if pos >= list.scrollOffset+list.height {
|
||||||
|
list.scrollOffset = pos - list.height + 1
|
||||||
|
}
|
||||||
|
if list.scrollOffset < 0 {
|
||||||
|
list.scrollOffset = 0
|
||||||
|
}
|
||||||
|
debug.Print("Selecting", room.GetTitle(), "in", list.GetTagDisplayName(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) HasSelected() bool {
|
||||||
|
return list.selected != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Selected() (string, *rooms.Room) {
|
||||||
|
return list.selectedTag, list.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) SelectedRoom() *rooms.Room {
|
||||||
|
return list.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) AddScrollOffset(offset int) {
|
||||||
|
list.scrollOffset += offset
|
||||||
|
contentHeight := list.ContentHeight()
|
||||||
|
if list.scrollOffset > contentHeight-list.height {
|
||||||
|
list.scrollOffset = contentHeight - list.height
|
||||||
|
}
|
||||||
|
if list.scrollOffset < 0 {
|
||||||
|
list.scrollOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) First() (string, *rooms.Room) {
|
||||||
|
list.RLock()
|
||||||
|
defer list.RUnlock()
|
||||||
|
return list.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) first() (string, *rooms.Room) {
|
||||||
|
for _, tag := range list.tags {
|
||||||
|
trl := list.items[tag]
|
||||||
|
if trl.HasVisibleRooms() {
|
||||||
|
return tag, trl.FirstVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Last() (string, *rooms.Room) {
|
||||||
|
list.RLock()
|
||||||
|
defer list.RUnlock()
|
||||||
|
return list.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) last() (string, *rooms.Room) {
|
||||||
|
for tagIndex := len(list.tags) - 1; tagIndex >= 0; tagIndex-- {
|
||||||
|
tag := list.tags[tagIndex]
|
||||||
|
trl := list.items[tag]
|
||||||
|
if trl.HasVisibleRooms() {
|
||||||
|
return tag, trl.LastVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) indexTag(tag string) int {
|
||||||
|
for index, entry := range list.tags {
|
||||||
|
if tag == entry {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Previous() (string, *rooms.Room) {
|
||||||
|
list.RLock()
|
||||||
|
defer list.RUnlock()
|
||||||
|
if len(list.items) == 0 {
|
||||||
|
return "", nil
|
||||||
|
} else if list.selected == nil {
|
||||||
|
return list.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
trl := list.items[list.selectedTag]
|
||||||
|
index := trl.IndexVisible(list.selected)
|
||||||
|
indexInvisible := trl.Index(list.selected)
|
||||||
|
if index == -1 && indexInvisible >= 0 {
|
||||||
|
num := trl.TotalLength() - indexInvisible
|
||||||
|
trl.maxShown = int(math.Ceil(float64(num)/10.0) * 10.0)
|
||||||
|
index = trl.IndexVisible(list.selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
if index == trl.Length()-1 {
|
||||||
|
tagIndex := list.indexTag(list.selectedTag)
|
||||||
|
tagIndex--
|
||||||
|
for ; tagIndex >= 0; tagIndex-- {
|
||||||
|
prevTag := list.tags[tagIndex]
|
||||||
|
prevTRL := list.items[prevTag]
|
||||||
|
if prevTRL.HasVisibleRooms() {
|
||||||
|
return prevTag, prevTRL.LastVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list.last()
|
||||||
|
} else if index >= 0 {
|
||||||
|
return list.selectedTag, trl.Visible()[index+1].Room
|
||||||
|
}
|
||||||
|
return list.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Next() (string, *rooms.Room) {
|
||||||
|
list.RLock()
|
||||||
|
defer list.RUnlock()
|
||||||
|
if len(list.items) == 0 {
|
||||||
|
return "", nil
|
||||||
|
} else if list.selected == nil {
|
||||||
|
return list.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
trl := list.items[list.selectedTag]
|
||||||
|
index := trl.IndexVisible(list.selected)
|
||||||
|
indexInvisible := trl.Index(list.selected)
|
||||||
|
if index == -1 && indexInvisible >= 0 {
|
||||||
|
num := trl.TotalLength() - indexInvisible + 1
|
||||||
|
trl.maxShown = int(math.Ceil(float64(num)/10.0) * 10.0)
|
||||||
|
index = trl.IndexVisible(list.selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
if index == 0 {
|
||||||
|
tagIndex := list.indexTag(list.selectedTag)
|
||||||
|
tagIndex++
|
||||||
|
for ; tagIndex < len(list.tags); tagIndex++ {
|
||||||
|
nextTag := list.tags[tagIndex]
|
||||||
|
nextTRL := list.items[nextTag]
|
||||||
|
if nextTRL.HasVisibleRooms() {
|
||||||
|
return nextTag, nextTRL.FirstVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list.first()
|
||||||
|
} else if index > 0 {
|
||||||
|
return list.selectedTag, trl.Visible()[index-1].Room
|
||||||
|
}
|
||||||
|
return list.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextWithActivity Returns next room with activity.
|
||||||
|
//
|
||||||
|
// Sorted by (in priority):
|
||||||
|
//
|
||||||
|
// - Highlights
|
||||||
|
// - Messages
|
||||||
|
// - Other traffic (joins, parts, etc)
|
||||||
|
//
|
||||||
|
// TODO: Sorting. Now just finds first room with new messages.
|
||||||
|
func (list *RoomList) NextWithActivity() (string, *rooms.Room) {
|
||||||
|
list.RLock()
|
||||||
|
defer list.RUnlock()
|
||||||
|
for tag, trl := range list.items {
|
||||||
|
for _, room := range trl.All() {
|
||||||
|
if room.HasNewMessages() {
|
||||||
|
return tag, room.Room
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No room with activity found
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) index(tag string, room *rooms.Room) int {
|
||||||
|
tagIndex := list.indexTag(tag)
|
||||||
|
if tagIndex == -1 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
trl, ok := list.items[tag]
|
||||||
|
localIndex := -1
|
||||||
|
if ok {
|
||||||
|
localIndex = trl.IndexVisible(room)
|
||||||
|
}
|
||||||
|
if localIndex == -1 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
localIndex = trl.Length() - 1 - localIndex
|
||||||
|
|
||||||
|
// Tag header
|
||||||
|
localIndex++
|
||||||
|
|
||||||
|
if tagIndex > 0 {
|
||||||
|
for i := 0; i < tagIndex; i++ {
|
||||||
|
prevTag := list.tags[i]
|
||||||
|
|
||||||
|
prevTRL := list.items[prevTag]
|
||||||
|
localIndex += prevTRL.RenderHeight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return localIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) ContentHeight() (height int) {
|
||||||
|
list.RLock()
|
||||||
|
for _, tag := range list.tags {
|
||||||
|
height += list.items[tag].RenderHeight()
|
||||||
|
}
|
||||||
|
list.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) OnKeyEvent(_ mauview.KeyEvent) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) OnPasteEvent(_ mauview.PasteEvent) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) OnMouseEvent(event mauview.MouseEvent) bool {
|
||||||
|
if event.HasMotion() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch event.Buttons() {
|
||||||
|
case tcell.WheelUp:
|
||||||
|
list.AddScrollOffset(-WheelScrollOffsetDiff)
|
||||||
|
return true
|
||||||
|
case tcell.WheelDown:
|
||||||
|
list.AddScrollOffset(WheelScrollOffsetDiff)
|
||||||
|
return true
|
||||||
|
case tcell.Button1:
|
||||||
|
x, y := event.Position()
|
||||||
|
return list.clickRoom(y, x, event.Modifiers() == tcell.ModCtrl)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Focus() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) Blur() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list *RoomList) clickRoom(line, column int, mod bool) bool {
|
||||||
|
line += list.scrollOffset
|
||||||
|
if line < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
list.RLock()
|
||||||
|
for _, tag := range list.tags {
|
||||||
|
trl := list.items[tag]
|
||||||
|
if line--; line == -1 {
|
||||||
|
trl.ToggleCollapse()
|
||||||
|
list.RUnlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if trl.IsCollapsed() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if line < 0 {
|
||||||
|
break
|
||||||
|
} else if line < trl.Length() {
|
||||||
|
switchToRoom := trl.Visible()[trl.Length()-1-line].Room
|
||||||
|
list.RUnlock()
|
||||||
|
list.parent.SwitchRoom(tag, switchToRoom)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag items
|
||||||
|
line -= trl.Length()
|
||||||
|
|
||||||
|
hasMore := trl.HasInvisibleRooms()
|
||||||
|
hasLess := trl.maxShown > 10
|
||||||
|
if hasMore || hasLess {
|
||||||
|
if line--; line == -1 {
|
||||||
|
diff := 10
|
||||||
|
if mod {
|
||||||
|
diff = 100
|
||||||
|
}
|
||||||
|
if column <= 6 && hasLess {
|
||||||
|
trl.maxShown -= diff
|
||||||
|
} else if column >= list.width-6 && hasMore {
|
||||||
|
trl.maxShown += diff
|
||||||
|
}
|
||||||
|
if trl.maxShown < 10 {
|
||||||
|
trl.maxShown = 10
|
||||||
|
}
|
||||||
|
list.RUnlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tag footer
|
||||||
|
line--
|
||||||
|
}
|
||||||
|
list.RUnlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsRegex = regexp.MustCompile("^[a-z]+\\.[a-z]+(?:\\.[a-z]+)*$")
|
||||||
|
|
||||||
|
func (list *RoomList) GetTagDisplayName(tag string) string {
|
||||||
|
switch {
|
||||||
|
case len(tag) == 0:
|
||||||
|
return "Rooms"
|
||||||
|
case tag == "m.favourite":
|
||||||
|
return "Favorites"
|
||||||
|
case tag == "m.lowpriority":
|
||||||
|
return "Low Priority"
|
||||||
|
case tag == "m.server_notice":
|
||||||
|
return "System Alerts"
|
||||||
|
case tag == "net.maunium.gomuks.fake.direct":
|
||||||
|
return "People"
|
||||||
|
case tag == "net.maunium.gomuks.fake.invite":
|
||||||
|
return "Invites"
|
||||||
|
case tag == "net.maunium.gomuks.fake.leave":
|
||||||
|
return "Historical"
|
||||||
|
case strings.HasPrefix(tag, "u."):
|
||||||
|
return tag[len("u."):]
|
||||||
|
case !nsRegex.MatchString(tag):
|
||||||
|
return tag
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw draws this primitive onto the screen.
|
||||||
|
func (list *RoomList) Draw(screen mauview.Screen) {
|
||||||
|
list.width, list.height = screen.Size()
|
||||||
|
y := 0
|
||||||
|
yLimit := y + list.height
|
||||||
|
y -= list.scrollOffset
|
||||||
|
|
||||||
|
// Draw the list items.
|
||||||
|
list.RLock()
|
||||||
|
for _, tag := range list.tags {
|
||||||
|
trl := list.items[tag]
|
||||||
|
tagDisplayName := list.GetTagDisplayName(tag)
|
||||||
|
if trl == nil || len(tagDisplayName) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeight := trl.RenderHeight()
|
||||||
|
if y+renderHeight >= yLimit {
|
||||||
|
renderHeight = yLimit - y
|
||||||
|
}
|
||||||
|
trl.Draw(mauview.NewProxyScreen(screen, 0, y, list.width, renderHeight))
|
||||||
|
y += renderHeight
|
||||||
|
if y >= yLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.RUnlock()
|
||||||
|
}
|
929
tui/room-view.go
Normal file
929
tui/room-view.go
Normal file
|
@ -0,0 +1,929 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
"github.com/zyedidia/clipboard"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/crypto/attachment"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/format"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
"maunium.net/go/gomuks/tui/messages"
|
||||||
|
"maunium.net/go/gomuks/tui/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoomView struct {
|
||||||
|
topic *mauview.TextView
|
||||||
|
content *MessageView
|
||||||
|
status *mauview.TextField
|
||||||
|
userList *MemberList
|
||||||
|
ulBorder *widget.Border
|
||||||
|
input *mauview.InputArea
|
||||||
|
Room *rooms.Room
|
||||||
|
|
||||||
|
topicScreen *mauview.ProxyScreen
|
||||||
|
contentScreen *mauview.ProxyScreen
|
||||||
|
statusScreen *mauview.ProxyScreen
|
||||||
|
inputScreen *mauview.ProxyScreen
|
||||||
|
ulBorderScreen *mauview.ProxyScreen
|
||||||
|
ulScreen *mauview.ProxyScreen
|
||||||
|
|
||||||
|
userListLoaded bool
|
||||||
|
|
||||||
|
prevScreen mauview.Screen
|
||||||
|
|
||||||
|
parent *MainView
|
||||||
|
config *config.Config
|
||||||
|
|
||||||
|
typing []string
|
||||||
|
|
||||||
|
selecting bool
|
||||||
|
selectReason SelectReason
|
||||||
|
selectContent string
|
||||||
|
|
||||||
|
replying *muksevt.Event
|
||||||
|
|
||||||
|
editing *muksevt.Event
|
||||||
|
editMoveText string
|
||||||
|
|
||||||
|
completions struct {
|
||||||
|
list []string
|
||||||
|
textCache string
|
||||||
|
time time.Time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoomView(parent *MainView, room *rooms.Room) *RoomView {
|
||||||
|
view := &RoomView{
|
||||||
|
topic: mauview.NewTextView(),
|
||||||
|
status: mauview.NewTextField(),
|
||||||
|
userList: NewMemberList(),
|
||||||
|
ulBorder: widget.NewBorder(),
|
||||||
|
input: mauview.NewInputArea(),
|
||||||
|
Room: room,
|
||||||
|
|
||||||
|
topicScreen: &mauview.ProxyScreen{OffsetX: 0, OffsetY: 0, Height: TopicBarHeight},
|
||||||
|
contentScreen: &mauview.ProxyScreen{OffsetX: 0, OffsetY: StatusBarHeight},
|
||||||
|
statusScreen: &mauview.ProxyScreen{OffsetX: 0, Height: StatusBarHeight},
|
||||||
|
inputScreen: &mauview.ProxyScreen{OffsetX: 0},
|
||||||
|
ulBorderScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListBorderWidth},
|
||||||
|
ulScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListWidth},
|
||||||
|
|
||||||
|
parent: parent,
|
||||||
|
}
|
||||||
|
view.content = NewMessageView(view)
|
||||||
|
view.Room.SetPreUnload(func() bool {
|
||||||
|
if view.parent.currentRoom == view {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
view.content.Unload()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
view.Room.SetPostLoad(view.loadTyping)
|
||||||
|
|
||||||
|
view.input.
|
||||||
|
SetTextColor(tcell.ColorDefault).
|
||||||
|
SetBackgroundColor(tcell.ColorDefault).
|
||||||
|
SetPlaceholder("Send a message...").
|
||||||
|
SetPlaceholderTextColor(tcell.ColorGray).
|
||||||
|
SetTabCompleteFunc(view.InputTabComplete).
|
||||||
|
SetPressKeyUpAtStartFunc(view.EditPrevious).
|
||||||
|
SetPressKeyDownAtEndFunc(view.EditNext)
|
||||||
|
|
||||||
|
if room.Encrypted {
|
||||||
|
view.input.SetPlaceholder("Send an encrypted message...")
|
||||||
|
}
|
||||||
|
|
||||||
|
view.topic.
|
||||||
|
SetTextColor(tcell.ColorWhite).
|
||||||
|
SetBackgroundColor(tcell.ColorDarkGreen)
|
||||||
|
|
||||||
|
view.status.SetBackgroundColor(tcell.ColorDimGray)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SetInputChangedFunc(fn func(room *RoomView, text string)) *RoomView {
|
||||||
|
view.input.SetChangedFunc(func(text string) {
|
||||||
|
fn(view, text)
|
||||||
|
})
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SetInputText(newText string) *RoomView {
|
||||||
|
view.input.SetTextAndMoveCursor(newText)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) GetInputText() string {
|
||||||
|
return view.input.GetText()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) Focus() {
|
||||||
|
view.input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) Blur() {
|
||||||
|
view.StopSelecting()
|
||||||
|
view.input.Blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) StartSelecting(reason SelectReason, content string) {
|
||||||
|
view.selecting = true
|
||||||
|
view.selectReason = reason
|
||||||
|
view.selectContent = content
|
||||||
|
msgView := view.MessageView()
|
||||||
|
if msgView.selected != nil {
|
||||||
|
view.OnSelect(msgView.selected)
|
||||||
|
} else {
|
||||||
|
view.input.Blur()
|
||||||
|
view.SelectPrevious()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) StopSelecting() {
|
||||||
|
view.selecting = false
|
||||||
|
view.selectContent = ""
|
||||||
|
view.MessageView().SetSelected(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) OnSelect(message *messages.UIMessage) {
|
||||||
|
if !view.selecting || message == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch view.selectReason {
|
||||||
|
case SelectReply:
|
||||||
|
view.replying = message.Event
|
||||||
|
if len(view.selectContent) > 0 {
|
||||||
|
go view.SendMessage(event.MsgText, view.selectContent)
|
||||||
|
}
|
||||||
|
case SelectEdit:
|
||||||
|
view.SetEditing(message.Event)
|
||||||
|
case SelectReact:
|
||||||
|
go view.SendReaction(message.EventID, view.selectContent)
|
||||||
|
case SelectRedact:
|
||||||
|
go view.Redact(message.EventID, view.selectContent)
|
||||||
|
case SelectDownload, SelectOpen:
|
||||||
|
msg, ok := message.Renderer.(*messages.FileMessage)
|
||||||
|
if ok {
|
||||||
|
path := ""
|
||||||
|
if len(view.selectContent) > 0 {
|
||||||
|
path = view.selectContent
|
||||||
|
} else if view.selectReason == SelectDownload {
|
||||||
|
path = msg.Body
|
||||||
|
}
|
||||||
|
go view.Download(msg.URL, msg.File, path, view.selectReason == SelectOpen)
|
||||||
|
}
|
||||||
|
case SelectCopy:
|
||||||
|
go view.CopyToClipboard(message.Renderer.PlainText(), view.selectContent)
|
||||||
|
}
|
||||||
|
view.selecting = false
|
||||||
|
view.selectContent = ""
|
||||||
|
view.MessageView().SetSelected(nil)
|
||||||
|
view.input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) GetStatus() string {
|
||||||
|
var buf strings.Builder
|
||||||
|
|
||||||
|
if view.editing != nil {
|
||||||
|
buf.WriteString("Editing message - ")
|
||||||
|
} else if view.replying != nil {
|
||||||
|
buf.WriteString("Replying to ")
|
||||||
|
buf.WriteString(string(view.replying.Sender))
|
||||||
|
buf.WriteString(" - ")
|
||||||
|
} else if view.selecting {
|
||||||
|
buf.WriteString("Selecting message to ")
|
||||||
|
buf.WriteString(string(view.selectReason))
|
||||||
|
buf.WriteString(" - ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(view.completions.list) > 0 {
|
||||||
|
if view.completions.textCache != view.input.GetText() || view.completions.time.Add(10*time.Second).Before(time.Now()) {
|
||||||
|
view.completions.list = []string{}
|
||||||
|
} else {
|
||||||
|
buf.WriteString(strings.Join(view.completions.list, ", "))
|
||||||
|
buf.WriteString(" - ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(view.typing) == 1 {
|
||||||
|
buf.WriteString("Typing: " + string(view.typing[0]))
|
||||||
|
buf.WriteString(" - ")
|
||||||
|
} else if len(view.typing) > 1 {
|
||||||
|
buf.WriteString("Typing: ")
|
||||||
|
for i, userID := range view.typing {
|
||||||
|
if i == len(view.typing)-1 {
|
||||||
|
buf.WriteString(" and ")
|
||||||
|
} else if i > 0 {
|
||||||
|
buf.WriteString(", ")
|
||||||
|
}
|
||||||
|
buf.WriteString(string(userID))
|
||||||
|
}
|
||||||
|
buf.WriteString(" - ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(buf.String(), " - ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants defining the size of the room view grid.
|
||||||
|
const (
|
||||||
|
UserListBorderWidth = 1
|
||||||
|
UserListWidth = 20
|
||||||
|
StaticHorizontalSpace = UserListBorderWidth + UserListWidth
|
||||||
|
|
||||||
|
TopicBarHeight = 1
|
||||||
|
StatusBarHeight = 1
|
||||||
|
|
||||||
|
MaxInputHeight = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
func (view *RoomView) Draw(screen mauview.Screen) {
|
||||||
|
width, height := screen.Size()
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if view.prevScreen != screen {
|
||||||
|
view.topicScreen.Parent = screen
|
||||||
|
view.contentScreen.Parent = screen
|
||||||
|
view.statusScreen.Parent = screen
|
||||||
|
view.inputScreen.Parent = screen
|
||||||
|
view.ulBorderScreen.Parent = screen
|
||||||
|
view.ulScreen.Parent = screen
|
||||||
|
view.prevScreen = screen
|
||||||
|
}
|
||||||
|
|
||||||
|
view.input.PrepareDraw(width)
|
||||||
|
inputHeight := view.input.GetTextHeight()
|
||||||
|
if inputHeight > MaxInputHeight {
|
||||||
|
inputHeight = MaxInputHeight
|
||||||
|
} else if inputHeight < 1 {
|
||||||
|
inputHeight = 1
|
||||||
|
}
|
||||||
|
contentHeight := height - inputHeight - TopicBarHeight - StatusBarHeight
|
||||||
|
contentWidth := width - StaticHorizontalSpace
|
||||||
|
if view.config.Preferences.HideUserList {
|
||||||
|
contentWidth = width
|
||||||
|
}
|
||||||
|
|
||||||
|
view.topicScreen.Width = width
|
||||||
|
view.contentScreen.Width = contentWidth
|
||||||
|
view.contentScreen.Height = contentHeight
|
||||||
|
view.statusScreen.OffsetY = view.contentScreen.YEnd()
|
||||||
|
view.statusScreen.Width = width
|
||||||
|
view.inputScreen.Width = width
|
||||||
|
view.inputScreen.OffsetY = view.statusScreen.YEnd()
|
||||||
|
view.inputScreen.Height = inputHeight
|
||||||
|
view.ulBorderScreen.OffsetX = view.contentScreen.XEnd()
|
||||||
|
view.ulBorderScreen.Height = contentHeight
|
||||||
|
view.ulScreen.OffsetX = view.ulBorderScreen.XEnd()
|
||||||
|
view.ulScreen.Height = contentHeight
|
||||||
|
|
||||||
|
// Draw everything
|
||||||
|
view.topic.Draw(view.topicScreen)
|
||||||
|
view.content.Draw(view.contentScreen)
|
||||||
|
view.status.SetText(view.GetStatus())
|
||||||
|
view.status.Draw(view.statusScreen)
|
||||||
|
view.input.Draw(view.inputScreen)
|
||||||
|
if !view.config.Preferences.HideUserList {
|
||||||
|
view.ulBorder.Draw(view.ulBorderScreen)
|
||||||
|
view.userList.Draw(view.ulScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) ClearAllContext() {
|
||||||
|
view.SetEditing(nil)
|
||||||
|
view.StopSelecting()
|
||||||
|
view.replying = nil
|
||||||
|
view.input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) OnKeyEvent(event mauview.KeyEvent) bool {
|
||||||
|
msgView := view.MessageView()
|
||||||
|
//helpp
|
||||||
|
kb := config.Keybind{
|
||||||
|
Key: event.Key(),
|
||||||
|
Ch: event.Rune(),
|
||||||
|
Mod: event.Modifiers(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if view.selecting {
|
||||||
|
switch view.config.Keybindings.Visual[kb] {
|
||||||
|
case "clear":
|
||||||
|
view.ClearAllContext()
|
||||||
|
case "select_prev":
|
||||||
|
view.SelectPrevious()
|
||||||
|
case "select_next":
|
||||||
|
view.SelectNext()
|
||||||
|
case "confirm":
|
||||||
|
view.OnSelect(msgView.selected)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch view.config.Keybindings.Room[kb] {
|
||||||
|
case "clear":
|
||||||
|
view.ClearAllContext()
|
||||||
|
return true
|
||||||
|
case "scroll_up":
|
||||||
|
if msgView.IsAtTop() {
|
||||||
|
go view.parent.LoadHistory(view.Room.ID)
|
||||||
|
}
|
||||||
|
msgView.AddScrollOffset(+msgView.Height() / 2)
|
||||||
|
return true
|
||||||
|
case "scroll_down":
|
||||||
|
msgView.AddScrollOffset(-msgView.Height() / 2)
|
||||||
|
return true
|
||||||
|
case "send":
|
||||||
|
view.InputSubmit(view.input.GetText())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return view.input.OnKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) OnPasteEvent(event mauview.PasteEvent) bool {
|
||||||
|
return view.input.OnPasteEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) OnMouseEvent(event mauview.MouseEvent) bool {
|
||||||
|
switch {
|
||||||
|
case view.contentScreen.IsInArea(event.Position()):
|
||||||
|
return view.content.OnMouseEvent(view.contentScreen.OffsetMouseEvent(event))
|
||||||
|
case view.topicScreen.IsInArea(event.Position()):
|
||||||
|
return view.topic.OnMouseEvent(view.topicScreen.OffsetMouseEvent(event))
|
||||||
|
case view.inputScreen.IsInArea(event.Position()):
|
||||||
|
return view.input.OnMouseEvent(view.inputScreen.OffsetMouseEvent(event))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SetCompletions(completions []string) {
|
||||||
|
view.completions.list = completions
|
||||||
|
view.completions.textCache = view.input.GetText()
|
||||||
|
view.completions.time = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) loadTyping() {
|
||||||
|
for index, user := range view.typing {
|
||||||
|
member := view.Room.GetMember(id.UserID(user))
|
||||||
|
if member != nil {
|
||||||
|
view.typing[index] = member.Displayname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SetTyping(users []id.UserID) {
|
||||||
|
view.typing = make([]string, len(users))
|
||||||
|
for i, user := range users {
|
||||||
|
view.typing[i] = string(user)
|
||||||
|
}
|
||||||
|
if view.Room.Loaded() {
|
||||||
|
view.loadTyping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var editHTMLParser = &format.HTMLParser{
|
||||||
|
PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string {
|
||||||
|
if len(eventID) > 0 {
|
||||||
|
return fmt.Sprintf(`[%s](https://matrix.to/#/%s/%s)`, displayname, mxid, eventID)
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf(`[%s](https://matrix.to/#/%s)`, displayname, mxid)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Newline: "\n",
|
||||||
|
HorizontalLine: "\n---\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SetEditing(evt *muksevt.Event) {
|
||||||
|
if evt == nil {
|
||||||
|
view.editing = nil
|
||||||
|
view.SetInputText(view.editMoveText)
|
||||||
|
view.editMoveText = ""
|
||||||
|
} else {
|
||||||
|
if view.editing == nil {
|
||||||
|
view.editMoveText = view.GetInputText()
|
||||||
|
}
|
||||||
|
view.editing = evt
|
||||||
|
// replying should never be non-nil when SetEditing, but do this just to be safe
|
||||||
|
view.replying = nil
|
||||||
|
msgContent := view.editing.Content.AsMessage()
|
||||||
|
if len(view.editing.Gomuks.Edits) > 0 {
|
||||||
|
// This feels kind of dangerous, but I think it works
|
||||||
|
msgContent = view.editing.Gomuks.Edits[len(view.editing.Gomuks.Edits)-1].Content.AsMessage().NewContent
|
||||||
|
}
|
||||||
|
text := msgContent.Body
|
||||||
|
if len(msgContent.FormattedBody) > 0 && (!view.config.Preferences.DisableMarkdown || !view.config.Preferences.DisableHTML) {
|
||||||
|
if view.config.Preferences.DisableMarkdown {
|
||||||
|
text = msgContent.FormattedBody
|
||||||
|
} else {
|
||||||
|
text = editHTMLParser.Parse(msgContent.FormattedBody, make(format.Context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msgContent.MsgType == event.MsgEmote {
|
||||||
|
text = "/me " + text
|
||||||
|
}
|
||||||
|
view.input.SetText(text)
|
||||||
|
}
|
||||||
|
view.status.SetText(view.GetStatus())
|
||||||
|
view.input.SetCursorOffset(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type findFilter func(evt *muksevt.Event) bool
|
||||||
|
|
||||||
|
func (view *RoomView) filterOwnOnly(evt *muksevt.Event) bool {
|
||||||
|
return evt.Sender == view.parent.matrix.Client().UserID && evt.Type == event.EventMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) filterMediaOnly(evt *muksevt.Event) bool {
|
||||||
|
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
|
||||||
|
return ok && (content.MsgType == event.MsgFile ||
|
||||||
|
content.MsgType == event.MsgImage ||
|
||||||
|
content.MsgType == event.MsgAudio ||
|
||||||
|
content.MsgType == event.MsgVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) findMessage(current *muksevt.Event, forward bool, allow findFilter) *messages.UIMessage {
|
||||||
|
currentFound := current == nil
|
||||||
|
msgs := view.MessageView().messages
|
||||||
|
for i := 0; i < len(msgs); i++ {
|
||||||
|
index := i
|
||||||
|
if !forward {
|
||||||
|
index = len(msgs) - i - 1
|
||||||
|
}
|
||||||
|
evt := msgs[index]
|
||||||
|
if evt.EventID == "" || string(evt.EventID) == evt.TxnID || evt.IsService {
|
||||||
|
continue
|
||||||
|
} else if currentFound {
|
||||||
|
if allow == nil || allow(evt.Event) {
|
||||||
|
return evt
|
||||||
|
}
|
||||||
|
} else if evt.EventID == current.ID {
|
||||||
|
currentFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) EditNext() {
|
||||||
|
if view.editing == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foundMsg := view.findMessage(view.editing, true, view.filterOwnOnly)
|
||||||
|
view.SetEditing(foundMsg.GetEvent())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) EditPrevious() {
|
||||||
|
if view.replying != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foundMsg := view.findMessage(view.editing, false, view.filterOwnOnly)
|
||||||
|
if foundMsg != nil {
|
||||||
|
view.SetEditing(foundMsg.GetEvent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SelectNext() {
|
||||||
|
msgView := view.MessageView()
|
||||||
|
if msgView.selected == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var filter findFilter
|
||||||
|
if view.selectReason == SelectDownload || view.selectReason == SelectOpen {
|
||||||
|
filter = view.filterMediaOnly
|
||||||
|
}
|
||||||
|
foundMsg := view.findMessage(msgView.selected.GetEvent(), true, filter)
|
||||||
|
if foundMsg != nil {
|
||||||
|
msgView.SetSelected(foundMsg)
|
||||||
|
// TODO scroll selected message into view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SelectPrevious() {
|
||||||
|
msgView := view.MessageView()
|
||||||
|
var filter findFilter
|
||||||
|
if view.selectReason == SelectDownload || view.selectReason == SelectOpen {
|
||||||
|
filter = view.filterMediaOnly
|
||||||
|
}
|
||||||
|
foundMsg := view.findMessage(msgView.selected.GetEvent(), false, filter)
|
||||||
|
if foundMsg != nil {
|
||||||
|
msgView.SetSelected(foundMsg)
|
||||||
|
// TODO scroll selected message into view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type completion struct {
|
||||||
|
displayName string
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AutocompleteUser(existingText string) (completions []completion) {
|
||||||
|
textWithoutPrefix := strings.TrimPrefix(existingText, "@")
|
||||||
|
for userID, user := range view.Room.GetMembers() {
|
||||||
|
if user.Displayname == textWithoutPrefix || string(userID) == existingText {
|
||||||
|
// Exact match, return that.
|
||||||
|
return []completion{{user.Displayname, string(userID)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(user.Displayname, textWithoutPrefix) || strings.HasPrefix(string(userID), existingText) {
|
||||||
|
completions = append(completions, completion{user.Displayname, string(userID)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AutocompleteRoom(existingText string) (completions []completion) {
|
||||||
|
for _, room := range view.parent.rooms {
|
||||||
|
alias := string(room.Room.GetCanonicalAlias())
|
||||||
|
if alias == existingText {
|
||||||
|
// Exact match, return that.
|
||||||
|
return []completion{{alias, string(room.Room.ID)}}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(alias, existingText) {
|
||||||
|
completions = append(completions, completion{alias, string(room.Room.ID)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AutocompleteEmoji(word string) (completions []string) {
|
||||||
|
if word[0] != ':' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var valueCompletion1 string
|
||||||
|
var manyValues bool
|
||||||
|
for name, value := range emoji.CodeMap() {
|
||||||
|
if name == word {
|
||||||
|
return []string{value}
|
||||||
|
} else if strings.HasPrefix(name, word) {
|
||||||
|
completions = append(completions, name)
|
||||||
|
if valueCompletion1 == "" {
|
||||||
|
valueCompletion1 = value
|
||||||
|
} else if valueCompletion1 != value {
|
||||||
|
manyValues = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !manyValues && len(completions) > 0 {
|
||||||
|
return []string{emoji.CodeMap()[completions[0]]}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWordToTabComplete(text string) string {
|
||||||
|
output := ""
|
||||||
|
runes := []rune(text)
|
||||||
|
for i := len(runes) - 1; i >= 0; i-- {
|
||||||
|
if unicode.IsSpace(runes[i]) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output = string(runes[i]) + output
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mentionMarkdown = "[%[1]s](https://matrix.to/#/%[2]s)"
|
||||||
|
mentionHTML = `<a href="https://matrix.to/#/%[2]s">%[1]s</a>`
|
||||||
|
mentionPlaintext = "%[1]s"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (view *RoomView) defaultAutocomplete(word string, startIndex int) (strCompletions []string, strCompletion string) {
|
||||||
|
if len(word) == 0 {
|
||||||
|
return []string{}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
completions := view.AutocompleteUser(word)
|
||||||
|
completions = append(completions, view.AutocompleteRoom(word)...)
|
||||||
|
|
||||||
|
if len(completions) == 1 {
|
||||||
|
completion := completions[0]
|
||||||
|
template := mentionMarkdown
|
||||||
|
if view.config.Preferences.DisableMarkdown {
|
||||||
|
if view.config.Preferences.DisableHTML {
|
||||||
|
template = mentionPlaintext
|
||||||
|
} else {
|
||||||
|
template = mentionHTML
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strCompletion = fmt.Sprintf(template, completion.displayName, completion.id)
|
||||||
|
if startIndex == 0 && completion.id[0] == '@' {
|
||||||
|
strCompletion = strCompletion + ":"
|
||||||
|
}
|
||||||
|
} else if len(completions) > 1 {
|
||||||
|
for _, completion := range completions {
|
||||||
|
strCompletions = append(strCompletions, completion.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strCompletions = append(strCompletions, view.parent.cmdProcessor.AutocompleteCommand(word)...)
|
||||||
|
strCompletions = append(strCompletions, view.AutocompleteEmoji(word)...)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) InputTabComplete(text string, cursorOffset int) {
|
||||||
|
if len(text) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
str := runewidth.Truncate(text, cursorOffset, "")
|
||||||
|
word := findWordToTabComplete(str)
|
||||||
|
startIndex := len(str) - len(word)
|
||||||
|
|
||||||
|
var strCompletion string
|
||||||
|
|
||||||
|
strCompletions, newText, ok := view.parent.cmdProcessor.Autocomplete(view, text, cursorOffset)
|
||||||
|
if !ok {
|
||||||
|
strCompletions, strCompletion = view.defaultAutocomplete(word, startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(strCompletions) > 0 {
|
||||||
|
strCompletion = util.LongestCommonPrefix(strCompletions)
|
||||||
|
sort.Sort(sort.StringSlice(strCompletions))
|
||||||
|
}
|
||||||
|
if len(strCompletion) > 0 && len(strCompletions) < 2 {
|
||||||
|
strCompletion += " "
|
||||||
|
strCompletions = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(strCompletion) > 0 && newText == text {
|
||||||
|
newText = str[0:startIndex] + strCompletion + text[len(str):]
|
||||||
|
}
|
||||||
|
|
||||||
|
view.input.SetTextAndMoveCursor(newText)
|
||||||
|
view.SetCompletions(strCompletions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) InputSubmit(text string) {
|
||||||
|
if len(text) == 0 {
|
||||||
|
return
|
||||||
|
} else if cmd := view.parent.cmdProcessor.ParseCommand(view, text); cmd != nil {
|
||||||
|
go view.parent.cmdProcessor.HandleCommand(cmd)
|
||||||
|
} else {
|
||||||
|
go view.SendMessage(event.MsgText, text)
|
||||||
|
}
|
||||||
|
view.editMoveText = ""
|
||||||
|
view.SetInputText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) CopyToClipboard(text string, register string) {
|
||||||
|
if register == "clipboard" || register == "primary" {
|
||||||
|
err := clipboard.WriteAll(text, register)
|
||||||
|
if err != nil {
|
||||||
|
view.AddServiceMessage(fmt.Sprintf("Clipboard unsupported: %v", err))
|
||||||
|
view.parent.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view.AddServiceMessage(fmt.Sprintf("Clipboard register %v unsupported", register))
|
||||||
|
view.parent.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) Download(url id.ContentURI, file *attachment.EncryptedFile, filename string, openFile bool) {
|
||||||
|
path, err := view.parent.matrix.DownloadToDisk(url, file, filename)
|
||||||
|
if err != nil {
|
||||||
|
view.AddServiceMessage(fmt.Sprintf("Failed to download media: %v", err))
|
||||||
|
view.parent.parent.App.Redraw()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.AddServiceMessage(fmt.Sprintf("File downloaded to %s", path))
|
||||||
|
view.parent.parent.App.Redraw()
|
||||||
|
if openFile {
|
||||||
|
debug.Print("Opening file", path)
|
||||||
|
open.Open(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) Redact(eventID id.EventID, reason string) {
|
||||||
|
defer debug.Recover()
|
||||||
|
err := view.parent.matrix.Redact(view.Room.ID, eventID, reason)
|
||||||
|
if err != nil {
|
||||||
|
if httpErr, ok := err.(mautrix.HTTPError); ok {
|
||||||
|
err = httpErr
|
||||||
|
if respErr := httpErr.RespError; respErr != nil {
|
||||||
|
err = respErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.AddServiceMessage(fmt.Sprintf("Failed to redact message: %v", err))
|
||||||
|
view.parent.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SendReaction(eventID id.EventID, reaction string) {
|
||||||
|
defer debug.Recover()
|
||||||
|
if !view.config.Preferences.DisableEmojis {
|
||||||
|
reaction = emoji.Sprint(reaction)
|
||||||
|
}
|
||||||
|
reaction = variationselector.Add(strings.TrimSpace(reaction))
|
||||||
|
debug.Print("Reacting to", eventID, "in", view.Room.ID, "with", reaction)
|
||||||
|
eventID, err := view.parent.matrix.SendEvent(&muksevt.Event{
|
||||||
|
Event: &event.Event{
|
||||||
|
Type: event.EventReaction,
|
||||||
|
RoomID: view.Room.ID,
|
||||||
|
Content: event.Content{Parsed: &event.ReactionEventContent{RelatesTo: event.RelatesTo{
|
||||||
|
Type: event.RelAnnotation,
|
||||||
|
EventID: eventID,
|
||||||
|
Key: reaction,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if httpErr, ok := err.(mautrix.HTTPError); ok {
|
||||||
|
err = httpErr
|
||||||
|
if respErr := httpErr.RespError; respErr != nil {
|
||||||
|
err = respErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.AddServiceMessage(fmt.Sprintf("Failed to send reaction: %v", err))
|
||||||
|
view.parent.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SendMessage(msgtype event.MessageType, text string) {
|
||||||
|
view.SendMessageHTML(msgtype, text, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) getRelationForNewEvent() *ifc.Relation {
|
||||||
|
if view.editing != nil {
|
||||||
|
return &ifc.Relation{
|
||||||
|
Type: event.RelReplace,
|
||||||
|
Event: view.editing,
|
||||||
|
}
|
||||||
|
} else if view.replying != nil {
|
||||||
|
return &ifc.Relation{
|
||||||
|
Type: event.RelReply,
|
||||||
|
Event: view.replying,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SendMessageHTML(msgtype event.MessageType, text, html string) {
|
||||||
|
defer debug.Recover()
|
||||||
|
debug.Print("Sending message", msgtype, text, "to", view.Room.ID)
|
||||||
|
if !view.config.Preferences.DisableEmojis {
|
||||||
|
text = emoji.Sprint(text)
|
||||||
|
}
|
||||||
|
rel := view.getRelationForNewEvent()
|
||||||
|
evt := view.parent.matrix.PrepareMarkdownMessage(view.Room.ID, msgtype, text, html, rel)
|
||||||
|
view.addLocalEcho(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) SendMessageMedia(path string) {
|
||||||
|
defer debug.Recover()
|
||||||
|
debug.Print("Sending media at", path, "to", view.Room.ID)
|
||||||
|
rel := view.getRelationForNewEvent()
|
||||||
|
evt, err := view.parent.matrix.PrepareMediaMessage(view.Room, path, rel)
|
||||||
|
if err != nil {
|
||||||
|
view.AddServiceMessage(fmt.Sprintf("Failed to upload media: %v", err))
|
||||||
|
view.parent.parent.App.Redraw()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.addLocalEcho(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) addLocalEcho(evt *muksevt.Event) {
|
||||||
|
msg := view.parseEvent(evt.SomewhatDangerousCopy())
|
||||||
|
view.content.AddMessage(msg, AppendMessage)
|
||||||
|
view.ClearAllContext()
|
||||||
|
view.status.SetText(view.GetStatus())
|
||||||
|
eventID, err := view.parent.matrix.SendEvent(evt)
|
||||||
|
if err != nil {
|
||||||
|
msg.State = muksevt.StateSendFail
|
||||||
|
// Show shorter version if available
|
||||||
|
if httpErr, ok := err.(mautrix.HTTPError); ok {
|
||||||
|
err = httpErr
|
||||||
|
if respErr := httpErr.RespError; respErr != nil {
|
||||||
|
err = respErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.AddServiceMessage(fmt.Sprintf("Failed to send message: %v", err))
|
||||||
|
view.parent.parent.App.Redraw()
|
||||||
|
} else {
|
||||||
|
debug.Print("Event ID received:", eventID)
|
||||||
|
msg.EventID = eventID
|
||||||
|
msg.State = muksevt.StateDefault
|
||||||
|
view.MessageView().setMessageID(msg)
|
||||||
|
view.parent.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) MessageView() *MessageView {
|
||||||
|
return view.content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) MxRoom() *rooms.Room {
|
||||||
|
return view.Room
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) Update() {
|
||||||
|
topicStr := strings.TrimSpace(strings.ReplaceAll(view.Room.GetTopic(), "\n", " "))
|
||||||
|
if view.config.Preferences.HideRoomList {
|
||||||
|
if len(topicStr) > 0 {
|
||||||
|
topicStr = fmt.Sprintf("%s - %s", view.Room.GetTitle(), topicStr)
|
||||||
|
} else {
|
||||||
|
topicStr = view.Room.GetTitle()
|
||||||
|
}
|
||||||
|
topicStr = strings.TrimSpace(topicStr)
|
||||||
|
}
|
||||||
|
view.topic.SetText(topicStr)
|
||||||
|
if !view.userListLoaded {
|
||||||
|
view.UpdateUserList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) UpdateUserList() {
|
||||||
|
pls := &event.PowerLevelsEventContent{}
|
||||||
|
if plEvent := view.Room.GetStateEvent(event.StatePowerLevels, ""); plEvent != nil {
|
||||||
|
pls = plEvent.Content.AsPowerLevels()
|
||||||
|
}
|
||||||
|
view.userList.Update(view.Room.GetMembers(), pls)
|
||||||
|
view.userListLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AddServiceMessage(text string) {
|
||||||
|
view.content.AddMessage(messages.NewServiceMessage(text), AppendMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) parseEvent(evt *muksevt.Event) *messages.UIMessage {
|
||||||
|
return messages.ParseEvent(view.parent.matrix, view.parent, view.Room, evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AddHistoryEvent(evt *muksevt.Event) {
|
||||||
|
if msg := view.parseEvent(evt); msg != nil {
|
||||||
|
view.content.AddMessage(msg, PrependMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AddEvent(evt *muksevt.Event) ifc.Message {
|
||||||
|
if msg := view.parseEvent(evt); msg != nil {
|
||||||
|
view.content.AddMessage(msg, AppendMessage)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AddRedaction(redactedEvt *muksevt.Event) {
|
||||||
|
view.AddEvent(redactedEvt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AddEdit(evt *muksevt.Event) {
|
||||||
|
if msg := view.parseEvent(evt); msg != nil {
|
||||||
|
view.content.AddMessage(msg, IgnoreMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AddReaction(evt *muksevt.Event, key string) {
|
||||||
|
msgView := view.MessageView()
|
||||||
|
msg := msgView.getMessageByID(evt.ID)
|
||||||
|
if msg == nil {
|
||||||
|
// Message not in view, nothing to do
|
||||||
|
return
|
||||||
|
}
|
||||||
|
heightChanged := len(msg.Reactions) == 0
|
||||||
|
msg.AddReaction(key)
|
||||||
|
if heightChanged {
|
||||||
|
// Replace buffer to update height of message
|
||||||
|
msgView.replaceBuffer(msg, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) GetEvent(eventID id.EventID) ifc.Message {
|
||||||
|
message, ok := view.content.messageIDs[eventID]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
71
tui/syncing-modal.go
Normal file
71
tui/syncing-modal.go
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncingModal struct {
|
||||||
|
parent *MainView
|
||||||
|
text *mauview.TextView
|
||||||
|
progress *mauview.ProgressBar
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSyncingModal(parent *MainView) (mauview.Component, *SyncingModal) {
|
||||||
|
sm := &SyncingModal{
|
||||||
|
parent: parent,
|
||||||
|
progress: mauview.NewProgressBar(),
|
||||||
|
text: mauview.NewTextView(),
|
||||||
|
}
|
||||||
|
return mauview.Center(
|
||||||
|
mauview.NewBox(
|
||||||
|
mauview.NewFlex().
|
||||||
|
SetDirection(mauview.FlexRow).
|
||||||
|
AddFixedComponent(sm.progress, 1).
|
||||||
|
AddFixedComponent(mauview.Center(sm.text, 40, 1), 1)).
|
||||||
|
SetTitle("Synchronizing"),
|
||||||
|
42, 4).
|
||||||
|
SetAlwaysFocusChild(true), sm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SyncingModal) SetMessage(text string) {
|
||||||
|
sm.text.SetText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SyncingModal) SetIndeterminate() {
|
||||||
|
sm.progress.SetIndeterminate(true)
|
||||||
|
sm.parent.parent.App.SetRedrawTicker(100 * time.Millisecond)
|
||||||
|
sm.parent.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SyncingModal) SetSteps(max int) {
|
||||||
|
sm.progress.SetMax(max)
|
||||||
|
sm.progress.SetIndeterminate(false)
|
||||||
|
sm.parent.parent.App.SetRedrawTicker(1 * time.Minute)
|
||||||
|
sm.parent.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SyncingModal) Step() {
|
||||||
|
sm.progress.Increment(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SyncingModal) Close() {
|
||||||
|
sm.parent.HideModal()
|
||||||
|
}
|
329
tui/tag-room-list.go
Normal file
329
tui/tag-room-list.go
Normal file
|
@ -0,0 +1,329 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderedRoom struct {
|
||||||
|
*rooms.Room
|
||||||
|
order float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderedRoom(order json.Number, room *rooms.Room) *OrderedRoom {
|
||||||
|
numOrder, err := order.Float64()
|
||||||
|
if err != nil {
|
||||||
|
numOrder = 0.5
|
||||||
|
}
|
||||||
|
return &OrderedRoom{
|
||||||
|
Room: room,
|
||||||
|
order: numOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDefaultOrderedRoom(room *rooms.Room) *OrderedRoom {
|
||||||
|
return NewOrderedRoom("0.5", room)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (or *OrderedRoom) Draw(roomList *RoomList, screen mauview.Screen, x, y, lineWidth int, isSelected bool) {
|
||||||
|
style := tcell.StyleDefault.
|
||||||
|
Foreground(roomList.mainTextColor).
|
||||||
|
Bold(or.HasNewMessages())
|
||||||
|
if isSelected {
|
||||||
|
style = style.
|
||||||
|
Foreground(roomList.selectedTextColor).
|
||||||
|
Background(roomList.selectedBackgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadCount := or.UnreadCount()
|
||||||
|
|
||||||
|
widget.WriteLinePadded(screen, mauview.AlignLeft, or.GetTitle(), x, y, lineWidth, style)
|
||||||
|
|
||||||
|
if unreadCount > 0 {
|
||||||
|
unreadMessageCount := "99+"
|
||||||
|
if unreadCount < 100 {
|
||||||
|
unreadMessageCount = strconv.Itoa(unreadCount)
|
||||||
|
}
|
||||||
|
if or.Highlighted() {
|
||||||
|
unreadMessageCount += "!"
|
||||||
|
}
|
||||||
|
unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount)
|
||||||
|
widget.WriteLine(screen, mauview.AlignRight, unreadMessageCount, x+lineWidth-7, y, 7, style)
|
||||||
|
lineWidth -= len(unreadMessageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagRoomList struct {
|
||||||
|
mauview.NoopEventHandler
|
||||||
|
// The list of rooms in the list, in reverse order
|
||||||
|
rooms []*OrderedRoom
|
||||||
|
// Maximum number of rooms to show
|
||||||
|
maxShown int
|
||||||
|
// The internal name of this tag
|
||||||
|
name string
|
||||||
|
// The displayname of this tag
|
||||||
|
displayname string
|
||||||
|
// The parent RoomList instance
|
||||||
|
parent *RoomList
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTagRoomList(parent *RoomList, name string, rooms ...*OrderedRoom) *TagRoomList {
|
||||||
|
return &TagRoomList{
|
||||||
|
maxShown: 10,
|
||||||
|
rooms: rooms,
|
||||||
|
name: name,
|
||||||
|
displayname: parent.GetTagDisplayName(name),
|
||||||
|
parent: parent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) Visible() []*OrderedRoom {
|
||||||
|
return trl.rooms[len(trl.rooms)-trl.Length():]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) FirstVisible() *rooms.Room {
|
||||||
|
visible := trl.Visible()
|
||||||
|
if len(visible) > 0 {
|
||||||
|
return visible[len(visible)-1].Room
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) LastVisible() *rooms.Room {
|
||||||
|
visible := trl.Visible()
|
||||||
|
if len(visible) > 0 {
|
||||||
|
return visible[0].Room
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) All() []*OrderedRoom {
|
||||||
|
return trl.rooms
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) Length() int {
|
||||||
|
if len(trl.rooms) < trl.maxShown {
|
||||||
|
return len(trl.rooms)
|
||||||
|
}
|
||||||
|
return trl.maxShown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) TotalLength() int {
|
||||||
|
return len(trl.rooms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) IsEmpty() bool {
|
||||||
|
return len(trl.rooms) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) IsCollapsed() bool {
|
||||||
|
return trl.maxShown == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) ToggleCollapse() {
|
||||||
|
if trl.IsCollapsed() {
|
||||||
|
trl.maxShown = 10
|
||||||
|
} else {
|
||||||
|
trl.maxShown = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) HasInvisibleRooms() bool {
|
||||||
|
return trl.maxShown < trl.TotalLength()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) HasVisibleRooms() bool {
|
||||||
|
return !trl.IsEmpty() && trl.maxShown > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const equalityThreshold = 1e-6
|
||||||
|
|
||||||
|
func almostEqual(a, b float64) bool {
|
||||||
|
return math.Abs(a-b) <= equalityThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldBeAfter returns if the first room should be after the second room in the room list.
|
||||||
|
// The manual order and last received message timestamp are considered.
|
||||||
|
func (trl *TagRoomList) ShouldBeAfter(room1 *OrderedRoom, room2 *OrderedRoom) bool {
|
||||||
|
// Lower order value = higher in list
|
||||||
|
return room1.order > room2.order ||
|
||||||
|
// Equal order value and more recent message = higher in the list
|
||||||
|
(almostEqual(room1.order, room2.order) && room2.LastReceivedMessage.After(room1.LastReceivedMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) Insert(order json.Number, mxRoom *rooms.Room) {
|
||||||
|
room := NewOrderedRoom(order, mxRoom)
|
||||||
|
// The default insert index is the newly added slot.
|
||||||
|
// That index will be used if all other rooms in the list have the same LastReceivedMessage timestamp.
|
||||||
|
insertAt := len(trl.rooms)
|
||||||
|
// Find the spot where the new room should be put according to the last received message timestamps.
|
||||||
|
for i := 0; i < len(trl.rooms); i++ {
|
||||||
|
if trl.rooms[i].Room == mxRoom {
|
||||||
|
debug.Printf("Warning: tried to re-insert room %s into tag %s", mxRoom.ID, trl.name)
|
||||||
|
return
|
||||||
|
} else if trl.ShouldBeAfter(room, trl.rooms[i]) {
|
||||||
|
insertAt = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trl.rooms = append(trl.rooms, nil)
|
||||||
|
copy(trl.rooms[insertAt+1:], trl.rooms[insertAt:len(trl.rooms)-1])
|
||||||
|
trl.rooms[insertAt] = room
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) Bump(mxRoom *rooms.Room) {
|
||||||
|
var roomBeingBumped *OrderedRoom
|
||||||
|
for i := 0; i < len(trl.rooms); i++ {
|
||||||
|
currentIndexRoom := trl.rooms[i]
|
||||||
|
if roomBeingBumped != nil {
|
||||||
|
if trl.ShouldBeAfter(roomBeingBumped, currentIndexRoom) {
|
||||||
|
// This room should be after the room being bumped, so insert the
|
||||||
|
// room being bumped here and return
|
||||||
|
trl.rooms[i-1] = roomBeingBumped
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Move older rooms back in the array
|
||||||
|
trl.rooms[i-1] = currentIndexRoom
|
||||||
|
} else if currentIndexRoom.Room == mxRoom {
|
||||||
|
roomBeingBumped = currentIndexRoom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roomBeingBumped == nil {
|
||||||
|
debug.Print("Warning: couldn't find room", mxRoom.ID, mxRoom.NameCache, "to bump in tag", trl.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If the room being bumped should be first in the list, it won't be inserted during the loop.
|
||||||
|
trl.rooms[len(trl.rooms)-1] = roomBeingBumped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) Remove(room *rooms.Room) {
|
||||||
|
trl.RemoveIndex(trl.Index(room))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) RemoveIndex(index int) {
|
||||||
|
if index < 0 || index > len(trl.rooms) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last := len(trl.rooms) - 1
|
||||||
|
if index < last {
|
||||||
|
copy(trl.rooms[index:], trl.rooms[index+1:])
|
||||||
|
}
|
||||||
|
trl.rooms[last] = nil
|
||||||
|
trl.rooms = trl.rooms[:last]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) Index(room *rooms.Room) int {
|
||||||
|
return trl.indexInList(trl.All(), room)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) IndexVisible(room *rooms.Room) int {
|
||||||
|
return trl.indexInList(trl.Visible(), room)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) indexInList(list []*OrderedRoom, room *rooms.Room) int {
|
||||||
|
for index, entry := range list {
|
||||||
|
if entry.Room == room {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
var TagDisplayNameStyle = tcell.StyleDefault.Underline(true).Bold(true)
|
||||||
|
var TagRoomCountStyle = tcell.StyleDefault.Italic(true)
|
||||||
|
|
||||||
|
func (trl *TagRoomList) RenderHeight() int {
|
||||||
|
if len(trl.displayname) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if trl.IsCollapsed() {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
height := 2 + trl.Length()
|
||||||
|
if trl.HasInvisibleRooms() || trl.maxShown > 10 {
|
||||||
|
height++
|
||||||
|
}
|
||||||
|
return height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) DrawHeader(screen mauview.Screen) {
|
||||||
|
width, _ := screen.Size()
|
||||||
|
roomCount := strconv.Itoa(trl.TotalLength())
|
||||||
|
|
||||||
|
// Draw tag name
|
||||||
|
displayNameWidth := width - 1 - len(roomCount)
|
||||||
|
widget.WriteLine(screen, mauview.AlignLeft, trl.displayname, 0, 0, displayNameWidth, TagDisplayNameStyle)
|
||||||
|
|
||||||
|
// Draw tag room count
|
||||||
|
roomCountX := len(trl.displayname) + 1
|
||||||
|
roomCountWidth := width - 2 - len(trl.displayname)
|
||||||
|
widget.WriteLine(screen, mauview.AlignLeft, roomCount, roomCountX, 0, roomCountWidth, TagRoomCountStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (trl *TagRoomList) Draw(screen mauview.Screen) {
|
||||||
|
if len(trl.displayname) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trl.DrawHeader(screen)
|
||||||
|
|
||||||
|
width, height := screen.Size()
|
||||||
|
|
||||||
|
items := trl.Visible()
|
||||||
|
|
||||||
|
if trl.IsCollapsed() {
|
||||||
|
screen.SetCell(width-1, 0, tcell.StyleDefault, '▶')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
screen.SetCell(width-1, 0, tcell.StyleDefault, '▼')
|
||||||
|
|
||||||
|
y := 1
|
||||||
|
for i := len(items) - 1; i >= 0; i-- {
|
||||||
|
if y >= height {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item := items[i]
|
||||||
|
|
||||||
|
lineWidth := width
|
||||||
|
isSelected := trl.name == trl.parent.selectedTag && item.Room == trl.parent.selected
|
||||||
|
item.Draw(trl.parent, screen, 0, y, lineWidth, isSelected)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
hasLess := trl.maxShown > 10
|
||||||
|
hasMore := trl.HasInvisibleRooms()
|
||||||
|
if (hasLess || hasMore) && y < height {
|
||||||
|
if hasMore {
|
||||||
|
widget.WriteLine(screen, mauview.AlignRight, "More ↓", 0, y, width, tcell.StyleDefault)
|
||||||
|
}
|
||||||
|
if hasLess {
|
||||||
|
widget.WriteLine(screen, mauview.AlignLeft, "↑ Less", 0, y, width, tcell.StyleDefault)
|
||||||
|
}
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
}
|
69
tui/tui.go
Normal file
69
tui/tui.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
|
||||||
|
"go.mau.fi/gomuks/pkg/gomuks"
|
||||||
|
)
|
||||||
|
|
||||||
|
type View string
|
||||||
|
|
||||||
|
// Allowed views in GomuksTUI
|
||||||
|
type GomuksTUI struct {
|
||||||
|
*gomuks.Gomuks
|
||||||
|
App *mauview.Application
|
||||||
|
mainView *MainView
|
||||||
|
loginView *LoginView
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(gmx *gomuks.Gomuks) *GomuksTUI {
|
||||||
|
return &GomuksTUI{
|
||||||
|
Gomuks: gmx,
|
||||||
|
App: mauview.NewApplication(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
mauview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault
|
||||||
|
mauview.Styles.PrimaryTextColor = tcell.ColorDefault
|
||||||
|
mauview.Styles.BorderColor = tcell.ColorDefault
|
||||||
|
mauview.Styles.ContrastBackgroundColor = tcell.ColorDarkGreen
|
||||||
|
if tcellDB := os.Getenv("TCELLDB"); len(tcellDB) == 0 {
|
||||||
|
if info, err := os.Stat("/usr/share/tcell/database"); err == nil && info.IsDir() {
|
||||||
|
os.Setenv("TCELLDB", "/usr/share/tcell/database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gt *GomuksTUI) Run() {
|
||||||
|
gt.App = mauview.NewApplication()
|
||||||
|
if !gt.Client.IsLoggedIn() {
|
||||||
|
gt.App.SetRoot(gt.NewLoginView())
|
||||||
|
} else {
|
||||||
|
gt.App.SetRoot(gt.NewMainView())
|
||||||
|
}
|
||||||
|
err := gt.App.Start()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
252
tui/verification-modal.go
Normal file
252
tui/verification-modal.go
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build cgo
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/crypto"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmojiView struct {
|
||||||
|
mauview.SimpleEventHandler
|
||||||
|
Data crypto.SASData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmojiView) Draw(screen mauview.Screen) {
|
||||||
|
if e.Data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch e.Data.Type() {
|
||||||
|
case event.SASEmoji:
|
||||||
|
width := 10
|
||||||
|
for i, emoji := range e.Data.(crypto.EmojiSASData) {
|
||||||
|
x := i*width + i
|
||||||
|
y := 0
|
||||||
|
if i >= 4 {
|
||||||
|
x = (i-4)*width + i
|
||||||
|
y = 2
|
||||||
|
}
|
||||||
|
mauview.Print(screen, string(emoji.Emoji), x, y, width, mauview.AlignCenter, tcell.ColorDefault)
|
||||||
|
mauview.Print(screen, emoji.Description, x, y+1, width, mauview.AlignCenter, tcell.ColorDefault)
|
||||||
|
}
|
||||||
|
case event.SASDecimal:
|
||||||
|
maxWidth := 43
|
||||||
|
for i, number := range e.Data.(crypto.DecimalSASData) {
|
||||||
|
mauview.Print(screen, strconv.FormatUint(uint64(number), 10), 0, i, maxWidth, mauview.AlignCenter, tcell.ColorDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerificationModal struct {
|
||||||
|
mauview.Component
|
||||||
|
|
||||||
|
device *crypto.DeviceIdentity
|
||||||
|
|
||||||
|
container *mauview.Box
|
||||||
|
|
||||||
|
waitingBar *mauview.ProgressBar
|
||||||
|
infoText *mauview.TextView
|
||||||
|
emojiText *EmojiView
|
||||||
|
inputBar *mauview.InputField
|
||||||
|
|
||||||
|
progress int
|
||||||
|
progressMax int
|
||||||
|
stopWaiting chan struct{}
|
||||||
|
confirmChan chan bool
|
||||||
|
done bool
|
||||||
|
|
||||||
|
parent *MainView
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVerificationModal(mainView *MainView, device *crypto.DeviceIdentity, timeout time.Duration) *VerificationModal {
|
||||||
|
vm := &VerificationModal{
|
||||||
|
parent: mainView,
|
||||||
|
device: device,
|
||||||
|
stopWaiting: make(chan struct{}),
|
||||||
|
confirmChan: make(chan bool),
|
||||||
|
done: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.progressMax = int(timeout.Seconds())
|
||||||
|
vm.progress = vm.progressMax
|
||||||
|
vm.waitingBar = mauview.NewProgressBar().
|
||||||
|
SetMax(vm.progressMax).
|
||||||
|
SetProgress(vm.progress).
|
||||||
|
SetIndeterminate(false)
|
||||||
|
|
||||||
|
vm.infoText = mauview.NewTextView()
|
||||||
|
vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto accept", device.UserID))
|
||||||
|
|
||||||
|
vm.emojiText = &EmojiView{}
|
||||||
|
|
||||||
|
vm.inputBar = mauview.NewInputField().
|
||||||
|
SetBackgroundColor(tcell.ColorDefault).
|
||||||
|
SetPlaceholderTextColor(tcell.ColorDefault)
|
||||||
|
|
||||||
|
flex := mauview.NewFlex().
|
||||||
|
SetDirection(mauview.FlexRow).
|
||||||
|
AddFixedComponent(vm.waitingBar, 1).
|
||||||
|
AddFixedComponent(vm.infoText, 4).
|
||||||
|
AddFixedComponent(vm.emojiText, 4).
|
||||||
|
AddFixedComponent(vm.inputBar, 1)
|
||||||
|
|
||||||
|
vm.container = mauview.NewBox(flex).
|
||||||
|
SetBorder(true).
|
||||||
|
SetTitle("Interactive verification")
|
||||||
|
|
||||||
|
vm.Component = mauview.Center(vm.container, 45, 12).SetAlwaysFocusChild(true)
|
||||||
|
|
||||||
|
go vm.decrementWaitingBar()
|
||||||
|
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *VerificationModal) decrementWaitingBar() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-time.Tick(time.Second):
|
||||||
|
if vm.progress <= 0 {
|
||||||
|
vm.waitingBar.SetIndeterminate(true)
|
||||||
|
vm.parent.parent.App.SetRedrawTicker(100 * time.Millisecond)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vm.progress--
|
||||||
|
vm.waitingBar.SetProgress(vm.progress)
|
||||||
|
vm.parent.parent.App.Redraw()
|
||||||
|
case <-vm.stopWaiting:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *VerificationModal) VerificationMethods() []crypto.VerificationMethod {
|
||||||
|
return []crypto.VerificationMethod{crypto.VerificationMethodEmoji{}, crypto.VerificationMethodDecimal{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *VerificationModal) VerifySASMatch(device *crypto.DeviceIdentity, data crypto.SASData) bool {
|
||||||
|
vm.device = device
|
||||||
|
var typeName string
|
||||||
|
if data.Type() == event.SASDecimal {
|
||||||
|
typeName = "numbers"
|
||||||
|
} else if data.Type() == event.SASEmoji {
|
||||||
|
typeName = "emojis"
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
vm.infoText.SetText(fmt.Sprintf(
|
||||||
|
"Check if the other device is showing the\n"+
|
||||||
|
"same %s as below, then type \"yes\" to\n"+
|
||||||
|
"accept, or \"no\" to reject", typeName))
|
||||||
|
vm.inputBar.
|
||||||
|
SetTextColor(tcell.ColorDefault).
|
||||||
|
SetBackgroundColor(tcell.ColorDarkCyan).
|
||||||
|
SetPlaceholder("Type \"yes\" or \"no\"").
|
||||||
|
Focus()
|
||||||
|
vm.emojiText.Data = data
|
||||||
|
vm.parent.parent.App.Redraw()
|
||||||
|
vm.progress = vm.progressMax
|
||||||
|
confirm := <-vm.confirmChan
|
||||||
|
vm.progress = vm.progressMax
|
||||||
|
vm.emojiText.Data = nil
|
||||||
|
vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto confirm", vm.device.UserID))
|
||||||
|
vm.parent.parent.App.Redraw()
|
||||||
|
return confirm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *VerificationModal) OnCancel(cancelledByUs bool, reason string, _ event.VerificationCancelCode) {
|
||||||
|
vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100)
|
||||||
|
vm.parent.parent.app.SetRedrawTicker(1 * time.Minute)
|
||||||
|
if cancelledByUs {
|
||||||
|
vm.infoText.SetText(fmt.Sprintf("Verification failed: %s", reason))
|
||||||
|
} else {
|
||||||
|
vm.infoText.SetText(fmt.Sprintf("Verification cancelled by %s: %s", vm.device.UserID, reason))
|
||||||
|
}
|
||||||
|
vm.inputBar.SetPlaceholder("Press enter to close the dialog")
|
||||||
|
vm.stopWaiting <- struct{}{}
|
||||||
|
vm.done = true
|
||||||
|
vm.parent.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *VerificationModal) OnSuccess() {
|
||||||
|
vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100)
|
||||||
|
vm.parent.parent.App.SetRedrawTicker(1 * time.Minute)
|
||||||
|
vm.infoText.SetText(fmt.Sprintf("Successfully verified %s (%s) of %s", vm.device.Name, vm.device.DeviceID, vm.device.UserID))
|
||||||
|
vm.inputBar.SetPlaceholder("Press enter to close the dialog")
|
||||||
|
vm.stopWaiting <- struct{}{}
|
||||||
|
vm.done = true
|
||||||
|
vm.parent.parent.App.Redraw()
|
||||||
|
if vm.parent.config.SendToVerifiedOnly {
|
||||||
|
// Hacky way to make new group sessions after verified
|
||||||
|
vm.parent.matrix.Crypto().(*crypto.OlmMachine).OnDevicesChanged(vm.device.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *VerificationModal) OnKeyEvent(event mauview.KeyEvent) bool {
|
||||||
|
kb := config.Keybind{
|
||||||
|
Key: event.Key(),
|
||||||
|
Ch: event.Rune(),
|
||||||
|
Mod: event.Modifiers(),
|
||||||
|
}
|
||||||
|
if vm.done {
|
||||||
|
if vm.parent.config.Keybindings.Modal[kb] == "cancel" || vm.parent.config.Keybindings.Modal[kb] == "confirm" {
|
||||||
|
vm.parent.HideModal()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} else if vm.emojiText.Data == nil {
|
||||||
|
debug.Print("Ignoring pre-emoji key event")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if vm.parent.config.Keybindings.Modal[kb] == "confirm" {
|
||||||
|
text := strings.ToLower(strings.TrimSpace(vm.inputBar.GetText()))
|
||||||
|
if text == "yes" {
|
||||||
|
debug.Print("Confirming verification")
|
||||||
|
vm.confirmChan <- true
|
||||||
|
} else if text == "no" {
|
||||||
|
debug.Print("Rejecting verification")
|
||||||
|
vm.confirmChan <- false
|
||||||
|
}
|
||||||
|
vm.inputBar.
|
||||||
|
SetPlaceholder("").
|
||||||
|
SetTextAndMoveCursor("").
|
||||||
|
SetBackgroundColor(tcell.ColorDefault).
|
||||||
|
SetTextColor(tcell.ColorDefault)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return vm.inputBar.OnKeyEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *VerificationModal) Focus() {
|
||||||
|
vm.container.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *VerificationModal) Blur() {
|
||||||
|
vm.container.Blur()
|
||||||
|
}
|
145
tui/view-login.go
Normal file
145
tui/view-login.go
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
// gomuks - A Matrix client written in Go.
|
||||||
|
// Copyright (C) 2025 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"context"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginView struct {
|
||||||
|
*mauview.Form
|
||||||
|
|
||||||
|
container *mauview.Centerer
|
||||||
|
|
||||||
|
homeserverLabel *mauview.TextField
|
||||||
|
idLabel *mauview.TextField
|
||||||
|
passwordLabel *mauview.TextField
|
||||||
|
|
||||||
|
homeserver *mauview.InputField
|
||||||
|
id *mauview.InputField
|
||||||
|
password *mauview.InputField
|
||||||
|
error *mauview.TextView
|
||||||
|
|
||||||
|
loginButton *mauview.Button
|
||||||
|
quitButton *mauview.Button
|
||||||
|
|
||||||
|
loading bool
|
||||||
|
|
||||||
|
parent *GomuksTUI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gt *GomuksTUI) NewLoginView() mauview.Component {
|
||||||
|
view := &LoginView{
|
||||||
|
Form: mauview.NewForm(),
|
||||||
|
|
||||||
|
idLabel: mauview.NewTextField().SetText("User ID"),
|
||||||
|
passwordLabel: mauview.NewTextField().SetText("Password"),
|
||||||
|
homeserverLabel: mauview.NewTextField().SetText("Homeserver"),
|
||||||
|
|
||||||
|
id: mauview.NewInputField(),
|
||||||
|
password: mauview.NewInputField(),
|
||||||
|
homeserver: mauview.NewInputField(),
|
||||||
|
|
||||||
|
loginButton: mauview.NewButton("Login"),
|
||||||
|
quitButton: mauview.NewButton("Quit"),
|
||||||
|
|
||||||
|
parent: gt,
|
||||||
|
}
|
||||||
|
|
||||||
|
view.homeserver.SetPlaceholder("https://example.com").SetText("").SetTextColor(tcell.ColorWhite)
|
||||||
|
view.id.SetPlaceholder("@user:example.com").SetText("").SetTextColor(tcell.ColorWhite)
|
||||||
|
view.password.SetPlaceholder("correct horse battery staple").SetMaskCharacter('*').SetTextColor(tcell.ColorWhite)
|
||||||
|
|
||||||
|
view.quitButton.
|
||||||
|
SetOnClick(gt.App.ForceStop).
|
||||||
|
SetBackgroundColor(tcell.ColorDarkCyan).
|
||||||
|
SetForegroundColor(tcell.ColorWhite).
|
||||||
|
SetFocusedForegroundColor(tcell.ColorWhite)
|
||||||
|
view.loginButton.
|
||||||
|
SetOnClick(view.Login).
|
||||||
|
SetBackgroundColor(tcell.ColorDarkCyan).
|
||||||
|
SetForegroundColor(tcell.ColorWhite).
|
||||||
|
SetFocusedForegroundColor(tcell.ColorWhite)
|
||||||
|
|
||||||
|
view.
|
||||||
|
SetColumns([]int{1, 10, 1, 30, 1}).
|
||||||
|
SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1})
|
||||||
|
view.
|
||||||
|
AddFormItem(view.id, 3, 1, 1, 1).
|
||||||
|
AddFormItem(view.password, 3, 3, 1, 1).
|
||||||
|
AddFormItem(view.homeserver, 3, 5, 1, 1).
|
||||||
|
AddFormItem(view.loginButton, 1, 7, 3, 1).
|
||||||
|
AddFormItem(view.quitButton, 1, 9, 3, 1).
|
||||||
|
AddComponent(view.idLabel, 1, 1, 1, 1).
|
||||||
|
AddComponent(view.passwordLabel, 1, 3, 1, 1).
|
||||||
|
AddComponent(view.homeserverLabel, 1, 5, 1, 1)
|
||||||
|
view.FocusNextItem()
|
||||||
|
gt.loginView = view
|
||||||
|
|
||||||
|
view.container = mauview.Center(mauview.NewBox(view).SetTitle("Log in to Matrix"), 45, 13)
|
||||||
|
view.container.SetAlwaysFocusChild(true)
|
||||||
|
return view.container
|
||||||
|
}
|
||||||
|
func (view *LoginView) Error(err string) {
|
||||||
|
if len(err) == 0 && view.error != nil {
|
||||||
|
view.RemoveComponent(view.error)
|
||||||
|
view.container.SetHeight(13)
|
||||||
|
view.SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1})
|
||||||
|
view.error = nil
|
||||||
|
} else if len(err) > 0 {
|
||||||
|
if view.error == nil {
|
||||||
|
view.error = mauview.NewTextView().SetTextColor(tcell.ColorRed)
|
||||||
|
view.AddComponent(view.error, 1, 11, 3, 1)
|
||||||
|
}
|
||||||
|
view.error.SetText(err)
|
||||||
|
errorHeight := int(math.Ceil(float64(runewidth.StringWidth(err)) / 41))
|
||||||
|
view.container.SetHeight(14 + errorHeight)
|
||||||
|
view.SetRow(11, errorHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
func (view *LoginView) actuallyLogin(ctx context.Context, hs, mxid, password string) {
|
||||||
|
|
||||||
|
view.loading = true
|
||||||
|
view.loginButton.SetText("Logging in...")
|
||||||
|
err := view.parent.Client.LoginPassword(ctx, hs, mxid, password)
|
||||||
|
if err == nil {
|
||||||
|
view.loginButton.SetText("it woked")
|
||||||
|
} else {
|
||||||
|
view.Error(err.Error())
|
||||||
|
}
|
||||||
|
view.loading = false
|
||||||
|
view.loginButton.SetText("Login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *LoginView) Login() {
|
||||||
|
if view.loading {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hs := view.homeserver.GetText()
|
||||||
|
mxid := view.id.GetText()
|
||||||
|
password := view.password.GetText()
|
||||||
|
ctx := context.TODO()
|
||||||
|
go view.actuallyLogin(ctx, hs, mxid, password)
|
||||||
|
}
|
480
tui/view-main.go
Normal file
480
tui/view-main.go
Normal file
|
@ -0,0 +1,480 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
/*
|
||||||
|
sync "github.com/sasha-s/go-deadlock"
|
||||||
|
*/
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
"maunium.net/go/mautrix/pushrules"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
|
||||||
|
//find what to use
|
||||||
|
"maunium.net/go/gomuks/gt/messages"
|
||||||
|
"maunium.net/go/gomuks/gt/widget"
|
||||||
|
"maunium.net/go/gomuks/lib/notification"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MainView struct {
|
||||||
|
flex *mauview.Flex
|
||||||
|
|
||||||
|
roomList *RoomList
|
||||||
|
roomView *mauview.Box
|
||||||
|
currentRoom *RoomView
|
||||||
|
rooms map[id.RoomID]*RoomView
|
||||||
|
roomsLock sync.RWMutex
|
||||||
|
cmdProcessor *CommandProcessor
|
||||||
|
focused mauview.Focusable
|
||||||
|
|
||||||
|
modal mauview.Component
|
||||||
|
|
||||||
|
lastFocusTime time.Time
|
||||||
|
|
||||||
|
parent *GomuksTUI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gt *GomuksTUI) NewMainView() mauview.Component {
|
||||||
|
mainView := &MainView{
|
||||||
|
flex: mauview.NewFlex().SetDirection(mauview.FlexColumn),
|
||||||
|
roomView: mauview.NewBox(nil).SetBorder(false),
|
||||||
|
rooms: make(map[id.RoomID]*RoomView),
|
||||||
|
/*crying face*
|
||||||
|
matrix: gt.Gomuks.Matrix(),
|
||||||
|
gmx: gt.Gomuks,
|
||||||
|
config: gt.Gomuks.Config(),
|
||||||
|
*/
|
||||||
|
parent: gt,
|
||||||
|
}
|
||||||
|
mainView.roomList = NewRoomList(mainView)
|
||||||
|
mainView.cmdProcessor = NewCommandProcessor(mainView)
|
||||||
|
|
||||||
|
mainView.flex.
|
||||||
|
AddFixedComponent(mainView.roomList, 25).
|
||||||
|
AddFixedComponent(widget.NewBorder(), 1).
|
||||||
|
AddProportionalComponent(mainView.roomView, 1)
|
||||||
|
mainView.BumpFocus(nil)
|
||||||
|
|
||||||
|
gt.mainView = mainView
|
||||||
|
|
||||||
|
return mainView
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) ShowModal(modal mauview.Component) {
|
||||||
|
view.modal = modal
|
||||||
|
var ok bool
|
||||||
|
view.focused, ok = modal.(mauview.Focusable)
|
||||||
|
if !ok {
|
||||||
|
view.focused = nil
|
||||||
|
} else {
|
||||||
|
view.focused.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) HideModal() {
|
||||||
|
view.modal = nil
|
||||||
|
view.focused = view.roomView
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) Draw(screen mauview.Screen) {
|
||||||
|
//config does not exist
|
||||||
|
if view.config.Preferences.HideRoomList {
|
||||||
|
view.roomView.Draw(screen)
|
||||||
|
} else {
|
||||||
|
view.flex.Draw(screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
if view.modal != nil {
|
||||||
|
view.modal.Draw(screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) BumpFocus(roomView *RoomView) {
|
||||||
|
if roomView != nil {
|
||||||
|
view.lastFocusTime = time.Now()
|
||||||
|
view.MarkRead(roomView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) MarkRead(roomView *RoomView) {
|
||||||
|
if roomView != nil && roomView.Room.HasNewMessages() && roomView.MessageView().ScrollOffset == 0 {
|
||||||
|
msgList := roomView.MessageView().messages
|
||||||
|
if len(msgList) > 0 {
|
||||||
|
msg := msgList[len(msgList)-1]
|
||||||
|
if roomView.Room.MarkRead(msg.ID()) {
|
||||||
|
//? receipt type
|
||||||
|
view.parent.Client.MarkRead(context.TODO(), roomView.Room.ID, msg.ID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) InputChanged(roomView *RoomView, text string) {
|
||||||
|
if !roomView.config.Preferences.DisableTypingNotifs {
|
||||||
|
//fix args in this too
|
||||||
|
view.parent.Client.SetTyping(context.TODO(), roomView.Room.ID, len(text) > 0 && text[0] != '/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) ShowBare(roomView *RoomView) {
|
||||||
|
if roomView == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, height := view.parent.App.Screen().Size()
|
||||||
|
view.parent.App.Suspend(func() {
|
||||||
|
print("\033[2J\033[0;0H")
|
||||||
|
// We don't know how much space there exactly is. Too few messages looks weird,
|
||||||
|
// and too many messages shouldn't cause any problems, so we just show too many.
|
||||||
|
height *= 2
|
||||||
|
fmt.Println(roomView.MessageView().CapturePlaintext(height))
|
||||||
|
fmt.Println("Press enter to return to normal mode.")
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
_, _, _ = reader.ReadRune()
|
||||||
|
print("\033[2J\033[0;0H")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//???? idk what it does
|
||||||
|
func (view *MainView) OpenSyncingModal() ifc.SyncingModal {
|
||||||
|
component, modal := NewSyncingModal(view)
|
||||||
|
view.ShowModal(component)
|
||||||
|
return modal
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
//umm this is some keyboard magic which idk how to implement
|
||||||
|
|
||||||
|
func (view *MainView) OnKeyEvent(event mauview.KeyEvent) bool {
|
||||||
|
view.BumpFocus(view.currentRoom)
|
||||||
|
if view.modal != nil {
|
||||||
|
return view.modal.OnKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
kb := config.Keybind{
|
||||||
|
Key: event.Key(),
|
||||||
|
Ch: event.Rune(),
|
||||||
|
Mod: event.Modifiers(),
|
||||||
|
}
|
||||||
|
switch view.config.Keybindings.Main[kb] {
|
||||||
|
case "next_room":
|
||||||
|
view.SwitchRoom(view.roomList.Next())
|
||||||
|
case "prev_room":
|
||||||
|
view.SwitchRoom(view.roomList.Previous())
|
||||||
|
case "search_rooms":
|
||||||
|
view.ShowModal(NewFuzzySearchModal(view, 42, 12))
|
||||||
|
case "scroll_up":
|
||||||
|
msgView := view.currentRoom.MessageView()
|
||||||
|
msgView.AddScrollOffset(msgView.TotalHeight())
|
||||||
|
case "scroll_down":
|
||||||
|
msgView := view.currentRoom.MessageView()
|
||||||
|
msgView.AddScrollOffset(-msgView.TotalHeight())
|
||||||
|
case "add_newline":
|
||||||
|
return view.flex.OnKeyEvent(tcell.NewEventKey(tcell.KeyEnter, '\n', event.Modifiers()|tcell.ModShift))
|
||||||
|
case "next_active_room":
|
||||||
|
view.SwitchRoom(view.roomList.NextWithActivity())
|
||||||
|
case "show_bare":
|
||||||
|
view.ShowBare(view.currentRoom)
|
||||||
|
default:
|
||||||
|
goto defaultHandler
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
defaultHandler:
|
||||||
|
|
||||||
|
if view.config.Preferences.HideRoomList {
|
||||||
|
return view.roomView.OnKeyEvent(event)
|
||||||
|
}
|
||||||
|
return view.flex.OnKeyEvent(event)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
const WheelScrollOffsetDiff = 3
|
||||||
|
|
||||||
|
/*
|
||||||
|
//more :( key things
|
||||||
|
|
||||||
|
func (view *MainView) OnMouseEvent(event mauview.MouseEvent) bool {
|
||||||
|
if view.modal != nil {
|
||||||
|
return view.modal.OnMouseEvent(event)
|
||||||
|
}
|
||||||
|
if view.config.Preferences.HideRoomList {
|
||||||
|
return view.roomView.OnMouseEvent(event)
|
||||||
|
}
|
||||||
|
return view.flex.OnMouseEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) OnPasteEvent(event mauview.PasteEvent) bool {
|
||||||
|
if view.modal != nil {
|
||||||
|
return view.modal.OnPasteEvent(event)
|
||||||
|
} else if view.config.Preferences.HideRoomList {
|
||||||
|
return view.roomView.OnPasteEvent(event)
|
||||||
|
}
|
||||||
|
return view.flex.OnPasteEvent(event)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
func (view *MainView) Focus() {
|
||||||
|
if view.focused != nil {
|
||||||
|
view.focused.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) Blur() {
|
||||||
|
if view.focused != nil {
|
||||||
|
view.focused.Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) SwitchRoom(tag string, room *rooms.Room) {
|
||||||
|
view.switchRoom(tag, room, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) {
|
||||||
|
if room == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
room.Load()
|
||||||
|
|
||||||
|
roomView, ok := view.getRoomView(room.ID, lock)
|
||||||
|
if !ok {
|
||||||
|
debug.Print("Tried to switch to room with nonexistent roomView!")
|
||||||
|
debug.Print(tag, room)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roomView.Update()
|
||||||
|
view.roomView.SetInnerComponent(roomView)
|
||||||
|
view.currentRoom = roomView
|
||||||
|
view.MarkRead(roomView)
|
||||||
|
view.roomList.SetSelected(tag, room)
|
||||||
|
view.flex.SetFocused(view.roomView)
|
||||||
|
view.focused = view.roomView
|
||||||
|
view.roomView.Focus()
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
|
||||||
|
if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded {
|
||||||
|
msgView.initialHistoryLoaded = true
|
||||||
|
go view.LoadHistory(room.ID)
|
||||||
|
}
|
||||||
|
if !room.MembersFetched {
|
||||||
|
go func() {
|
||||||
|
//another args thing
|
||||||
|
err := view.parent.Client.GetRoomState(context.TODO())
|
||||||
|
if err != nil {
|
||||||
|
debug.Print("Error fetching members:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roomView.UpdateUserList()
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) addRoomPage(room *rooms.Room) *RoomView {
|
||||||
|
if _, ok := view.rooms[room.ID]; !ok {
|
||||||
|
roomView := NewRoomView(view, room).
|
||||||
|
SetInputChangedFunc(view.InputChanged)
|
||||||
|
view.rooms[room.ID] = roomView
|
||||||
|
return roomView
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ifc + what do i get?
|
||||||
|
func (view *MainView) GetRoom(roomID id.RoomID) ifc.RoomView {
|
||||||
|
room, ok := view.getRoomView(roomID, true)
|
||||||
|
if !ok {
|
||||||
|
return view.addRoom(view.matrix.GetOrCreateRoom(roomID))
|
||||||
|
}
|
||||||
|
return room
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) getRoomView(roomID id.RoomID, lock bool) (room *RoomView, ok bool) {
|
||||||
|
if lock {
|
||||||
|
view.roomsLock.RLock()
|
||||||
|
room, ok = view.rooms[roomID]
|
||||||
|
view.roomsLock.RUnlock()
|
||||||
|
} else {
|
||||||
|
room, ok = view.rooms[roomID]
|
||||||
|
}
|
||||||
|
return room, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) AddRoom(room *rooms.Room) {
|
||||||
|
view.addRoom(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) RemoveRoom(room *rooms.Room) {
|
||||||
|
view.roomsLock.Lock()
|
||||||
|
_, ok := view.getRoomView(room.ID, false)
|
||||||
|
if !ok {
|
||||||
|
view.roomsLock.Unlock()
|
||||||
|
debug.Print("Remove aborted (not found)", room.ID, room.GetTitle())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debug.Print("Removing", room.ID, room.GetTitle())
|
||||||
|
|
||||||
|
view.roomList.Remove(room)
|
||||||
|
t, r := view.roomList.Selected()
|
||||||
|
view.switchRoom(t, r, false)
|
||||||
|
delete(view.rooms, room.ID)
|
||||||
|
view.roomsLock.Unlock()
|
||||||
|
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) addRoom(room *rooms.Room) *RoomView {
|
||||||
|
if view.roomList.Contains(room.ID) {
|
||||||
|
debug.Print("Add aborted (room exists)", room.ID, room.GetTitle())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
debug.Print("Adding", room.ID, room.GetTitle())
|
||||||
|
view.roomList.Add(room)
|
||||||
|
view.roomsLock.Lock()
|
||||||
|
roomView := view.addRoomPage(room)
|
||||||
|
if !view.roomList.HasSelected() {
|
||||||
|
t, r := view.roomList.First()
|
||||||
|
view.switchRoom(t, r, false)
|
||||||
|
}
|
||||||
|
view.roomsLock.Unlock()
|
||||||
|
return roomView
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) SetRooms(rooms *rooms.RoomCache) {
|
||||||
|
view.roomList.Clear()
|
||||||
|
view.roomsLock.Lock()
|
||||||
|
view.rooms = make(map[id.RoomID]*RoomView)
|
||||||
|
for _, room := range rooms.Map {
|
||||||
|
if room.HasLeft {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
view.roomList.Add(room)
|
||||||
|
view.addRoomPage(room)
|
||||||
|
}
|
||||||
|
t, r := view.roomList.First()
|
||||||
|
view.switchRoom(t, r, false)
|
||||||
|
view.roomsLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) UpdateTags(room *rooms.Room) {
|
||||||
|
if !view.roomList.Contains(room.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reselect := view.roomList.selected == room
|
||||||
|
view.roomList.Remove(room)
|
||||||
|
view.roomList.Add(room)
|
||||||
|
if reselect {
|
||||||
|
view.roomList.SetSelected(room.Tags()[0].Tag, room)
|
||||||
|
}
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) SetTyping(roomID id.RoomID, users []id.UserID) {
|
||||||
|
roomView, ok := view.getRoomView(roomID, true)
|
||||||
|
if ok {
|
||||||
|
roomView.SetTyping(users)
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendNotification(room *rooms.Room, sender, text string, critical, sound bool) {
|
||||||
|
if room.GetTitle() != sender {
|
||||||
|
sender = fmt.Sprintf("%s (%s)", sender, room.GetTitle())
|
||||||
|
}
|
||||||
|
debug.Printf("Sending notification with body \"%s\" from %s in room ID %s (critical=%v, sound=%v)", text, sender, room.ID, critical, sound)
|
||||||
|
notification.Send(sender, text, critical, sound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) Bump(room *rooms.Room) {
|
||||||
|
view.roomList.Bump(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// another arg mess. Did not find what is userID
|
||||||
|
func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) {
|
||||||
|
view.Bump(room)
|
||||||
|
gtMsg, ok := message.(*messages.UIMessage)
|
||||||
|
if ok && gtMsg.SenderID == view.UserID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Whether or not the room where the message came is the currently shown room.
|
||||||
|
isCurrent := room == view.roomList.SelectedRoom()
|
||||||
|
// Whether or not the terminal window is focused.
|
||||||
|
recentlyFocused := time.Now().Add(-30 * time.Second).Before(view.lastFocusTime)
|
||||||
|
isFocused := time.Now().Add(-5 * time.Second).Before(view.lastFocusTime)
|
||||||
|
|
||||||
|
if !isCurrent || !isFocused {
|
||||||
|
// The message is not in the current room, show new message status in room list.
|
||||||
|
room.AddUnread(message.ID(), should.Notify, should.Highlight)
|
||||||
|
} else {
|
||||||
|
//args & also why not use the func in here
|
||||||
|
view.parent.Client.MarkRead(room.ID, message.ID())
|
||||||
|
}
|
||||||
|
//config
|
||||||
|
if should.Notify && !recentlyFocused && !view.config.Preferences.DisableNotifications {
|
||||||
|
// Push rules say notify and the terminal is not focused, send desktop notification.
|
||||||
|
shouldPlaySound := should.PlaySound &&
|
||||||
|
should.SoundName == "default" &&
|
||||||
|
view.config.NotifySound
|
||||||
|
sendNotification(room, message.NotificationSenderName(), message.NotificationContent(), should.Highlight, shouldPlaySound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO this should probably happen somewhere else
|
||||||
|
// (actually it's probably completely broken now)
|
||||||
|
message.SetIsHighlight(should.Highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MainView) LoadHistory(roomID id.RoomID) {
|
||||||
|
defer debug.Recover()
|
||||||
|
roomView, ok := view.getRoomView(roomID, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgView := roomView.MessageView()
|
||||||
|
|
||||||
|
if !atomic.CompareAndSwapInt32(&msgView.loadingMessages, 0, 1) {
|
||||||
|
// Locked
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer atomic.StoreInt32(&msgView.loadingMessages, 0)
|
||||||
|
// Update the "Loading more messages..." text
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
//history?????
|
||||||
|
history, newLoadPtr, err := view.matrix.GetHistory(roomView.Room, 50, msgView.historyLoadPtr)
|
||||||
|
if err != nil {
|
||||||
|
roomView.AddServiceMessage("Failed to fetch history")
|
||||||
|
debug.Print("Failed to fetch history for", roomView.Room.ID, err)
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
//debug.Printf("Load pointer %d -> %d", msgView.historyLoadPtr, newLoadPtr)
|
||||||
|
msgView.historyLoadPtr = newLoadPtr
|
||||||
|
for _, evt := range history {
|
||||||
|
roomView.AddHistoryEvent(evt)
|
||||||
|
}
|
||||||
|
view.parent.App.Redraw()
|
||||||
|
}
|
63
tui/widget/border.go
Normal file
63
tui/widget/border.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Border is a simple tview widget that renders a horizontal or vertical bar.
|
||||||
|
//
|
||||||
|
// If the width of the box is 1, the bar will be vertical.
|
||||||
|
// If the height is 1, the bar will be horizontal.
|
||||||
|
// If the width nor the height are 1, nothing will be rendered.
|
||||||
|
type Border struct {
|
||||||
|
Style tcell.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBorder wraps a new tview Box into a new Border.
|
||||||
|
func NewBorder() *Border {
|
||||||
|
return &Border{
|
||||||
|
Style: tcell.StyleDefault.Foreground(mauview.Styles.BorderColor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (border *Border) Draw(screen mauview.Screen) {
|
||||||
|
width, height := screen.Size()
|
||||||
|
if width == 1 {
|
||||||
|
for borderY := 0; borderY < height; borderY++ {
|
||||||
|
screen.SetContent(0, borderY, mauview.Borders.Vertical, nil, border.Style)
|
||||||
|
}
|
||||||
|
} else if height == 1 {
|
||||||
|
for borderX := 0; borderX < width; borderX++ {
|
||||||
|
screen.SetContent(borderX, 0, mauview.Borders.Horizontal, nil, border.Style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (border *Border) OnKeyEvent(event mauview.KeyEvent) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (border *Border) OnPasteEvent(event mauview.PasteEvent) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (border *Border) OnMouseEvent(event mauview.MouseEvent) bool {
|
||||||
|
return false
|
||||||
|
}
|
224
tui/widget/color.go
Normal file
224
tui/widget/color.go
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
var colorNames = []string{
|
||||||
|
"maroon",
|
||||||
|
"green",
|
||||||
|
"olive",
|
||||||
|
"navy",
|
||||||
|
"purple",
|
||||||
|
"teal",
|
||||||
|
"silver",
|
||||||
|
"gray",
|
||||||
|
"red",
|
||||||
|
"lime",
|
||||||
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"fuchsia",
|
||||||
|
"aqua",
|
||||||
|
"white",
|
||||||
|
"aliceblue",
|
||||||
|
"antiquewhite",
|
||||||
|
"aquamarine",
|
||||||
|
"azure",
|
||||||
|
"beige",
|
||||||
|
"bisque",
|
||||||
|
"blanchedalmond",
|
||||||
|
"blueviolet",
|
||||||
|
"brown",
|
||||||
|
"burlywood",
|
||||||
|
"cadetblue",
|
||||||
|
"chartreuse",
|
||||||
|
"chocolate",
|
||||||
|
"coral",
|
||||||
|
"cornflowerblue",
|
||||||
|
"cornsilk",
|
||||||
|
"crimson",
|
||||||
|
"darkblue",
|
||||||
|
"darkcyan",
|
||||||
|
"darkgoldenrod",
|
||||||
|
"darkgray",
|
||||||
|
"darkgreen",
|
||||||
|
"darkkhaki",
|
||||||
|
"darkmagenta",
|
||||||
|
"darkolivegreen",
|
||||||
|
"darkorange",
|
||||||
|
"darkorchid",
|
||||||
|
"darkred",
|
||||||
|
"darksalmon",
|
||||||
|
"darkseagreen",
|
||||||
|
"darkslateblue",
|
||||||
|
"darkslategray",
|
||||||
|
"darkturquoise",
|
||||||
|
"darkviolet",
|
||||||
|
"deeppink",
|
||||||
|
"deepskyblue",
|
||||||
|
"dimgray",
|
||||||
|
"dodgerblue",
|
||||||
|
"firebrick",
|
||||||
|
"floralwhite",
|
||||||
|
"forestgreen",
|
||||||
|
"gainsboro",
|
||||||
|
"ghostwhite",
|
||||||
|
"gold",
|
||||||
|
"goldenrod",
|
||||||
|
"greenyellow",
|
||||||
|
"honeydew",
|
||||||
|
"hotpink",
|
||||||
|
"indianred",
|
||||||
|
"indigo",
|
||||||
|
"ivory",
|
||||||
|
"khaki",
|
||||||
|
"lavender",
|
||||||
|
"lavenderblush",
|
||||||
|
"lawngreen",
|
||||||
|
"lemonchiffon",
|
||||||
|
"lightblue",
|
||||||
|
"lightcoral",
|
||||||
|
"lightcyan",
|
||||||
|
"lightgoldenrodyellow",
|
||||||
|
"lightgray",
|
||||||
|
"lightgreen",
|
||||||
|
"lightpink",
|
||||||
|
"lightsalmon",
|
||||||
|
"lightseagreen",
|
||||||
|
"lightskyblue",
|
||||||
|
"lightslategray",
|
||||||
|
"lightsteelblue",
|
||||||
|
"lightyellow",
|
||||||
|
"limegreen",
|
||||||
|
"linen",
|
||||||
|
"mediumaquamarine",
|
||||||
|
"mediumblue",
|
||||||
|
"mediumorchid",
|
||||||
|
"mediumpurple",
|
||||||
|
"mediumseagreen",
|
||||||
|
"mediumslateblue",
|
||||||
|
"mediumspringgreen",
|
||||||
|
"mediumturquoise",
|
||||||
|
"mediumvioletred",
|
||||||
|
"midnightblue",
|
||||||
|
"mintcream",
|
||||||
|
"mistyrose",
|
||||||
|
"moccasin",
|
||||||
|
"navajowhite",
|
||||||
|
"oldlace",
|
||||||
|
"olivedrab",
|
||||||
|
"orange",
|
||||||
|
"orangered",
|
||||||
|
"orchid",
|
||||||
|
"palegoldenrod",
|
||||||
|
"palegreen",
|
||||||
|
"paleturquoise",
|
||||||
|
"palevioletred",
|
||||||
|
"papayawhip",
|
||||||
|
"peachpuff",
|
||||||
|
"peru",
|
||||||
|
"pink",
|
||||||
|
"plum",
|
||||||
|
"powderblue",
|
||||||
|
"rebeccapurple",
|
||||||
|
"rosybrown",
|
||||||
|
"royalblue",
|
||||||
|
"saddlebrown",
|
||||||
|
"salmon",
|
||||||
|
"sandybrown",
|
||||||
|
"seagreen",
|
||||||
|
"seashell",
|
||||||
|
"sienna",
|
||||||
|
"skyblue",
|
||||||
|
"slateblue",
|
||||||
|
"slategray",
|
||||||
|
"snow",
|
||||||
|
"springgreen",
|
||||||
|
"steelblue",
|
||||||
|
"tan",
|
||||||
|
"thistle",
|
||||||
|
"tomato",
|
||||||
|
"turquoise",
|
||||||
|
"violet",
|
||||||
|
"wheat",
|
||||||
|
"whitesmoke",
|
||||||
|
"yellowgreen",
|
||||||
|
"grey",
|
||||||
|
"dimgrey",
|
||||||
|
"darkgrey",
|
||||||
|
"darkslategrey",
|
||||||
|
"lightgrey",
|
||||||
|
"lightslategrey",
|
||||||
|
"slategrey",
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHashColorName gets a color name for the given string based on its FNV-1 hash.
|
||||||
|
//
|
||||||
|
// The array of possible color names are the alphabetically ordered color
|
||||||
|
// names specified in tcell.ColorNames.
|
||||||
|
//
|
||||||
|
// The algorithm to get the color is as follows:
|
||||||
|
//
|
||||||
|
// colorNames[ FNV1(string) % len(colorNames) ]
|
||||||
|
//
|
||||||
|
// With the exception of the three special cases:
|
||||||
|
//
|
||||||
|
// --> = green
|
||||||
|
// <-- = red
|
||||||
|
// --- = yellow
|
||||||
|
func GetHashColorName(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "-->":
|
||||||
|
return "green"
|
||||||
|
case "<--":
|
||||||
|
return "red"
|
||||||
|
case "---":
|
||||||
|
return "yellow"
|
||||||
|
default:
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(s))
|
||||||
|
return colorNames[h.Sum32()%uint32(len(colorNames))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHashColor gets the tcell Color value for the given string.
|
||||||
|
//
|
||||||
|
// GetHashColor calls GetHashColorName() and gets the Color value from the tcell.ColorNames map.
|
||||||
|
func GetHashColor(val interface{}) tcell.Color {
|
||||||
|
switch str := val.(type) {
|
||||||
|
case string:
|
||||||
|
return tcell.ColorNames[GetHashColorName(str)]
|
||||||
|
case *string:
|
||||||
|
return tcell.ColorNames[GetHashColorName(*str)]
|
||||||
|
case id.UserID:
|
||||||
|
return tcell.ColorNames[GetHashColorName(string(str))]
|
||||||
|
default:
|
||||||
|
return tcell.ColorNames["red"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddColor adds tview color tags to the given string.
|
||||||
|
func AddColor(s, color string) string {
|
||||||
|
return fmt.Sprintf("[%s]%s[white]", color, s)
|
||||||
|
}
|
2
tui/widget/doc.go
Normal file
2
tui/widget/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Package widget contains additional tview widgets.
|
||||||
|
package widget
|
73
tui/widget/util.go
Normal file
73
tui/widget/util.go
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
// gomuks - A terminal Matrix client written in Go.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"go.mau.fi/mauview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WriteLineSimple(screen mauview.Screen, line string, x, y int) {
|
||||||
|
WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteLineSimpleColor(screen mauview.Screen, line string, x, y int, color tcell.Color) {
|
||||||
|
WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteLineColor(screen mauview.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) {
|
||||||
|
WriteLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteLine(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
|
||||||
|
offsetX := 0
|
||||||
|
if align == mauview.AlignRight {
|
||||||
|
offsetX = maxWidth - runewidth.StringWidth(line)
|
||||||
|
}
|
||||||
|
if offsetX < 0 {
|
||||||
|
offsetX = 0
|
||||||
|
}
|
||||||
|
for _, ch := range line {
|
||||||
|
chWidth := runewidth.RuneWidth(ch)
|
||||||
|
if chWidth == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for localOffset := 0; localOffset < chWidth; localOffset++ {
|
||||||
|
screen.SetContent(x+offsetX+localOffset, y, ch, nil, style)
|
||||||
|
}
|
||||||
|
offsetX += chWidth
|
||||||
|
if offsetX >= maxWidth {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteLinePadded(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
|
||||||
|
padding := strconv.Itoa(maxWidth)
|
||||||
|
if align == mauview.AlignRight {
|
||||||
|
line = fmt.Sprintf("%"+padding+"s", line)
|
||||||
|
} else {
|
||||||
|
line = fmt.Sprintf("%-"+padding+"s", line)
|
||||||
|
}
|
||||||
|
WriteLine(screen, mauview.AlignLeft, line, x, y, maxWidth, style)
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue