From 2fae6ffbb3e6fd37a9f2ce7e3a8fb341d85bf768 Mon Sep 17 00:00:00 2001 From: "diamondburned (Forefront)" Date: Fri, 3 Jul 2020 21:41:12 -0700 Subject: [PATCH] UI changes; typing state working This commit refactors the input container's UI as well as fixing some bugs related to asynchronous fetching of images. It also adds complete typing indicator capabilities, all without using a single mutex! --- go.mod | 10 +- go.sum | 63 +++----- internal/gts/gts.go | 101 ++---------- internal/gts/httputil/image.go | 2 +- internal/ui/messages/container/container.go | 5 +- internal/ui/messages/container/grid.go | 7 +- internal/ui/messages/input/input.go | 32 +++- internal/ui/messages/input/username.go | 141 ----------------- .../ui/messages/input/username/username.go | 15 +- internal/ui/messages/typing/state.go | 145 ++++++++++++++++++ internal/ui/messages/typing/typing.go | 96 ++++++++---- internal/ui/messages/view.go | 11 ++ internal/ui/rich/async.go | 108 +++++++++++++ internal/ui/rich/image.go | 31 ++-- internal/ui/rich/label.go | 23 ++- internal/ui/rich/rich.go | 4 +- internal/ui/service/service.go | 7 + .../ui/service/session/commander/commander.go | 6 + internal/ui/style.css | 17 +- 19 files changed, 464 insertions(+), 360 deletions(-) delete mode 100644 internal/ui/messages/input/username.go create mode 100644 internal/ui/messages/typing/state.go create mode 100644 internal/ui/rich/async.go diff --git a/go.mod b/go.mod index 0076715..6920eb1 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,11 @@ replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200630 require ( github.com/Xuanwo/go-locale v0.2.0 github.com/alecthomas/chroma v0.7.3 - github.com/diamondburned/cchat v0.0.39 - github.com/diamondburned/cchat-discord v0.0.0-20200701182710-73a7393e7846 - github.com/diamondburned/cchat-mock v0.0.0-20200630025821-605d61d89288 - github.com/diamondburned/imgutil v0.0.0-20200611215339-650ac7cfaf64 + github.com/diamondburned/cchat v0.0.40 + github.com/diamondburned/cchat-discord v0.0.0-20200703190659-fbf95b9b6c03 + github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3 + github.com/diamondburned/imgutil v0.0.0-20200704034004-40dbfc732516 github.com/goodsign/monday v1.0.0 - github.com/google/btree v1.0.0 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/ianlancetaylor/cgosymbolizer v0.0.0-20200424224625-be1b05b0b279 diff --git a/go.sum b/go.sum index 39b0196..5ef7aad 100644 --- a/go.sum +++ b/go.sum @@ -21,11 +21,14 @@ github.com/Pallinder/go-randomdata v1.2.0 h1:DZ41wBchNRb/0GfsePLiSwb0PHZmT67XY00 github.com/Pallinder/go-randomdata v1.2.0/go.mod h1:yHmJgulpD2Nfrm0cR9tI/+oAgRqCQQixsA8HyRZfV9Y= github.com/Xuanwo/go-locale v0.2.0 h1:1N8SGG2VNpLl6VVa8ueZm3Nm+dxvk8ffY9aviKHl4IE= github.com/Xuanwo/go-locale v0.2.0/go.mod h1:6qbT9M726OJgyiGZro2YwPmx63wQzlH+VvtjJWQoftw= +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= github.com/alecthomas/chroma v0.7.3 h1:NfdAERMy+esYQs8OXk0I868/qDxxCEo7FMz1WIqMAeI= github.com/alecthomas/chroma v0.7.3/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -39,48 +42,20 @@ github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/diamondburned/aqs v0.0.0-20200704043812-99b676ee44eb h1:Ja/niwykeFoSkYxdRRzM8QUAuCswfLmaiBTd2UIU+54= +github.com/diamondburned/aqs v0.0.0-20200704043812-99b676ee44eb/go.mod h1:q1MbMBfZrv7xqV8n7LgMwhHs3oBbNwWJes8exs2AmDs= github.com/diamondburned/arikawa v0.9.5 h1:P1ffsp+NHT22wWKYFVC8CdlGRLzPuUV9FcCBKOCJpCI= github.com/diamondburned/arikawa v0.9.5/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= -github.com/diamondburned/cchat v0.0.28 h1:+1VnltW0rl8/NZTUP+x89jVhi3YTTR+e6iLprZ7HcwM= -github.com/diamondburned/cchat v0.0.28/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat v0.0.31 h1:yUgrh5xbGX0R55glyxYtVewIDL2eXLJ+okIEfVaVoFk= -github.com/diamondburned/cchat v0.0.31/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat v0.0.32 h1:nLiD4sL9+DLBnvNb9XLidd5peRzTgM9lWcqRsUmm474= -github.com/diamondburned/cchat v0.0.32/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat v0.0.34 h1:BGiVxMRA9dmW3rLilIldBvjVan7eTTpaWCCfX9IKBYU= -github.com/diamondburned/cchat v0.0.34/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat v0.0.35 h1:WiMGl8BQJgbP9E4xRxgLGlqUsHpTcJgDKDt8/6a7lBk= -github.com/diamondburned/cchat v0.0.35/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat v0.0.37 h1:yGz9yls5Lb/vLkU/DU53GjC80WOqoRe229DXsu5mtaY= -github.com/diamondburned/cchat v0.0.37/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat v0.0.39 h1:Hxd7swmAIECm0MBd5wb1IFvreChwDFwnAshqgAstWGA= -github.com/diamondburned/cchat v0.0.39/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat-discord v0.0.0-20200619222738-e5babcbb42e3 h1:8RCcaY3gtA+8NG2mwkcC/PIFK+eS8XnGyeVaUbCXbF0= -github.com/diamondburned/cchat-discord v0.0.0-20200619222738-e5babcbb42e3/go.mod h1:4q0jHEl1gJEzkS92oacwcSf9+3fFcNPukOpURDJpV/A= -github.com/diamondburned/cchat-discord v0.0.0-20200629023132-0a66a65fcf27 h1:MAWs83jAZKxkU04RAoaewc1xUrLKlB6F5X1ViFRkWdw= -github.com/diamondburned/cchat-discord v0.0.0-20200629023132-0a66a65fcf27/go.mod h1:af673uNyL2NSjYqJ54DotPV/iNY+OvBcQfDqHefbap4= -github.com/diamondburned/cchat-discord v0.0.0-20200630031444-53111f3186b3 h1:NDh2osOfPBtiNzz8ro8P7vN4f91uuVMMBJ8+Q2s/EQo= -github.com/diamondburned/cchat-discord v0.0.0-20200630031444-53111f3186b3/go.mod h1:hj6qS/5TOiIxwWyFMts51ILzY9M3GKbXT31hjVbr9gM= -github.com/diamondburned/cchat-discord v0.0.0-20200701182710-73a7393e7846 h1:LLgI89H9A4ZVf8U7bHmOSkG12b8B6a9dwo32Bq7+0AI= -github.com/diamondburned/cchat-discord v0.0.0-20200701182710-73a7393e7846/go.mod h1:lLWRY7l6cC3bI2ge+Vc7pEhgbIL8XYCeXt+h1PWA/1k= -github.com/diamondburned/cchat-mock v0.0.0-20200615015702-8cac8b16378d h1:LkzARyvdGRvAsaKEPTV3XcqMHENH6J+KRAI+3sq41Qs= -github.com/diamondburned/cchat-mock v0.0.0-20200615015702-8cac8b16378d/go.mod h1:SVTt5je4G+re8aSVJAFk/x8vvbRzXdpKgSKmVGoM1tg= -github.com/diamondburned/cchat-mock v0.0.0-20200620231423-b286a0301190 h1:mHbA/xhL584IToD38m3QkeU1cRWIPzBZCDFi1Wv0Bz4= -github.com/diamondburned/cchat-mock v0.0.0-20200620231423-b286a0301190/go.mod h1:u4aWUu4be+JkuMtTIGJNS79T1b+8ruijVn9qL/LM4Rk= -github.com/diamondburned/cchat-mock v0.0.0-20200628063912-3155c1b6d6a9 h1:PnVUCrLTsQlafutS13ST7292WkBpiMJzA7125q02LkA= -github.com/diamondburned/cchat-mock v0.0.0-20200628063912-3155c1b6d6a9/go.mod h1:kKtLZzvkJdeLpiNVmwe15Bl202gfbIHC/LwWUbsSnls= -github.com/diamondburned/cchat-mock v0.0.0-20200630025821-605d61d89288 h1:ApNV7DUyj+LG5lUxtTv4yvQZcprsDgZji0iiD+LYJmM= -github.com/diamondburned/cchat-mock v0.0.0-20200630025821-605d61d89288/go.mod h1:Tu+8b1iz9NGeQb2jmndXn+dQ9zBUa8a8ktK9hL5aaxw= -github.com/diamondburned/gotk3 v0.0.0-20200619213419-0533bcce0dd6 h1:ZzLrfQqszhzWI7zqwltzQIWtppfcL7m2aIEpB4kuqx0= -github.com/diamondburned/gotk3 v0.0.0-20200619213419-0533bcce0dd6/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= +github.com/diamondburned/cchat v0.0.40 h1:38gPyJnnDoNDHrXcV8Qchfv3y6jlS3Fzz/6FY0BPH6I= +github.com/diamondburned/cchat v0.0.40/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat-discord v0.0.0-20200703190659-fbf95b9b6c03 h1:F5TL7GPRU/D4ldVkS0haY3SiHPtf1Kby/4nbYpm//MQ= +github.com/diamondburned/cchat-discord v0.0.0-20200703190659-fbf95b9b6c03/go.mod h1:p0X6QUH0mxK8yEW0+a4QA77ClAmoxz8CvgbnobMtWQA= +github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3 h1:xr07/2cwINyrMqh92pQQJVDfQqG0u6gHAK+ZcGfpSew= +github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3/go.mod h1:SRu3OOeggELFr2Wd3/+SpYV1eNcvSk2LBhM70NOZSG8= github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d h1:Ha/I6PMKi+B4hpWclwlXj0tUMehR7Q0TNxPczzBwzPI= github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= -github.com/diamondburned/imgutil v0.0.0-20200611215339-650ac7cfaf64 h1:/ykUYHuYyj+NN/aaqe6lfaCZQc3EMZs93wAGVJTh5j0= -github.com/diamondburned/imgutil v0.0.0-20200611215339-650ac7cfaf64/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ= -github.com/diamondburned/ningen v0.1.0 h1:cTnRNrN0g2Wr/kgjLLpa3pqlbEd6JPNa1yGDer8uV4U= -github.com/diamondburned/ningen v0.1.0/go.mod h1:1vi8mlBlM2xjJy+IugU51q+IMgyNXggS4Xv3SPFd2Q4= -github.com/diamondburned/ningen v0.1.1-0.20200621003851-e5908c53bf21 h1:luSZOjnwoHCbuw5mG3muuDSOUv5YCbFbbjw17dKZ1Ik= -github.com/diamondburned/ningen v0.1.1-0.20200621003851-e5908c53bf21/go.mod h1:1vi8mlBlM2xjJy+IugU51q+IMgyNXggS4Xv3SPFd2Q4= +github.com/diamondburned/imgutil v0.0.0-20200704034004-40dbfc732516 h1:6j4oZahbNdVhSEInRfeYbgDpx1FXDfJy6CcUVyWOuVY= +github.com/diamondburned/imgutil v0.0.0-20200704034004-40dbfc732516/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ= github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249 h1:yP7kJ+xCGpDz6XbcfACJcju4SH1XDPwlrvbofz3lP8I= github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249/go.mod h1:xW9hpBZsGi8KpAh10TyP+YQlYBo+Xc+2w4TR6N0951A= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= @@ -125,8 +100,6 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= @@ -146,15 +119,21 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/markbates/pkger v0.17.0 h1:RFfyBPufP2V6cddUyyEVSHBpaAnM1WzaMNyqomeT+iY= github.com/markbates/pkger v0.17.0/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= @@ -166,6 +145,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= @@ -262,12 +242,15 @@ golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY= golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI= diff --git a/internal/gts/gts.go b/internal/gts/gts.go index 6cce724..17855f3 100644 --- a/internal/gts/gts.go +++ b/internal/gts/gts.go @@ -1,7 +1,6 @@ package gts import ( - "context" "os" "time" @@ -159,93 +158,21 @@ func ExecSync(fn func()) <-chan struct{} { return ch } +// AfterFunc mimics time.AfterFunc's API but runs the callback inside the Gtk +// main loop. +func AfterFunc(d time.Duration, f func()) (stop func()) { + h, err := glib.TimeoutAdd( + uint(d.Milliseconds()), + func() bool { f(); return true }, + ) + if err != nil { + panic(err) + } + + return func() { glib.SourceRemove(h) } +} + func EventIsRightClick(ev *gdk.Event) bool { keyev := gdk.EventButtonNewFromEvent(ev) return keyev.Type() == gdk.EVENT_BUTTON_PRESS && keyev.Button() == gdk.BUTTON_SECONDARY } - -// Reuser is an interface for structs that inherit Reusable. -type Reuser interface { - Context() context.Context - Acquire() int64 - Validate(int64) bool -} - -type AsyncUser = func(context.Context) (interface{}, error) - -// AsyncUse is a handler for structs that implement the Reuser primitive. The -// passed in function will be called asynchronously, but swap will be called in -// the Gtk main thread. -func AsyncUse(r Reuser, swap func(interface{}), fn AsyncUser) { - // Acquire an ID. - id := r.Acquire() - ctx := r.Context() - - Async(func() (func(), error) { - // Run the callback asynchronously. - v, err := fn(ctx) - if err != nil { - return nil, err - } - - return func() { - // Validate the ID. Cancel if it's invalid. - if !r.Validate(id) { - log.Println("Async function value dropped for reusable primitive.") - return - } - - // Update the resource. - swap(v) - }, nil - }) -} - -// Reusable is the synchronization primitive to provide a method for -// asynchronous cancellation and reusability. -// -// It works by copying the ID (time) for each asynchronous operation. The -// operation then completes, and the ID is then compared again before being -// used. It provides a cancellation abstraction around the Gtk main thread. -// -// This struct is not thread-safe, as it relies on the Gtk main thread -// synchronization. -type Reusable struct { - time int64 // creation time, used as ID - ctx context.Context - cancel func() -} - -func NewReusable() *Reusable { - r := &Reusable{} - r.Invalidate() - return r -} - -// Invalidate generates a new ID for the primitive, which would render -// asynchronously updating elements invalid. -func (r *Reusable) Invalidate() { - // Cancel the old context. - if r.cancel != nil { - r.cancel() - } - - // Reset. - r.time = time.Now().UnixNano() - r.ctx, r.cancel = context.WithCancel(context.Background()) -} - -// Context returns the reusable's cancellable context. It never returns nil. -func (r *Reusable) Context() context.Context { - return r.ctx -} - -// Reusable checks the acquired ID against the current one. -func (r *Reusable) Validate(acquired int64) (valid bool) { - return r.time == acquired -} - -// Acquire lends the ID to be given to Reusable() after finishing. -func (r *Reusable) Acquire() int64 { - return r.time -} diff --git a/internal/gts/httputil/image.go b/internal/gts/httputil/image.go index bd7bba1..a412bae 100644 --- a/internal/gts/httputil/image.go +++ b/internal/gts/httputil/image.go @@ -57,7 +57,7 @@ func AsyncImageSized(img ImageContainerSizer, url string, w, h int, procs ...img } // Add a processor to resize. - procs = imgutil.Prepend(imgutil.Resize(w, h), procs) + procs = append(procs, imgutil.Resize(w, h)) ctx, cancel := context.WithCancel(context.Background()) connectDestroyer(img, cancel) diff --git a/internal/ui/messages/container/container.go b/internal/ui/messages/container/container.go index 280c57a..40b6bb3 100644 --- a/internal/ui/messages/container/container.go +++ b/internal/ui/messages/container/container.go @@ -59,8 +59,9 @@ type Controller interface { BindMenu(GridMessage) // Bottomed returns whether or not the message scroller is at the bottom. Bottomed() bool - // ScrollToBottom scrolls the message view to the bottom. - // ScrollToBottom() + // AuthorEvent is called on message create/update. This is used to update + // the typer state. + AuthorEvent(a cchat.MessageAuthor) } // Constructor is an interface for making custom message implementations which diff --git a/internal/ui/messages/container/grid.go b/internal/ui/messages/container/grid.go index 0c63be0..000f8c6 100644 --- a/internal/ui/messages/container/grid.go +++ b/internal/ui/messages/container/grid.go @@ -26,7 +26,6 @@ func NewGridStore(constr Constructor, ctrl Controller) *GridStore { grid.SetRowSpacing(5) grid.SetMarginStart(5) grid.SetMarginEnd(5) - grid.SetMarginBottom(5) grid.Show() primitives.AddClass(grid, "message-grid") @@ -230,6 +229,9 @@ func (c *GridStore) AddPresendMessage(msg input.PresendMessage) PresendGridMessa } func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) { + // Call the event handler last. + defer c.Controller.AuthorEvent(msg.Author()) + // Attempt to update before insertion (aka upsert). if msgc := c.Message(msg); msgc != nil { msgc.UpdateAuthor(msg.Author()) @@ -253,6 +255,9 @@ func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) { } func (c *GridStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) { + // Call the event handler last. + defer c.Controller.AuthorEvent(msg.Author()) + if msgc := c.Message(msg); msgc != nil { if author := msg.Author(); author != nil { msgc.UpdateAuthor(author) diff --git a/internal/ui/messages/input/input.go b/internal/ui/messages/input/input.go index bb0fd8d..157eec6 100644 --- a/internal/ui/messages/input/input.go +++ b/internal/ui/messages/input/input.go @@ -23,19 +23,31 @@ type InputView struct { } var textCSS = primitives.PrepareCSS(` - textview, textview * { + .message-input, .message-input * { background-color: transparent; } + + .message-input * { + background-color: @theme_base_color; + border: 1px solid alpha(@theme_fg_color, 0.2); + border-radius: 4px; + transition: linear 50ms border-color; + } + + .message-input:focus * { + border-color: @theme_selected_bg_color; + } `) func NewView(ctrl Controller) *InputView { text, _ := gtk.TextViewNew() text.SetSensitive(false) text.SetWrapMode(gtk.WRAP_WORD_CHAR) - text.SetProperty("top-margin", inputmargin) - text.SetProperty("left-margin", inputmargin) - text.SetProperty("right-margin", inputmargin) - text.SetProperty("bottom-margin", inputmargin) + text.SetVAlign(gtk.ALIGN_START) + text.SetProperty("top-margin", 4) + text.SetProperty("bottom-margin", 4) + text.SetProperty("left-margin", 8) + text.SetProperty("right-margin", 8) text.Show() primitives.AddClass(text, "message-input") @@ -79,11 +91,14 @@ type Field struct { editingID string // never empty } -const inputmargin = username.VMargin +var scrollinputCSS = primitives.PrepareCSS(` + .scrolled-input { + margin: 5px; + } +`) func NewField(text *gtk.TextView, ctrl Controller) *Field { username := username.NewContainer() - username.SetVAlign(gtk.ALIGN_END) username.Show() buf, _ := text.GetBuffer() @@ -91,6 +106,9 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field { sw := scrollinput.NewV(text, 150) sw.Show() + primitives.AddClass(sw, "scrolled-input") + primitives.AttachCSS(sw, scrollinputCSS) + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) box.PackStart(username, false, false, 0) box.PackStart(sw, true, true, 0) diff --git a/internal/ui/messages/input/username.go b/internal/ui/messages/input/username.go deleted file mode 100644 index f2a973d..0000000 --- a/internal/ui/messages/input/username.go +++ /dev/null @@ -1,141 +0,0 @@ -package input - -import ( - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/gts" - "github.com/diamondburned/cchat-gtk/internal/ui/config" - "github.com/diamondburned/cchat-gtk/internal/ui/rich" - "github.com/diamondburned/cchat/text" - "github.com/diamondburned/imgutil" - "github.com/gotk3/gotk3/gtk" -) - -const AvatarSize = 24 - -var showUser = true -var currentRevealer = func(bool) {} // noop by default - -func init() { - // Bind this revealer in settings. - config.AppearanceAdd("Show Username in Input", config.Switch( - &showUser, - func(b bool) { currentRevealer(b) }, - )) -} - -type usernameContainer struct { - *gtk.Revealer - main *gtk.Box - avatar *rich.Icon - label *rich.Label -} - -var ( - _ cchat.LabelContainer = (*usernameContainer)(nil) - _ cchat.IconContainer = (*usernameContainer)(nil) -) - -func newUsernameContainer() *usernameContainer { - avatar := rich.NewIcon(AvatarSize, imgutil.Round(true)) - avatar.SetPlaceholderIcon("user-available-symbolic", AvatarSize) - avatar.Show() - - label := rich.NewLabel(text.Rich{}) - label.SetMaxWidthChars(35) - label.Show() - - box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5) - box.PackStart(avatar, false, false, 0) - box.PackStart(label, false, false, 0) - box.SetMarginStart(10) - box.SetMarginEnd(10) - box.SetMarginTop(inputmargin) - box.SetMarginBottom(inputmargin) - box.SetVAlign(gtk.ALIGN_START) - box.Show() - - rev, _ := gtk.RevealerNew() - rev.SetRevealChild(false) - rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) - rev.SetTransitionDuration(50) - rev.Add(box) - - // Bind the current global revealer to this revealer for settings. This - // operation should be thread-safe, as everything is being done in the main - // thread. - currentRevealer = rev.SetRevealChild - - return &usernameContainer{ - Revealer: rev, - main: box, - avatar: avatar, - label: label, - } -} - -func (u *usernameContainer) SetRevealChild(reveal bool) { - // Only reveal if showUser is true. - u.Revealer.SetRevealChild(reveal && showUser) -} - -// shouldReveal returns whether or not the container should reveal. -func (u *usernameContainer) shouldReveal() bool { - return !u.label.GetLabel().Empty() && showUser -} - -func (u *usernameContainer) Reset() { - u.SetRevealChild(false) - u.avatar.Reset() - u.label.Reset() -} - -// Update is not thread-safe. -func (u *usernameContainer) Update(session cchat.Session, sender cchat.ServerMessageSender) { - // Set the fallback username. - u.label.SetLabelUnsafe(session.Name()) - // Reveal the name if it's not empty. - u.SetRevealChild(u.shouldReveal()) - - // Does sender (aka Server) implement ServerNickname? If yes, use it. - if nicknamer, ok := sender.(cchat.ServerNickname); ok { - u.label.AsyncSetLabel(nicknamer.Nickname, "Error fetching server nickname") - } - - // Does session implement an icon? Update if yes. - if iconer, ok := session.(cchat.Icon); ok { - u.avatar.AsyncSetIconer(iconer, "Error fetching session icon URL") - } -} - -// GetLabel is not thread-safe. -func (u *usernameContainer) GetLabel() text.Rich { - return u.label.GetLabel() -} - -// SetLabel is thread-safe. -func (u *usernameContainer) SetLabel(content text.Rich) { - gts.ExecAsync(func() { - u.label.SetLabelUnsafe(content) - - // Reveal if the name is not empty. - u.SetRevealChild(u.shouldReveal()) - }) -} - -// SetIcon is thread-safe. -func (u *usernameContainer) SetIcon(url string) { - gts.ExecAsync(func() { - u.avatar.SetIconUnsafe(url) - - // Reveal if the icon URL is not empty. We don't touch anything if the - // URL is empty, as the name might not be. - if url != "" { - u.SetRevealChild(true) - } - }) -} - -// GetIconURL is not thread-safe. -func (u *usernameContainer) GetIconURL() string { - return u.avatar.URL() -} diff --git a/internal/ui/messages/input/username/username.go b/internal/ui/messages/input/username/username.go index de54849..89ca69b 100644 --- a/internal/ui/messages/input/username/username.go +++ b/internal/ui/messages/input/username/username.go @@ -4,6 +4,7 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/ui/config" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat/text" "github.com/diamondburned/imgutil" @@ -11,7 +12,6 @@ import ( ) const AvatarSize = 24 -const VMargin = 4 var showUser = true var currentRevealer = func(bool) {} // noop by default @@ -36,6 +36,12 @@ var ( _ cchat.IconContainer = (*Container)(nil) ) +var usernameCSS = primitives.PrepareCSS(` + .username-view { + margin: 8px 10px; + } +`) + func NewContainer() *Container { avatar := rich.NewIcon(AvatarSize, imgutil.Round(true)) avatar.SetPlaceholderIcon("user-available-symbolic", AvatarSize) @@ -48,13 +54,12 @@ func NewContainer() *Container { box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5) box.PackStart(avatar, false, false, 0) box.PackStart(label, false, false, 0) - box.SetMarginStart(10) - box.SetMarginEnd(10) - box.SetMarginTop(VMargin) - box.SetMarginBottom(VMargin) box.SetVAlign(gtk.ALIGN_START) box.Show() + primitives.AddClass(box, "username-view") + primitives.AttachCSS(box, usernameCSS) + rev, _ := gtk.RevealerNew() rev.SetRevealChild(false) rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) diff --git a/internal/ui/messages/typing/state.go b/internal/ui/messages/typing/state.go new file mode 100644 index 0000000..c817f4f --- /dev/null +++ b/internal/ui/messages/typing/state.go @@ -0,0 +1,145 @@ +package typing + +import ( + "sort" + "time" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/pkg/errors" +) + +type State struct { + // states + typers []cchat.Typer + timeout time.Duration + canceler func() + invalidated bool + + // consts + changed func(s *State, empty bool) + stopper func() // stops the event loop, not used atm +} + +var _ cchat.TypingIndicator = (*State)(nil) + +func NewState(changed func(s *State, empty bool)) *State { + s := &State{changed: changed} + s.stopper = gts.AfterFunc(time.Second/2, s.loop) + return s +} + +func (s *State) reset() { + if s.canceler != nil { + s.canceler() + s.canceler = nil + } + + s.timeout = 0 + s.typers = nil + s.invalidated = false +} + +// Subscribe is thread-safe. +func (s *State) Subscribe(indicator cchat.ServerMessageTypingIndicator) { + gts.Async(func() (func(), error) { + c, err := indicator.TypingSubscribe(s) + if err != nil { + return nil, errors.Wrap(err, "Failed to subscribe to typing indicator") + } + + return func() { + s.canceler = c + s.timeout = indicator.TypingTimeout() + }, nil + }) +} + +// loop runs a single iteration of the event loop. This function is not +// thread-safe. +func (s *State) loop() { + // Filter out any expired typers. + t, ok := filterTypers(s.typers, s.timeout) + if ok { + s.invalidated = true + s.typers = t + } + + // Call the event handler if things are invalidated. + if s.invalidated { + s.changed(s, len(s.typers) == 0) + s.invalidated = false + } +} + +// invalidate sorts and invalidates the state. +func (s *State) invalidate() { + // Sort the list of typers again. + sort.Slice(s.typers, func(i, j int) bool { + return s.typers[i].Time().Before(s.typers[j].Time()) + }) + + s.invalidated = true +} + +// AddTyper is thread-safe. +func (s *State) AddTyper(typer cchat.Typer) { + gts.ExecAsync(func() { + defer s.invalidate() + + // If the typer already exists, then pop them to the start of the list. + for i, t := range s.typers { + if t.ID() == typer.ID() { + s.typers[i] = t + return + } + } + + s.typers = append(s.typers, typer) + }) +} + +// RemoveTyper is thread-safe. +func (s *State) RemoveTyper(typerID string) { + gts.ExecAsync(func() { s.removeTyper(typerID) }) +} + +func (s *State) removeTyper(typerID string) { + defer s.invalidate() + + for i, t := range s.typers { + if t.ID() == typerID { + // Remove the quick way. Sort will take care of ordering. + l := len(s.typers) - 1 + s.typers[i] = s.typers[l] + s.typers[l] = nil + s.typers = s.typers[:l] + + return + } + } +} + +func filterTypers(typers []cchat.Typer, timeout time.Duration) ([]cchat.Typer, bool) { + // Fast path. + if len(typers) == 0 || timeout == 0 { + return nil, false + } + + var now = time.Now() + var cut int + + for _, t := range typers { + if now.Sub(t.Time()) < timeout { + typers[cut] = t + cut++ + } + } + + for i := cut; i < len(typers); i++ { + typers[i] = nil + } + + var changed = cut != len(typers) + return typers[:cut], changed +} diff --git a/internal/ui/messages/typing/typing.go b/internal/ui/messages/typing/typing.go index 3427af4..1dafd9f 100644 --- a/internal/ui/messages/typing/typing.go +++ b/internal/ui/messages/typing/typing.go @@ -1,29 +1,19 @@ package typing import ( + "strings" + "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/ui/messages/input/username" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser" "github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/pango" ) -type State struct { - typers []cchat.Typer -} - -func NewState() *State { - return &State{} -} - -func (s *State) Empty() bool { - // return len(s.typers) == 0 - return false -} - var typingIndicatorCSS = primitives.PrepareCSS(` .typing-indicator { - border-radius: 8px 8px 0 0; + margin: 0 6px; + border-radius: 6px 6px 0 0; color: alpha(@theme_fg_color, 0.8); background-color: @theme_base_color; } @@ -35,42 +25,94 @@ var smallfonts = primitives.PrepareCSS(` type Container struct { *gtk.Revealer - empty bool // && state.Empty() - State *State + state *State } -const placeholder = "Bruh moment..." - func New() *Container { d := NewDots() d.Show() - l, _ := gtk.LabelNew(placeholder) + l, _ := gtk.LabelNew("") l.SetXAlign(0) l.SetEllipsize(pango.ELLIPSIZE_END) l.Show() primitives.AttachCSS(l, smallfonts) b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) - b.PackStart(d, false, false, username.VMargin) + b.PackStart(d, false, false, 4) b.PackStart(l, true, true, 0) - b.SetMarginStart(username.VMargin * 2) - b.SetMarginEnd(username.VMargin * 2) b.Show() r, _ := gtk.RevealerNew() r.SetTransitionDuration(50) r.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_CROSSFADE) - r.SetRevealChild(true) + r.SetRevealChild(false) r.Add(b) - state := NewState() - primitives.AddClass(b, "typing-indicator") primitives.AttachCSS(b, typingIndicatorCSS) + state := NewState(func(s *State, empty bool) { + r.SetRevealChild(!empty) + l.SetMarkup(render(s.typers)) + }) + + // On label destroy, stop the state loop as well. + l.Connect("destroy", state.stopper) + return &Container{ Revealer: r, - State: state, + state: state, } } + +func (c *Container) Reset() { + c.state.reset() + c.SetRevealChild(false) +} + +func (c *Container) RemoveAuthor(author cchat.MessageAuthor) { + c.state.removeTyper(author.ID()) +} + +func (c *Container) TrySubscribe(svmsg cchat.ServerMessage) bool { + ti, ok := svmsg.(cchat.ServerMessageTypingIndicator) + if !ok { + return false + } + + c.state.Subscribe(ti) + return true +} + +func render(typers []cchat.Typer) string { + // fast path + if len(typers) == 0 { + return "" + } + + var builder strings.Builder + + for i, typer := range typers { + builder.WriteString("") + builder.WriteString(parser.RenderMarkup(typer.Name())) + builder.WriteString("") + + switch i { + case len(typers) - 2: + builder.WriteString(" and ") + case len(typers) - 1: + // Write nothing if this is the last item. + default: + builder.WriteString(", ") + } + } + + if len(typers) == 1 { + builder.WriteString(" is typing.") + } else { + builder.WriteString(" are typing.") + } + + return builder.String() +} diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index 99d4719..c53d85b 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -14,6 +14,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" "github.com/diamondburned/cchat-gtk/internal/ui/messages/sadface" "github.com/diamondburned/cchat-gtk/internal/ui/messages/typing" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/autoscroll" "github.com/diamondburned/cchat-gtk/internal/ui/service/menu" "github.com/gotk3/gotk3/gtk" @@ -81,6 +82,8 @@ func NewView() *View { view.Box.PackStart(view.InputView, false, false, 0) view.Box.Show() + primitives.AddClass(view.Box, "message-view") + // placeholder logo logo, _ := gtk.ImageNewFromPixbuf(icons.Logo256()) logo.Show() @@ -111,6 +114,7 @@ func (v *View) Bottomed() bool { return v.Scroller.Bottomed } func (v *View) Reset() { v.state.Reset() // Reset the state variables. + v.Typing.Reset() // Reset the typing state. v.FaceView.Reset() // Switch back to the main screen. v.InputView.Reset() // Reset the input. v.Container.Reset() // Clean all messages. @@ -164,6 +168,8 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage, done func // Set the cancel handler. v.state.setcurrent(s) + // Try setting the typing indicator if available. + v.Typing.TrySubscribe(server) }, nil }) } @@ -185,6 +191,11 @@ func (v *View) AddPresendMessage(msg input.PresendMessage) func(error) { } } +// AuthorEvent should be called on message create/update/delete. +func (v *View) AuthorEvent(author cchat.MessageAuthor) { + v.Typing.RemoveAuthor(author) +} + // LatestMessageFrom returns the last message ID with that author. func (v *View) LatestMessageFrom(userID string) (msgID string, ok bool) { return v.Container.LatestMessageFrom(userID) diff --git a/internal/ui/rich/async.go b/internal/ui/rich/async.go new file mode 100644 index 0000000..6ff3ce3 --- /dev/null +++ b/internal/ui/rich/async.go @@ -0,0 +1,108 @@ +package rich + +import ( + "context" + "log" + "reflect" + "time" + + "github.com/diamondburned/cchat-gtk/internal/gts" +) + +// Reuser is an interface for structs that inherit Reusable. +type Reuser interface { + Context() context.Context + Acquire() int64 + Validate(int64) bool + SwapResource(v interface{}, cancel func()) +} + +type AsyncUser = func(context.Context) (interface{}, func(), error) + +// AsyncUse is a handler for structs that implement the Reuser primitive. The +// passed in function will be called asynchronously, but swap will be called in +// the Gtk main thread. +func AsyncUse(r Reuser, fn AsyncUser) { + // Acquire an ID. + id := r.Acquire() + ctx := r.Context() + + gts.Async(func() (func(), error) { + // Run the callback asynchronously. + v, cancel, err := fn(ctx) + if err != nil { + return nil, err + } + + return func() { + // Validate the ID. Cancel if it's invalid. + if !r.Validate(id) { + log.Println("Async function value dropped for reusable primitive.") + return + } + + // Update the resource. + r.SwapResource(v, cancel) + }, nil + }) +} + +// Reusable is the synchronization primitive to provide a method for +// asynchronous cancellation and reusability. +// +// It works by copying the ID (time) for each asynchronous operation. The +// operation then completes, and the ID is then compared again before being +// used. It provides a cancellation abstraction around the Gtk main thread. +// +// This struct is not thread-safe, as it relies on the Gtk main thread +// synchronization. +type Reusable struct { + time int64 // creation time, used as ID + ctx context.Context + cancel func() + + swapfn reflect.Value // reflect fn + arg1type reflect.Type +} + +var _ Reuser = (*Reusable)(nil) + +func NewReusable(swapperFn interface{}) *Reusable { + r := Reusable{} + r.swapfn = reflect.ValueOf(swapperFn) + r.arg1type = r.swapfn.Type().In(0) + r.Invalidate() + return &r +} + +// Invalidate generates a new ID for the primitive, which would render +// asynchronously updating elements invalid. +func (r *Reusable) Invalidate() { + // Cancel the old context. + if r.cancel != nil { + r.cancel() + } + + // Reset. + r.time = time.Now().UnixNano() + r.ctx, r.cancel = context.WithCancel(context.Background()) +} + +// Context returns the reusable's cancellable context. It never returns nil. +func (r *Reusable) Context() context.Context { + return r.ctx +} + +// Reusable checks the acquired ID against the current one. +func (r *Reusable) Validate(acquired int64) (valid bool) { + return r.time == acquired +} + +// Acquire lends the ID to be given to Reusable() after finishing. +func (r *Reusable) Acquire() int64 { + return r.time +} + +func (r *Reusable) SwapResource(v interface{}, cancel func()) { + r.swapfn.Call([]reflect.Value{reflect.ValueOf(v)}) +} diff --git a/internal/ui/rich/image.go b/internal/ui/rich/image.go index aa1afa8..3eba07d 100644 --- a/internal/ui/rich/image.go +++ b/internal/ui/rich/image.go @@ -16,12 +16,11 @@ type IconerFn = func(context.Context, cchat.IconContainer) (func(), error) type Icon struct { *gtk.Revealer - Image *gtk.Image - resizer imgutil.Processor - procs []imgutil.Processor - size int + Image *gtk.Image + procs []imgutil.Processor + size int - r gts.Reusable + r *Reusable // state url string @@ -50,10 +49,11 @@ func NewIcon(sizepx int, procs ...imgutil.Processor) *Icon { Revealer: rev, Image: img, procs: procs, - - r: *gts.NewReusable(), } i.SetSize(sizepx) + i.r = NewReusable(func(ni *nullIcon) { + i.SetIconUnsafe(ni.url) + }) return i } @@ -61,6 +61,7 @@ func NewIcon(sizepx int, procs ...imgutil.Processor) *Icon { // Reset wipes the state to be just after construction. func (i *Icon) Reset() { i.url = "" + i.r.Invalidate() // invalidate async fetching images i.Revealer.SetRevealChild(false) i.Image.SetFromPixbuf(nil) // destroy old pb } @@ -99,7 +100,6 @@ func (i *Icon) SetPlaceholderIcon(iconName string, iconSzPx int) { func (i *Icon) SetSize(szpx int) { i.size = szpx i.Image.SetSizeRequest(szpx, szpx) - i.resizer = imgutil.Resize(szpx, szpx) } // AddProcessors is not thread-safe. @@ -112,16 +112,11 @@ func (i *Icon) SetIcon(url string) { gts.ExecAsync(func() { i.SetIconUnsafe(url) }) } -func (i *Icon) swapResource(v interface{}) { - i.SetIconUnsafe(v.(*nullIcon).url) -} - func (i *Icon) AsyncSetIconer(iconer cchat.Icon, wrap string) { - gts.AsyncUse(&i.r, i.swapResource, func(ctx context.Context) (interface{}, error) { + AsyncUse(i.r, func(ctx context.Context) (interface{}, func(), error) { ni := &nullIcon{} f, err := iconer.Icon(ctx, ni) - ni.cancel = f - return ni, err + return ni, f, err }) } @@ -133,7 +128,11 @@ func (i *Icon) SetIconUnsafe(url string) { } func (i *Icon) updateAsync() { - httputil.AsyncImage(i.Image, i.url, imgutil.Prepend(i.resizer, i.procs)...) + if i.size > 0 { + httputil.AsyncImageSized(i.Image, i.url, i.size, i.size, i.procs...) + } else { + httputil.AsyncImage(i.Image, i.url, i.procs...) + } } type ToggleButtonImage struct { diff --git a/internal/ui/rich/label.go b/internal/ui/rich/label.go index 26790cb..100ae9a 100644 --- a/internal/ui/rich/label.go +++ b/internal/ui/rich/label.go @@ -30,7 +30,7 @@ type Label struct { current text.Rich // Reusable primitive. - r gts.Reusable + r *Reusable } var ( @@ -44,12 +44,17 @@ func NewLabel(content text.Rich) *Label { label.SetXAlign(0) // left align label.SetEllipsize(pango.ELLIPSIZE_END) - return &Label{ + l := &Label{ Label: *label, current: content, - // reusable primitive, take reference - r: *gts.NewReusable(), } + + // reusable primitive + l.r = NewReusable(func(nl *nullLabel) { + l.SetLabelUnsafe(nl.Rich) + }) + + return l } // Reset wipes the state to be just after construction. @@ -59,17 +64,11 @@ func (l *Label) Reset() { l.Label.SetText("") } -// swapResource is reserved for internal use only. -func (l *Label) swapResource(v interface{}) { - l.SetLabelUnsafe(v.(*nullLabel).Rich) -} - func (l *Label) AsyncSetLabel(fn LabelerFn, info string) { - gts.AsyncUse(&l.r, l.swapResource, func(ctx context.Context) (interface{}, error) { + AsyncUse(l.r, func(ctx context.Context) (interface{}, func(), error) { nl := &nullLabel{} f, err := fn(ctx, nl) - nl.cancel = f - return nl, err + return nl, f, err }) } diff --git a/internal/ui/rich/rich.go b/internal/ui/rich/rich.go index e9c4e8b..ec85151 100644 --- a/internal/ui/rich/rich.go +++ b/internal/ui/rich/rich.go @@ -17,15 +17,13 @@ func MakeRed(content text.Rich) string { // used for grabbing text without changing state type nullLabel struct { text.Rich - cancel func() } func (n *nullLabel) SetLabel(t text.Rich) { n.Rich = t } // used for grabbing url without changing state type nullIcon struct { - url string - cancel func() + url string } func (i *nullIcon) SetIcon(url string) { i.url = url } diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 0d624aa..0591743 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -21,11 +21,18 @@ type View struct { Services []*Container } +var servicesCSS = primitives.PrepareCSS(` + .services { + background-color: @theme_base_color; + } +`) + func NewView() *View { box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) box.Show() primitives.AddClass(box, "services") + primitives.AttachCSS(box, servicesCSS) sw, _ := gtk.ScrolledWindowNew(nil, nil) sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) diff --git a/internal/ui/service/session/commander/commander.go b/internal/ui/service/session/commander/commander.go index 778e17a..86d5bbb 100644 --- a/internal/ui/service/session/commander/commander.go +++ b/internal/ui/service/session/commander/commander.go @@ -24,6 +24,10 @@ var monospace = primitives.PrepareCSS(` } `) +var commandPadding = primitives.PrepareCSS(` + * { padding: 8px 12px; } +`) + type Session struct { *gtk.Box @@ -101,6 +105,8 @@ func NewSession(cmder cchat.Commander, buf *Buffer) *Session { primitives.AddClass(b, "commander") primitives.AddClass(view, "command-buffer") primitives.AddClass(input, "command-input") + primitives.AttachCSS(view, commandPadding) + primitives.AttachCSS(input, commandPadding) return session } diff --git a/internal/ui/style.css b/internal/ui/style.css index f126fd6..c9aa4ed 100644 --- a/internal/ui/style.css +++ b/internal/ui/style.css @@ -1,17 +1,10 @@ +/* Make CSS more consistent across themes */ headerbar { padding: 0; } +/* Consistent design */ .services button { border-radius: 0; } -.message-content, .message-content text { - background: none; - box-shadow: none; - border: none; -} +popover > box { margin: 6px; } -popover > box { - margin: 6px; -} - -.command-buffer, .command-input { - padding: 8px 12px; -} +/* Hack to fix the input bar being high in Adwaita */ +.input-field * { min-height: 0; }