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 ba1a045..40b6bb3 100644 --- a/internal/ui/messages/container/container.go +++ b/internal/ui/messages/container/container.go @@ -3,7 +3,6 @@ package container import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/gts" - "github.com/diamondburned/cchat-gtk/internal/ui/primitives/autoscroll" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" "github.com/diamondburned/cchat-gtk/internal/ui/messages/message" "github.com/diamondburned/cchat-gtk/internal/ui/service/menu" @@ -47,7 +46,6 @@ type Container interface { DeleteMessageUnsafe(cchat.MessageDelete) Reset() - ScrollToBottom() // AddPresendMessage adds and displays an unsent message. AddPresendMessage(msg input.PresendMessage) PresendGridMessage @@ -59,6 +57,11 @@ type Container interface { type Controller interface { // BindMenu expects the controller to add actioner into the message. BindMenu(GridMessage) + // Bottomed returns whether or not the message scroller is at the bottom. + Bottomed() bool + // 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 @@ -73,8 +76,8 @@ const ColumnSpacing = 10 // GridContainer is an implementation of Container, which allows flexible // message grids. type GridContainer struct { - *autoscroll.ScrolledWindow *GridStore + Controller } // gridMessage w/ required internals @@ -86,16 +89,9 @@ type gridMessage struct { var _ Container = (*GridContainer)(nil) func NewGridContainer(constr Constructor, ctrl Controller) *GridContainer { - store := NewGridStore(constr, ctrl) - - sw := autoscroll.NewScrolledWindow() - sw.Add(store.Grid) - sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS) - sw.Show() - return &GridContainer{ - ScrolledWindow: sw, - GridStore: store, + GridStore: NewGridStore(constr, ctrl), + Controller: ctrl, } } @@ -106,7 +102,7 @@ func (c *GridContainer) CreateMessageUnsafe(msg cchat.MessageCreate) { c.GridStore.CreateMessageUnsafe(msg) // Determine if the user is scrolled to the bottom for cleaning up. - if !c.ScrolledWindow.Bottomed { + if !c.Bottomed() { return } @@ -136,9 +132,3 @@ func (c *GridContainer) UpdateMessage(msg cchat.MessageUpdate) { func (c *GridContainer) DeleteMessage(msg cchat.MessageDelete) { gts.ExecAsync(func() { c.DeleteMessageUnsafe(msg) }) } - -// Reset is not thread-safe. -func (c *GridContainer) Reset() { - c.GridStore.Reset() - c.ScrolledWindow.Bottomed = true -} diff --git a/internal/ui/messages/container/cozy/cozy.go b/internal/ui/messages/container/cozy/cozy.go index 974fdea..686ee74 100644 --- a/internal/ui/messages/container/cozy/cozy.go +++ b/internal/ui/messages/container/cozy/cozy.go @@ -128,7 +128,7 @@ func (c *Container) CreateMessage(msg cchat.MessageCreate) { // Did the handler wipe old messages? It will only do so if the user is // scrolled to the bottom. - if !c.ScrolledWindow.Bottomed { + if !c.Bottomed() { // If we're not at the bottom, then we exit. return } diff --git a/internal/ui/messages/container/grid.go b/internal/ui/messages/container/grid.go index 6499900..000f8c6 100644 --- a/internal/ui/messages/container/grid.go +++ b/internal/ui/messages/container/grid.go @@ -11,7 +11,7 @@ import ( ) type GridStore struct { - Grid *gtk.Grid + *gtk.Grid Construct Constructor Controller Controller @@ -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 b6384fb..157eec6 100644 --- a/internal/ui/messages/input/input.go +++ b/internal/ui/messages/input/input.go @@ -4,6 +4,8 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input/completion" + "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/primitives/scrollinput" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" @@ -20,16 +22,37 @@ type InputView struct { Completer *completion.View } +var textCSS = primitives.PrepareCSS(` + .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") + primitives.AttachCSS(text, textCSS) + // Bind the text event handler to text first. c := completion.New(text) @@ -37,12 +60,7 @@ func NewView(ctrl Controller) *InputView { f := NewField(text, ctrl) f.Show() - // // Connect to the field's revealer. On resize, we want the autocompleter to - // // have the right padding too. - // f.username.Connect("size-allocate", func(w gtk.IWidget) { - // // Set the autocompleter's left margin to be the same. - // c.SetMarginStart(w.ToWidget().GetAllocatedWidth()) - // }) + primitives.AddClass(f, "input-field") return &InputView{f, c} } @@ -57,7 +75,7 @@ func (v *InputView) SetSender(session cchat.Session, sender cchat.ServerMessageS type Field struct { *gtk.Box - username *usernameContainer + Username *username.Container TextScroll *gtk.ScrolledWindow text *gtk.TextView @@ -73,10 +91,14 @@ type Field struct { editingID string // never empty } -const inputmargin = 4 +var scrollinputCSS = primitives.PrepareCSS(` + .scrolled-input { + margin: 5px; + } +`) func NewField(text *gtk.TextView, ctrl Controller) *Field { - username := newUsernameContainer() + username := username.NewContainer() username.Show() buf, _ := text.GetBuffer() @@ -84,14 +106,18 @@ 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) box.Show() field := &Field{ - Box: box, - username: username, + Box: box, + Username: username, + // typing: typing, TextScroll: sw, text: text, buffer: buf, @@ -102,6 +128,13 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field { text.SetFocusVAdjustment(sw.GetVAdjustment()) text.Connect("key-press-event", field.keyDown) + // // Connect to the field's revealer. On resize, we want the autocompleter to + // // have the right padding too. + // f.username.Connect("size-allocate", func(w gtk.IWidget) { + // // Set the autocompleter's left margin to be the same. + // c.SetMarginStart(w.ToWidget().GetAllocatedWidth()) + // }) + return field } @@ -113,7 +146,7 @@ func (f *Field) Reset() { f.UserID = "" f.Sender = nil f.editor = nil - f.username.Reset() + f.Username.Reset() // reset the input f.buffer.Delete(f.buffer.GetBounds()) @@ -123,7 +156,7 @@ func (f *Field) Reset() { // disabled. Reset() should be called first. func (f *Field) SetSender(session cchat.Session, sender cchat.ServerMessageSender) { // Update the left username container in the input. - f.username.Update(session, sender) + f.Username.Update(session, sender) f.UserID = session.ID() // Set the sender. diff --git a/internal/ui/messages/input/send.go b/internal/ui/messages/input/send.go index 5d3e9ec..8ff94c9 100644 --- a/internal/ui/messages/input/send.go +++ b/internal/ui/messages/input/send.go @@ -58,9 +58,9 @@ func (f *Field) sendInput() { f.SendMessage(SendMessageData{ time: time.Now().UTC(), content: text, - author: f.username.GetLabel(), + author: f.Username.GetLabel(), authorID: f.UserID, - authorURL: f.username.GetIconURL(), + authorURL: f.Username.GetIconURL(), nonce: f.generateNonce(), }) } diff --git a/internal/ui/messages/input/username.go b/internal/ui/messages/input/username/username.go similarity index 78% rename from internal/ui/messages/input/username.go rename to internal/ui/messages/input/username/username.go index f2a973d..89ca69b 100644 --- a/internal/ui/messages/input/username.go +++ b/internal/ui/messages/input/username/username.go @@ -1,9 +1,10 @@ -package input +package username 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" @@ -23,7 +24,7 @@ func init() { )) } -type usernameContainer struct { +type Container struct { *gtk.Revealer main *gtk.Box avatar *rich.Icon @@ -31,11 +32,17 @@ type usernameContainer struct { } var ( - _ cchat.LabelContainer = (*usernameContainer)(nil) - _ cchat.IconContainer = (*usernameContainer)(nil) + _ cchat.LabelContainer = (*Container)(nil) + _ cchat.IconContainer = (*Container)(nil) ) -func newUsernameContainer() *usernameContainer { +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) avatar.Show() @@ -47,13 +54,12 @@ func newUsernameContainer() *usernameContainer { 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() + primitives.AddClass(box, "username-view") + primitives.AttachCSS(box, usernameCSS) + rev, _ := gtk.RevealerNew() rev.SetRevealChild(false) rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) @@ -65,7 +71,7 @@ func newUsernameContainer() *usernameContainer { // thread. currentRevealer = rev.SetRevealChild - return &usernameContainer{ + return &Container{ Revealer: rev, main: box, avatar: avatar, @@ -73,24 +79,24 @@ func newUsernameContainer() *usernameContainer { } } -func (u *usernameContainer) SetRevealChild(reveal bool) { +func (u *Container) 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 { +func (u *Container) shouldReveal() bool { return !u.label.GetLabel().Empty() && showUser } -func (u *usernameContainer) Reset() { +func (u *Container) 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) { +func (u *Container) Update(session cchat.Session, sender cchat.ServerMessageSender) { // Set the fallback username. u.label.SetLabelUnsafe(session.Name()) // Reveal the name if it's not empty. @@ -108,12 +114,12 @@ func (u *usernameContainer) Update(session cchat.Session, sender cchat.ServerMes } // GetLabel is not thread-safe. -func (u *usernameContainer) GetLabel() text.Rich { +func (u *Container) GetLabel() text.Rich { return u.label.GetLabel() } // SetLabel is thread-safe. -func (u *usernameContainer) SetLabel(content text.Rich) { +func (u *Container) SetLabel(content text.Rich) { gts.ExecAsync(func() { u.label.SetLabelUnsafe(content) @@ -123,7 +129,7 @@ func (u *usernameContainer) SetLabel(content text.Rich) { } // SetIcon is thread-safe. -func (u *usernameContainer) SetIcon(url string) { +func (u *Container) SetIcon(url string) { gts.ExecAsync(func() { u.avatar.SetIconUnsafe(url) @@ -136,6 +142,6 @@ func (u *usernameContainer) SetIcon(url string) { } // GetIconURL is not thread-safe. -func (u *usernameContainer) GetIconURL() string { +func (u *Container) GetIconURL() string { return u.avatar.URL() } diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index 2f450b2..933b742 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -81,7 +81,7 @@ func NewEmptyContainer() *GenericContainer { ts.SetEllipsize(pango.ELLIPSIZE_MIDDLE) ts.SetXAlign(1) // right align ts.SetVAlign(gtk.ALIGN_END) - ts.SetSelectable(true) + // ts.SetSelectable(true) ts.Show() user, _ := gtk.LabelNew("") @@ -90,7 +90,7 @@ func NewEmptyContainer() *GenericContainer { user.SetLineWrapMode(pango.WRAP_WORD_CHAR) user.SetXAlign(1) // right align user.SetVAlign(gtk.ALIGN_START) - user.SetSelectable(true) + // user.SetSelectable(true) user.Show() content, _ := gtk.LabelNew("") diff --git a/internal/ui/messages/typing/dots.go b/internal/ui/messages/typing/dots.go new file mode 100644 index 0000000..efda33b --- /dev/null +++ b/internal/ui/messages/typing/dots.go @@ -0,0 +1,56 @@ +package typing + +import ( + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/gotk3/gotk3/gtk" +) + +var dotsCSS = primitives.PrepareCSS(` + @keyframes breathing { + 0% { opacity: 0.66; } + 100% { opacity: 0.12; } + } + + label { + animation: breathing 800ms infinite alternate; + } + + label:nth-child(1) { + animation-delay: 000ms; + } + + label:nth-child(2) { + animation-delay: 150ms; + } + + label:nth-child(3) { + animation-delay: 300ms; + } +`) + +const breathingChar = "●" + +func NewDots() *gtk.Box { + c1, _ := gtk.LabelNew(breathingChar) + c1.Show() + c2, _ := gtk.LabelNew(breathingChar) + c2.Show() + c3, _ := gtk.LabelNew(breathingChar) + c3.Show() + + b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + b.Add(c1) + b.Add(c2) + b.Add(c3) + + primitives.AddClass(b, "breathing-dots") + + primitives.AttachCSS(c1, dotsCSS) + primitives.AttachCSS(c1, smallfonts) + primitives.AttachCSS(c2, dotsCSS) + primitives.AttachCSS(c2, smallfonts) + primitives.AttachCSS(c3, dotsCSS) + primitives.AttachCSS(c3, smallfonts) + + return b +} 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 new file mode 100644 index 0000000..1dafd9f --- /dev/null +++ b/internal/ui/messages/typing/typing.go @@ -0,0 +1,118 @@ +package typing + +import ( + "strings" + + "github.com/diamondburned/cchat" + "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" +) + +var typingIndicatorCSS = primitives.PrepareCSS(` + .typing-indicator { + margin: 0 6px; + border-radius: 6px 6px 0 0; + color: alpha(@theme_fg_color, 0.8); + background-color: @theme_base_color; + } +`) + +var smallfonts = primitives.PrepareCSS(` + * { font-size: 0.9em; } +`) + +type Container struct { + *gtk.Revealer + state *State +} + +func New() *Container { + d := NewDots() + d.Show() + + 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, 4) + b.PackStart(l, true, true, 0) + b.Show() + + r, _ := gtk.RevealerNew() + r.SetTransitionDuration(50) + r.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_CROSSFADE) + r.SetRevealChild(false) + r.Add(b) + + 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, + } +} + +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 91beb22..c53d85b 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -13,6 +13,9 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/messages/container/cozy" "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" "github.com/pkg/errors" @@ -37,7 +40,11 @@ type View struct { *sadface.FaceView Box *gtk.Box + Scroller *autoscroll.ScrolledWindow InputView *input.InputView + + MsgBox *gtk.Box + Typing *typing.Container Container container.Container contType int // msgIndex @@ -47,16 +54,36 @@ type View struct { func NewView() *View { view := &View{} - view.InputView = input.NewView(view) + view.Typing = typing.New() + view.Typing.Show() - view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - view.Box.PackEnd(view.InputView, false, false, 0) - view.Box.Show() + view.MsgBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2) + view.MsgBox.PackEnd(view.Typing, false, false, 0) + view.MsgBox.Show() // Create the message container, which will use PackEnd to add the widget on - // TOP of the input view. + // TOP of the typing indicator. view.createMessageContainer() + view.Scroller = autoscroll.NewScrolledWindow() + view.Scroller.Add(view.MsgBox) + view.Scroller.Show() + + // A separator to go inbetween. + sep, _ := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL) + sep.Show() + + view.InputView = input.NewView(view) + view.InputView.Show() + + view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + view.Box.PackStart(view.Scroller, true, true, 0) + view.Box.PackStart(sep, false, false, 0) + 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() @@ -68,7 +95,7 @@ func NewView() *View { func (v *View) createMessageContainer() { // Remove the old message container. if v.Container != nil { - v.Box.Remove(v.Container) + v.MsgBox.Remove(v.Container) } // Update the container type. @@ -80,15 +107,21 @@ func (v *View) createMessageContainer() { } // Add the new message container. - v.Box.PackEnd(v.Container, true, true, 0) + v.MsgBox.PackEnd(v.Container, true, true, 0) } +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. + // Keep the scroller at the bottom. + v.Scroller.Bottomed = true + // Recreate the message container if the type is different. if v.contType != msgIndex { v.createMessageContainer() @@ -135,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 }) } @@ -156,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/primitives/completion/completer.go b/internal/ui/primitives/completion/completer.go index ac83e93..6cfaff5 100644 --- a/internal/ui/primitives/completion/completer.go +++ b/internal/ui/primitives/completion/completer.go @@ -48,7 +48,7 @@ func NewCompleter(input *gtk.TextView, ctrl Completeable) *Completer { input.Connect("key-press-event", KeyDownHandler(l, input.GrabFocus)) ibuf, _ := input.GetBuffer() - ibuf.Connect("changed", func() { + ibuf.Connect("end-user-action", func() { t, v := State(ibuf) c.Cursor = v c.Words, c.Index = split.SpaceIndexed(t, v) 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; }