Refactor server browser (message list not working)

This commit is contained in:
diamondburned 2021-03-29 14:46:52 -07:00
parent b822f63c18
commit e73f9a099b
60 changed files with 2275 additions and 1967 deletions

18
go.mod
View File

@ -1,26 +1,20 @@
module github.com/diamondburned/cchat-gtk
go 1.14
go 1.16
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20210223232102-0ce0ad615fb2
// replace github.com/diamondburned/cchat-discord => ../cchat-discord
// replace github.com/diamondburned/gotk3-tcmalloc => ../../gotk3-tcmalloc
// replace github.com/diamondburned/ningen/v2 => ../../ningen
// replace github.com/diamondburned/arikawa/v2 => ../../arikawa
replace github.com/diamondburned/cchat-discord => ../cchat-discord
require (
github.com/Xuanwo/go-locale v1.0.0
github.com/alecthomas/chroma v0.7.3
github.com/diamondburned/cchat v0.3.17
github.com/diamondburned/cchat-discord v0.0.0-20210107014523-4fefdf1b9332
github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db
github.com/diamondburned/cchat v0.6.4
github.com/diamondburned/cchat-discord v0.0.0-20210326063953-deb4ccb32bff
github.com/diamondburned/gspell v0.0.0-20201229064336-e43698fd5828
github.com/diamondburned/handy v0.0.0-20201229063418-ec23c1370374
github.com/diamondburned/handy v0.0.0-20210329054445-387ad28eb2c2
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972
github.com/disintegration/imaging v1.6.2
github.com/goodsign/monday v1.0.0
github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194
github.com/gotk3/gotk3 v0.5.3-0.20210326060404-6328e5470ece
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79
github.com/peterbourgon/diskv v2.0.1+incompatible
github.com/pkg/errors v0.9.1

101
go.sum
View File

@ -6,16 +6,24 @@ cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxK
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.53.0 h1:MZQCQQaRwOrAcuKjiHWHrgKykt4fZyuwF2dtiG3fGW8=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0 h1:sAbMqjY1PEQKZBWfbu6Y6bsupJ9c4QdHnzg/VvYTLcE=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/datastore v1.0.0 h1:Kt+gOPPp2LEPWp8CSfxhsM8ik9CcyE/gYu+0r+RnZvM=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0 h1:9/vpR43S4aJaROxqQHQ3nH9lfyKKV0dC3vOmnw8ebQQ=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0 h1:RPUcBvDeYgQFMfQu1eBMq6piD1SXmLH+vK3qjewZPus=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Pallinder/go-randomdata v1.2.0 h1:DZ41wBchNRb/0GfsePLiSwb0PHZmT67XY00lCDlaYPg=
github.com/Pallinder/go-randomdata v1.2.0/go.mod h1:yHmJgulpD2Nfrm0cR9tI/+oAgRqCQQixsA8HyRZfV9Y=
@ -27,19 +35,26 @@ github.com/alecthomas/chroma v0.7.3 h1:NfdAERMy+esYQs8OXk0I868/qDxxCEo7FMz1WIqMA
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 h1:Y0ZBCHAvHhTHw7FFJ2FzCAAG4pkbTgA45nc7BpMhDNk=
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 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/danieljoos/wincred v1.0.2 h1:zf4bhty2iLuwgjgpraD2E9UbvO+fe54XXGJbOwe23fU=
github.com/danieljoos/wincred v1.0.2/go.mod h1:SnuYRW9lp1oJrZX/dXJqr0cPK5gYXqx3EJbmjhLdK9U=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/dave/jennifer v1.4.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg=
github.com/dave/jennifer v1.4.1 h1:XyqG6cn5RQsTj3qlWQTKlRGAyrTcsk1kUmWdZBzRjDw=
github.com/dave/jennifer v1.4.1/go.mod h1:7jEdnm+qBcxl8PC0zyp7vxcpSRnzXSt9r39tpTVGlwA=
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=
@ -60,12 +75,28 @@ github.com/diamondburned/arikawa/v2 v2.0.0-20210105213913-8a213759164c h1:6n1EqF
github.com/diamondburned/arikawa/v2 v2.0.0-20210105213913-8a213759164c/go.mod h1:e+lhS20ni2luFEU06Pc8paCxgZL99/RZb77dOC82CF0=
github.com/diamondburned/arikawa/v2 v2.0.0-20210106050916-771591e5eb65 h1:foJMpT+BAoASVzDj9WDxNp6/OxnWnQ/uUHk2DXARP/Y=
github.com/diamondburned/arikawa/v2 v2.0.0-20210106050916-771591e5eb65/go.mod h1:e+lhS20ni2luFEU06Pc8paCxgZL99/RZb77dOC82CF0=
github.com/diamondburned/arikawa/v2 v2.0.5 h1:X/jPfeEFj/Hzk26kudNJqtVkQb3vnh3tAjEF1mY7GK8=
github.com/diamondburned/arikawa/v2 v2.0.5/go.mod h1:e+lhS20ni2luFEU06Pc8paCxgZL99/RZb77dOC82CF0=
github.com/diamondburned/cchat v0.3.11 h1:C1f9Tp7Kz3t+T1SlepL1RS7b/kACAKWAIZXAgJEpCHg=
github.com/diamondburned/cchat v0.3.11/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat v0.3.15 h1:BJf8ZiRtDWTGMtQ3QqjNU0H+784WSrkJEpFGkKY5gEw=
github.com/diamondburned/cchat v0.3.15/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat v0.3.17 h1:pGwas8Y0SBU7yg4EQ/MvrbqZhrnRhPBYm1AiRsL147s=
github.com/diamondburned/cchat v0.3.17/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat v0.5.4 h1:0N/H+zs4T6YBOP5dtUnJ+paZfq06772b5CXRw7bWx7U=
github.com/diamondburned/cchat v0.5.4/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat v0.5.5 h1:YgI98ID9UQAgHbLFEyHQ2qcAJ5A2HcVZaq+A26njAQo=
github.com/diamondburned/cchat v0.5.5/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat v0.5.6 h1:U6f41fEtXgs+fdmk0q9ouQsfzY7nQzp9N8fV0FfEbv0=
github.com/diamondburned/cchat v0.5.6/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat v0.6.0 h1:XVql8slOho9FUxuJm/I7/XWr5l1XgxLc5SvcvI+ybp4=
github.com/diamondburned/cchat v0.6.0/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat v0.6.1 h1:BFpY2Nvnut1/X+0dhL8STK80RVmfEJLmf4ppTJX2ADg=
github.com/diamondburned/cchat v0.6.1/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat v0.6.2 h1:H0Hyd2irpKlTR03ulm6IMy7RVorPtvQS5X0kTn26Up8=
github.com/diamondburned/cchat v0.6.2/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat v0.6.4 h1:CFh6eFNE+SQ7IOjb1QDcgTgv/I7W2k6E7d3n8qJ+ZrQ=
github.com/diamondburned/cchat v0.6.4/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI=
github.com/diamondburned/cchat-discord v0.0.0-20201220054426-918719599f2d h1:n61DxLdX7nPj7KA1N/azaR8wa0pnDBDT6Yi1seOsBWM=
github.com/diamondburned/cchat-discord v0.0.0-20201220054426-918719599f2d/go.mod h1:pvp1TOHK7NUM+GDRPixQGsKyCSbGYhiseK2jM+1I+ms=
github.com/diamondburned/cchat-discord v0.0.0-20201220081640-288591a535af h1:pTdxsrVSYCdraGormbu1t8uQJMe/OD/ZIz9KljDWAvc=
@ -98,6 +129,22 @@ github.com/diamondburned/cchat-discord v0.0.0-20210107010729-8edbbcc24992 h1:njL
github.com/diamondburned/cchat-discord v0.0.0-20210107010729-8edbbcc24992/go.mod h1:g+RqSLt/ccZZVZbsukOiCwQCqKM/9HfHMJVboIUW+tU=
github.com/diamondburned/cchat-discord v0.0.0-20210107014523-4fefdf1b9332 h1:nGOfPHgujDdvltCkzMiKhldlo9qLyflT8weG+xWOi2E=
github.com/diamondburned/cchat-discord v0.0.0-20210107014523-4fefdf1b9332/go.mod h1:g+RqSLt/ccZZVZbsukOiCwQCqKM/9HfHMJVboIUW+tU=
github.com/diamondburned/cchat-discord v0.0.0-20210318171231-71f2e1570400 h1:RwU9SqiWT/iqrosB0Nz54t1CRjMTP/XhZYUIT+jirgE=
github.com/diamondburned/cchat-discord v0.0.0-20210318171231-71f2e1570400/go.mod h1:OoF9yCU2eZmC8fNh7rJ+xaANLPsH8EWzFR7QG13UWLM=
github.com/diamondburned/cchat-discord v0.0.0-20210318194307-9dc28023dac5 h1:Zh3qkQmJowAdE6G8/hQJ8TLnTo03IDbCW9Lq/mIV4Ko=
github.com/diamondburned/cchat-discord v0.0.0-20210318194307-9dc28023dac5/go.mod h1:hkAXdLdcTYb87ZTBvfEopEavbs8x7xPygJGpK5Z2Es8=
github.com/diamondburned/cchat-discord v0.0.0-20210318212724-ba43f691bccc h1:sRXWos0PGQ7JrASu35AnF6tsT0wN3ZC9r5OTVNusr5k=
github.com/diamondburned/cchat-discord v0.0.0-20210318212724-ba43f691bccc/go.mod h1:ZFzQ34q1tr3ci5HRyqzbmoth7HMsXO9iTXImc112Yeg=
github.com/diamondburned/cchat-discord v0.0.0-20210320000032-f3e0d38c382d h1:tBcY8AqCXDq+hicxQGuFj8HTWt+3YnjfinexNbniWSA=
github.com/diamondburned/cchat-discord v0.0.0-20210320000032-f3e0d38c382d/go.mod h1:H5UuKeE097pqWKvWwdwEBIByO6chZgTva1qsiE8Jm4Q=
github.com/diamondburned/cchat-discord v0.0.0-20210320051719-18eb6081460f h1:alREboGThGvNzYFq+bqdhrntYvpClvkMAsnc3xblMZU=
github.com/diamondburned/cchat-discord v0.0.0-20210320051719-18eb6081460f/go.mod h1:yn0CA0/JyIs3U8v+B487DQWg2037ePvqcTZS1CZQGVA=
github.com/diamondburned/cchat-discord v0.0.0-20210325233237-f9814bf4101b h1:l35x20NdcMWnEbGyNSS0K+PDXbTclCMCK2n0hU8pV/U=
github.com/diamondburned/cchat-discord v0.0.0-20210325233237-f9814bf4101b/go.mod h1:zbm+BpkQOMD6s87x4FrP3lTt9ddJLWTTPXyMROT+LZs=
github.com/diamondburned/cchat-discord v0.0.0-20210326063215-9eb392a95413 h1:r6PAQX0PhUXVB7Uuxd9BNNOvNXBs73DUIlNSjDXHqFk=
github.com/diamondburned/cchat-discord v0.0.0-20210326063215-9eb392a95413/go.mod h1:zbm+BpkQOMD6s87x4FrP3lTt9ddJLWTTPXyMROT+LZs=
github.com/diamondburned/cchat-discord v0.0.0-20210326063953-deb4ccb32bff h1:p5XYPavnJ89wrJAf4ns6f1OfHQz5NMU9uXlX3EiKdfU=
github.com/diamondburned/cchat-discord v0.0.0-20210326063953-deb4ccb32bff/go.mod h1:zbm+BpkQOMD6s87x4FrP3lTt9ddJLWTTPXyMROT+LZs=
github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db h1:VQI2PdbsdsRJ7d669kp35GbCUO44KZ0Xfqdu4o/oqVg=
github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db/go.mod h1:M87kjNzWVPlkZycFNzpGPKQXzkHNnZphuwMf3E9ckgc=
github.com/diamondburned/gotk3 v0.0.0-20201209182406-e7291341a091 h1:lQpSWzbi3rQf66aMSip/rIypasIFwqCqF0Wfn5og6gw=
@ -138,6 +185,10 @@ github.com/diamondburned/handy v0.0.0-20200829011954-4667e7a918f4 h1:qF5VHC35+Gy
github.com/diamondburned/handy v0.0.0-20200829011954-4667e7a918f4/go.mod h1:V0qyhW4v6KPFwtDpXdBm5aWH7zWEyrzZpcB6MPnKArQ=
github.com/diamondburned/handy v0.0.0-20201229063418-ec23c1370374 h1:1KLPz5mbYF7t3ajrK55alkDcbjWc+7aQFjKzV9qJRN4=
github.com/diamondburned/handy v0.0.0-20201229063418-ec23c1370374/go.mod h1:9EiMOAKWhEFoVnFLTco2v0v0rJn855YAHDDa2bL8urU=
github.com/diamondburned/handy v0.0.0-20210317194712-ec07079c938b h1:vcu2dF4RyJM+w3nRgNsbOnGjdf/Bxd5etjyNJJ8trvY=
github.com/diamondburned/handy v0.0.0-20210317194712-ec07079c938b/go.mod h1:WsMP6/3+U5b8ZmIn7MilCXHhhph1uvqkBRr86Fubm+k=
github.com/diamondburned/handy v0.0.0-20210329054445-387ad28eb2c2 h1:fIQVMaOTWAcv6nThIyzKuyAO/GccwtVYDovnEpFFXGs=
github.com/diamondburned/handy v0.0.0-20210329054445-387ad28eb2c2/go.mod h1:+edD3Anr1+QF0HjgOUFJRkPiVAt0mj3IczvZc+yIuvo=
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 h1:OWxllHbUptXzDias6YI4MM0R3o50q8MfhkkwVIlfiNo=
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
github.com/diamondburned/ningen/v2 v2.0.0-20201219070301-15610044db9a h1:w8CWPYiwH9p2XGlHHeTqRWx7e8CJJLN8i4orAkOa27Y=
@ -160,25 +211,33 @@ github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473 h1:4cmBvAEBNJaGARUEs3/suWRyfyBfhf7I60WBZq+bv2w=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72 h1:b+9H1GAsx5RsjvDFLoS5zkNBzIQMuVKUYQDmxU3N5XE=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4=
github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0 h1:Rd1kQnQu0Hq3qvJppYSG0HtP+f5LPPUiDswTLiEegLg=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/goodsign/monday v1.0.0 h1:Yyk/s/WgudMbAJN6UWSU5xAs8jtNewfqtVblAlw0yoc=
github.com/goodsign/monday v1.0.0/go.mod h1:r4T4breXpoFwspQNM+u2sLxJb2zyTaxVGqUfTBjWOu8=
@ -188,13 +247,18 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12 h1:TgXhFz35pKlZuUz1pNlOKk1UCSXPpuUIc144Wd7SxCA=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
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=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@ -202,12 +266,21 @@ github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotk3/gotk3 v0.4.0/go.mod h1:Eew3QBwAOBTrfFFDmsDE5wZWbcagBL1NUslj1GhRveo=
github.com/gotk3/gotk3 v0.4.1-0.20200321173312-c4ae30c61acd/go.mod h1:Eew3QBwAOBTrfFFDmsDE5wZWbcagBL1NUslj1GhRveo=
github.com/gotk3/gotk3 v0.5.3-0.20210311170413-be85685ca6db h1:6DrB5/BH3GK8Wg6VZ+C2NqQQdMof0iHifNqxixBKefk=
github.com/gotk3/gotk3 v0.5.3-0.20210311170413-be85685ca6db/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/gotk3/gotk3 v0.5.3-0.20210326060404-6328e5470ece h1:97BTiWMRSBA/fkg4irBHzQPv8E30dIUThuAtQn/MK6Q=
github.com/gotk3/gotk3 v0.5.3-0.20210326060404-6328e5470ece/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6 h1:UDMh68UUwekSh5iP2OMhRRZJiiBccgV7axzUG8vi56c=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o=
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=
@ -215,9 +288,11 @@ github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q
github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg=
github.com/kawasin73/umutex v0.2.1 h1:Onkzz3LKs1HThskVwdhhBocqdRQqwCZ03quDJzuPzPo=
github.com/kawasin73/umutex v0.2.1/go.mod h1:A02N2muKVFMvFlp5c+hBycgdH964YtieGs+7mYB16NU=
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
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 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
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=
@ -225,11 +300,13 @@ github.com/lithammer/fuzzysearch v1.1.1 h1:8F9OAV2xPuYblToVohjanztdnPjbtA0MLgMvD
github.com/lithammer/fuzzysearch v1.1.1/go.mod h1:H2bng+w5gsR7NlfIJM8ElGZI0sX6C/9uzGqicVXGU6c=
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw=
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/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
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=
@ -238,8 +315,11 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
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=
@ -261,11 +341,14 @@ github.com/twmb/murmur3 v1.1.3 h1:D83U0XYKcHRYwYIpBKf3Pks91Z0Byda/9SJ8B6EMRcA=
github.com/twmb/murmur3 v1.1.3/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/yuin/goldmark v1.1.30 h1:j4d4Lw3zqZelDhBksEo3BnWg9xhXRQGJPPSL6OApZjI=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.2 h1:YjHC5TgyMmHpicTgEqDN0Q96Xo8K6tLXPnmNOHXCgs0=
github.com/yuin/goldmark v1.3.2/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717 h1:3M/uUZajYn/082wzUajekePxpUAZhMTfXvI9R+26SJ0=
github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717/go.mod h1:RaxNwUITJaHVdQ0VC7pELPZ3tOWn13nr0gZMZEhpVU0=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3 h1:8sGtKOrtQqkN1bp2AtX+misvLIlOmsEsNd+9NIcPEm8=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go4.org v0.0.0-20200411211856-f5505b9728dd h1:BNJlw5kRTzdmyfh5U8F93HA2OwkP7ZGwA51eJ/0wKOU=
go4.org v0.0.0-20200411211856-f5505b9728dd/go.mod h1:CIiUVy99QCPfoE13bO4EZaz5GZMZXMSBGhxRdsvzbkg=
@ -273,6 +356,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -281,6 +365,7 @@ golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm0
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd h1:zkO/Lhoka23X63N9OSzpSeROEUQ5ODw47tM3YWjygbs=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@ -294,12 +379,15 @@ golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367 h1:0IiAsCRByjO2QjX7ZPkw5oU9x+n1YqRL802rjC0c3Aw=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -314,11 +402,13 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0 h1:MsuvTghUPjX762sGLnGsxC3HM0B5r83wEtYcYR8/vRs=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -383,9 +473,11 @@ golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56 h1:DFtSed2q3HtNuVazwVDZ4nSRS/JrZEig0gz2BY4VNrg=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
@ -394,11 +486,13 @@ google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEn
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0 h1:0q95w+VuFtv4PAx4PZVQdBMmYbaCHbnfKaEiDIcVyag=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@ -412,6 +506,7 @@ google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvx
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce h1:1mbrb1tUU+Zmt5C94IGKADBTJZjZXAd+BubWi7r9EiI=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@ -419,11 +514,13 @@ google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ij
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1 h1:zvIju4sqAGvwKspUQOhwnpcqSbzi7/H6QomNNjTL4sk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@ -433,7 +530,11 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,6 +3,8 @@ package icons
import (
"log"
_ "embed"
"github.com/gotk3/gotk3/cairo"
"github.com/gotk3/gotk3/gdk"
)
@ -10,6 +12,12 @@ import (
// static assets
// var assets = map[string]*gdk.Pixbuf{}
//go:embed cchat_256.png
var __cchat_256 []byte
//go:embed cchat-variant2_256.png
var __cchat_variant2_256 []byte
func Logo256Variant2(sz, scale int) *cairo.Surface {
return mustSurface(loadPixbuf(__cchat_variant2_256, sz, scale), scale)
}

View File

@ -1,7 +1,6 @@
package httputil
import (
"bufio"
"context"
"io"
"mime"
@ -9,7 +8,6 @@ import (
"net/url"
"path"
"strings"
"sync"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
@ -21,32 +19,6 @@ import (
"github.com/pkg/errors"
)
// bufferPool provides a sync.Pool of *bufio.Writers. This is used to reduce the
// amount of cgo calls, by writing bytes in larger chunks.
//
// Technically, httpcache already wraps its cached reader around a bufio.Reader,
// but we have no control over the buffer size.
var bufferPool = sync.Pool{
New: func() interface{} {
// Allocate a 512KB buffer by default.
const defaultBufSz = 512 * 1024
return bufio.NewWriterSize(nil, defaultBufSz)
},
}
func bufferedWriter(w io.Writer) *bufio.Writer {
buf := bufferPool.Get().(*bufio.Writer)
buf.Reset(w)
return buf
}
func returnBufferedWriter(buf *bufio.Writer) {
// Unreference the internal reader.
buf.Reset(nil)
bufferPool.Put(buf)
}
type ImageContainer interface {
primitives.Connector
@ -93,9 +65,19 @@ func AsyncImage(ctx context.Context,
scale = surfaceContainer.GetScaleFactor()
}
ctx = primitives.HandleDestroyCtx(ctx, img)
ctx, cancel := context.WithCancel(ctx)
cancelHandle := img.Connect("destroy", func() {
log.Println("image destroyed, canceling")
cancel()
})
go func() {
// Ensure the contexts are cleaned up in the main thread.
defer gts.ExecAsync(func() {
img.HandlerDisconnect(cancelHandle)
cancel()
})
// Try and guess the MIME type from the URL.
mimeType := mime.TypeByExtension(urlExt(imageURL))
@ -143,20 +125,11 @@ func AsyncImage(ctx context.Context,
l.Connect("area-prepared", load)
l.Connect("area-updated", load)
// Borrow a buffered writer and return it at the end.
bufWriter := bufferedWriter(l)
defer returnBufferedWriter(bufWriter)
if err := downloadImage(r.Body, bufWriter, procs, isGIF); err != nil {
if err := downloadImage(r.Body, l, procs, isGIF); err != nil {
log.Error(errors.Wrapf(err, "failed to download %q", imageURL))
// Force close after downloading.
}
if err := bufWriter.Flush(); err != nil {
log.Error(errors.Wrapf(err, "failed to flush writer for %q", imageURL))
// Force close after downloading.
}
if err := l.Close(); err != nil {
log.Error(errors.Wrapf(err, "failed to close pixbuf loader for %q", imageURL))
}
@ -206,11 +179,13 @@ func loadFn(ctx context.Context, img ImageContainer, isGIF bool) func(l *gdk.Pix
}
func execIfCtx(ctx context.Context, fn func()) {
gts.ExecLater(func() {
if ctx.Err() == nil {
fn()
}
})
if ctx.Err() == nil {
gts.ExecAsync(func() {
if ctx.Err() == nil {
fn()
}
})
}
}
func downloadImage(src io.Reader, dst io.Writer, p []imgutil.Processor, isGIF bool) error {

View File

@ -17,16 +17,16 @@ var store = driver.NewStore(
)
type Session struct {
ID string
ID cchat.ID
// Metadata.
Name string
Data map[string]string
}
// ConvertSession attempts to get the session data from the given cchat session.
// It returns nil if it can't do it.
func ConvertSession(ses cchat.Session) *Session {
var name = ses.Name().Content
func ConvertSession(ses cchat.Session, name string) *Session {
saver := ses.AsSessionSaver()
if saver == nil {
return nil
@ -45,28 +45,60 @@ func ConvertSession(ses cchat.Session) *Session {
}
}
func SaveSessions(service cchat.Service, sessions []Session) {
if err := store.Set(service.Name().Content, sessions); err != nil {
log.Warn(errors.Wrap(err, "Error saving session"))
}
}
// RestoreSessions restores all sessions of the service asynchronously, then
// calls the auth callback inside the Gtk main thread.
func RestoreSessions(service cchat.Service) (sessions []Session) {
// Ignore the error, it's not important.
if err := store.Get(service.Name().Content, &sessions); err != nil {
log.Warn(err)
}
return
}
func RestoreSession(service cchat.Service, id string) *Session {
var sessions = RestoreSessions(service)
for _, session := range sessions {
if session.ID == id {
// RestoreSession restores a single session.
func RestoreSession(svc cchat.Service, sessionID cchat.ID) *Session {
service := Restore(svc)
for _, session := range service.Sessions {
if session.ID == sessionID {
return &session
}
}
return nil
}
// Sessions is a list of sessions within a keyring. It provides an abstract way
// to save sessions with order.
type Service struct {
ID cchat.ID
Sessions []Session
}
// NewService creates a new service.
func NewService(svc cchat.Service, cap int) Service {
return Service{
ID: svc.ID(),
Sessions: make([]Session, 0, cap),
}
}
// Restore restores all sessions of the service asynchronously, then calls the
// auth callback inside the GTK main thread.
func Restore(svc cchat.Service) Service {
var sessions []Session
// Ignore the error, it's not important.
if err := store.Get(svc.ID(), &sessions); err != nil {
log.Warn(err)
}
return Service{
ID: svc.ID(),
Sessions: sessions,
}
}
// Add adds a session into the sessions list.
func (svc *Service) Add(ses cchat.Session, name string) {
s := ConvertSession(ses, name)
if s == nil {
return
}
svc.Sessions = append(svc.Sessions, *s)
}
// Save saves the sessions into the keyring.
func (svc Service) Save() {
if err := store.Set(svc.ID, svc.Sessions); err != nil {
log.Warn(errors.Wrap(err, "Error saving session"))
}
}

View File

@ -56,12 +56,12 @@ func SaveToFile(file string, v []byte) error {
f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_SYNC|os.O_TRUNC, 0644)
if err != nil {
return errors.Wrap(err, "Failed to open file")
return errors.Wrap(err, "failed to open file")
}
defer f.Close()
if _, err := f.Write(v); err != nil {
return errors.Wrap(err, "Failed to write")
return errors.Wrap(err, "failed to write")
}
return nil
@ -72,14 +72,19 @@ func SaveToFile(file string, v []byte) error {
func MarshalToFile(file string, from interface{}) error {
file = filepath.Join(DirPath(), file)
dir := filepath.Dir(file)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return errors.Wrap(err, "failed to create config dir")
}
f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_SYNC|os.O_TRUNC, 0644)
if err != nil {
return errors.Wrap(err, "Failed to open file")
return errors.Wrap(err, "failed to open file")
}
defer f.Close()
if err := PrettyMarshal(f, from); err != nil {
return errors.Wrap(err, "Failed to marshal given struct")
return errors.Wrap(err, "failed to marshal given struct")
}
return nil

View File

@ -4,7 +4,7 @@ import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
"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/primitives"
)
@ -12,40 +12,31 @@ type Container struct {
*container.ListContainer
}
var _ container.Container = (*Container)(nil)
func NewContainer(ctrl container.Controller) *Container {
c := container.NewListContainer(ctrl, constructors)
c := container.NewListContainer(ctrl)
primitives.AddClass(c, "compact-container")
return &Container{c}
}
func (c *Container) NewPresendMessage(state *message.PresendState) container.PresendMessageRow {
msg := WrapPresendMessage(state)
c.AddMessage(msg)
return msg
}
func (c *Container) CreateMessage(msg cchat.MessageCreate) {
gts.ExecAsync(func() {
c.ListContainer.CreateMessageUnsafe(msg)
c.ListContainer.CleanMessages()
msg := WrapMessage(message.NewState(msg))
c.ListContainer.AddMessage(msg)
})
}
func (c *Container) UpdateMessage(msg cchat.MessageUpdate) {
gts.ExecAsync(func() { c.ListContainer.UpdateMessageUnsafe(msg) })
gts.ExecAsync(func() { container.UpdateMessage(c, msg) })
}
func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
gts.ExecAsync(func() { c.ListContainer.DeleteMessageUnsafe(msg) })
}
var constructors = container.Constructor{
NewMessage: newMessage,
NewPresendMessage: newPresendMessage,
}
func newMessage(
msg cchat.MessageCreate, _ container.MessageRow) container.MessageRow {
return NewMessage(msg)
}
func newPresendMessage(
msg input.PresendMessage, _ container.MessageRow) container.PresendMessageRow {
return NewPresendMessage(msg)
gts.ExecAsync(func() { c.PopMessage(msg.ID()) })
}

View File

@ -3,14 +3,13 @@ package compact
import (
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/humanize"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
"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/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
)
@ -29,41 +28,32 @@ var messageAuthorCSS = primitives.PrepareClassCSS("", `
`)
type PresendMessage struct {
message.PresendContainer
message.Presender
Message
}
func NewPresendMessage(msg input.PresendMessage) PresendMessage {
msgc := message.NewPresendContainer(msg)
func WrapPresendMessage(pstate *message.PresendState) PresendMessage {
return PresendMessage{
PresendContainer: msgc,
Message: wrapMessage(msgc.GenericContainer),
Presender: pstate,
Message: WrapMessage(pstate.State),
}
}
type Message struct {
*message.GenericContainer
*message.State
Timestamp *gtk.Label
Username *labeluri.Label
unwrap func()
}
var _ container.MessageRow = (*Message)(nil)
func NewMessage(msg cchat.MessageCreate) Message {
msgc := wrapMessage(message.NewContainer(msg))
message.FillContainer(msgc, msg)
return msgc
}
func NewEmptyMessage() Message {
ct := message.NewEmptyContainer()
return wrapMessage(ct)
}
func wrapMessage(ct *message.GenericContainer) Message {
func WrapMessage(ct *message.State) Message {
ts := message.NewTimestamp()
ts.SetVAlign(gtk.ALIGN_START)
ts.SetText(humanize.TimeAgo(ct.Time))
ts.SetTooltipText(ct.Time.Format(time.Stamp))
ts.Show()
messageTimeCSS(ts)
@ -80,31 +70,37 @@ func wrapMessage(ct *message.GenericContainer) Message {
ct.PackStart(ct.Content, true, true, 0)
ct.SetClass("compact")
rcfg := markup.RenderConfig{}
rcfg.NoReferencing = true
rcfg.SetForegroundAnchor(ct.ContentBodyStyle)
user.SetRenderer(func(rich text.Rich) markup.RenderOutput {
return markup.RenderCmplxWithConfig(rich, rcfg)
})
return Message{
GenericContainer: ct,
Timestamp: ts,
Username: user,
State: ct,
Timestamp: ts,
Username: user,
unwrap: ct.Author.Name.OnUpdate(func() {
user.SetLabel(ct.Author.Name.Label())
}),
}
}
// SetReferenceHighlighter sets the reference highlighter into the message.
func (m Message) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) {
m.GenericContainer.SetReferenceHighlighter(r)
m.State.SetReferenceHighlighter(r)
m.Username.SetReferenceHighlighter(r)
}
func (m Message) UpdateTimestamp(t time.Time) {
m.GenericContainer.UpdateTimestamp(t)
m.Timestamp.SetText(humanize.TimeAgo(t))
m.Timestamp.SetTooltipText(t.Format(time.Stamp))
}
func (m Message) UpdateAuthor(author cchat.Author) {
m.GenericContainer.UpdateAuthor(author)
cfg := markup.RenderConfig{}
cfg.NoReferencing = true
cfg.SetForegroundAnchor(m.ContentBodyStyle)
m.Username.SetOutput(markup.RenderCmplxWithConfig(author.Name(), cfg))
func (m Message) Unwrap(revert bool) *message.State {
if revert {
m.unwrap()
primitives.RemoveChildren(m)
m.SetClass("")
}
return m.State
}

View File

@ -2,11 +2,8 @@ package container
import (
"github.com/diamondburned/cchat"
"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/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gtk"
)
@ -17,45 +14,43 @@ const BacklogLimit = 50
type MessageRow interface {
message.Container
// Attach should only be called once.
Row() *gtk.ListBoxRow
// AttachMenu should override the stored constructor.
AttachMenu(items []menu.Item) // save memory
// MenuItems returns the list of attached menu items.
MenuItems() []menu.Item
// SetReferenceHighlighter sets the reference highlighter into the message.
SetReferenceHighlighter(refer labeluri.ReferenceHighlighter)
GrabFocus()
}
type PresendMessageRow interface {
MessageRow
message.PresendContainer
message.Presender
}
// Container is a generic messages container for children messages for children
// packages.
type Container interface {
gtk.IWidget
cchat.MessagesContainer
// Reset resets the message container to its original state.
Reset()
// CreateMessageUnsafe creates a new message and returns the index that is
// the location the message is added to.
CreateMessageUnsafe(cchat.MessageCreate) MessageRow
UpdateMessageUnsafe(cchat.MessageUpdate)
DeleteMessageUnsafe(cchat.MessageDelete)
// SetSelf sets the author for the current user.
SetSelf(self *message.Author)
// NewPresendMessage creates and adds a presend message state into the list.
NewPresendMessage(state *message.PresendState) PresendMessageRow
// AddMessage adds a new message into the list.
AddMessage(row MessageRow)
// FirstMessage returns the first message in the buffer. Nil is returned if
// there's nothing.
FirstMessage() MessageRow
// AddPresendMessage adds and displays an unsent message.
AddPresendMessage(msg input.PresendMessage) PresendMessageRow
// LatestMessageFrom returns the last message ID with that author.
LatestMessageFrom(authorID string) (msgID string, ok bool)
// Message finds and returns the message, if any.
// LastMessage returns the last message in the buffer or nil if there's
// nothing.
LastMessage() MessageRow
// Message finds and returns the message, if any. It performs maximum 2
// constant-time lookups.
Message(id cchat.ID, nonce string) MessageRow
// FindMessage finds a message that satisfies the given callback.
// FindMessage finds a message that satisfies the given callback. It
// iterates the message buffer from latest to earliest.
FindMessage(isMessage func(MessageRow) bool) MessageRow
// Highlight temporarily highlights the given message for a short while.
@ -67,6 +62,21 @@ type Container interface {
SetFocusVAdjustment(*gtk.Adjustment)
}
// UpdateMessage is a convenient function to update a message in the container.
// It does nothing if the message is not found.
func UpdateMessage(ct Container, update cchat.MessageUpdate) {
if msg := ct.Message(update.ID(), ""); msg != nil {
msg.UpdateContent(update.Content(), true)
}
}
// LatestMessageFrom returns the latest message from the given author ID.
func LatestMessageFrom(ct Container, authorID cchat.ID) MessageRow {
return ct.FindMessage(func(msg MessageRow) bool {
return msg.Unwrap(false).Author.ID == authorID
})
}
// Controller is for menu actions.
type Controller interface {
// Connector is used for button press events to unselect messages.
@ -77,20 +87,13 @@ type Controller interface {
Bottomed() bool
// AuthorEvent is called on message create/update. This is used to update
// the typer state.
AuthorEvent(a cchat.Author)
AuthorEvent(authorID cchat.ID)
// SelectMessage is called when a message is selected.
SelectMessage(list *ListStore, msg MessageRow)
// UnselectMessage is called when the message selection is cleared.
UnselectMessage()
}
// Constructor is an interface for making custom message implementations which
// allows ListContainer to generically work with.
type Constructor struct {
NewMessage func(msg cchat.MessageCreate, before MessageRow) MessageRow
NewPresendMessage func(msg input.PresendMessage, before MessageRow) PresendMessageRow
}
const ColumnSpacing = 8
// ListContainer is an implementation of Container, which allows flexible
@ -106,7 +109,7 @@ type ListContainer struct {
// messageRow w/ required internals
type messageRow struct {
MessageRow
presend message.PresendContainer // this shouldn't be here but i'm lazy
presend message.Presender // this shouldn't be here but i'm lazy
}
// unwrapRow is a helper that unwraps a messageRow if it's not nil. If it's nil,
@ -118,10 +121,8 @@ func unwrapRow(msg *messageRow) MessageRow {
return msg.MessageRow
}
var _ Container = (*ListContainer)(nil)
func NewListContainer(ctrl Controller, constr Constructor) *ListContainer {
listStore := NewListStore(ctrl, constr)
func NewListContainer(ctrl Controller) *ListContainer {
listStore := NewListStore(ctrl)
listStore.ListBox.Show()
clamp := handy.ClampNew()
@ -139,12 +140,10 @@ func NewListContainer(ctrl Controller, constr Constructor) *ListContainer {
}
}
// TODO: remove useless abstraction (this file).
// // CreateMessageUnsafe inserts a message. It does not clean up old messages.
// func (c *ListContainer) CreateMessageUnsafe(msg cchat.MessageCreate) MessageRow {
// return c.ListStore.CreateMessageUnsafe(msg)
// }
func (c *ListContainer) AddMessage(row MessageRow) {
c.ListStore.AddMessage(row)
c.CleanMessages()
}
// CleanMessages cleans up the oldest messages if the user is scrolled to the
// bottom. True is returned if there were changes.

View File

@ -0,0 +1,47 @@
package cozy
import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/gotk3/gotk3/gtk"
)
type Avatar struct {
roundimage.Button
Image *roundimage.StillImage
url string
}
func NewAvatar(parent primitives.Connector) *Avatar {
img := roundimage.NewStillImage(nil, 0)
img.SetSizeRequest(AvatarSize, AvatarSize)
img.Show()
avatar, _ := roundimage.NewCustomButton(img)
avatar.SetVAlign(gtk.ALIGN_START)
// Default icon.
primitives.SetImageIcon(img, "user-available-symbolic", AvatarSize)
return &Avatar{*avatar, img, ""}
}
// SetImage sets the avatar from the given label image.
func (a *Avatar) SetImage(img rich.LabelImage) {
a.SetURL(img.URL)
}
// SetURL updates the Avatar to be that URL. It does nothing if URL is empty or
// matches the existing one.
func (a *Avatar) SetURL(url string) {
// Check if the URL is the same. This will save us quite a few requests, as
// some methods rely on the side-effects of other methods, and they may call
// UpdateAuthor multiple times.
if a.url == url || url == "" {
return
}
a.url = url
a.Image.SetImageURL(url)
}

View File

@ -6,24 +6,10 @@ import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
"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/primitives"
)
// Unwrapper provides an interface for messages to be unwrapped. This is used to
// convert between collapsed and full messages.
type Unwrapper interface {
Unwrap() *message.GenericContainer
}
var (
_ Unwrapper = (*CollapsedMessage)(nil)
_ Unwrapper = (*CollapsedSendingMessage)(nil)
_ Unwrapper = (*FullMessage)(nil)
_ Unwrapper = (*FullSendingMessage)(nil)
)
// Collapsible is an interface for cozy messages to return whether or not
// they're full or collapsed.
type Collapsible interface {
@ -43,29 +29,26 @@ const (
AvatarMargin = 10
)
var messageConstructors = container.Constructor{
NewMessage: NewMessage,
NewPresendMessage: NewPresendMessage,
}
// NewMessage creates a new message.
func NewMessage(
msg cchat.MessageCreate, before container.MessageRow) container.MessageRow {
s *message.State, before container.MessageRow) container.MessageRow {
if isCollapsible(before, msg) {
return NewCollapsedMessage(msg)
if isCollapsible(before, s) {
return WrapCollapsedMessage(s)
}
return NewFullMessage(msg)
return WrapFullMessage(s)
}
// NewPresendMessage creates a new presend message.
func NewPresendMessage(
msg input.PresendMessage, before container.MessageRow) container.PresendMessageRow {
s *message.PresendState, before container.MessageRow) container.PresendMessageRow {
if isCollapsible(before, msg) {
return NewCollapsedSendingMessage(msg)
if isCollapsible(before, s.State) {
return WrapCollapsedSendingMessage(s)
}
return NewFullSendingMessage(msg)
return WrapFullSendingMessage(s)
}
type Container struct {
@ -73,7 +56,7 @@ type Container struct {
}
func NewContainer(ctrl container.Controller) *Container {
c := container.NewListContainer(ctrl, messageConstructors)
c := container.NewListContainer(ctrl)
primitives.AddClass(c, "cozy-container")
return &Container{ListContainer: c}
}
@ -81,105 +64,72 @@ func NewContainer(ctrl container.Controller) *Container {
func (c *Container) findAuthorID(authorID string) container.MessageRow {
// Search the old author if we have any.
return c.ListStore.FindMessage(func(msgc container.MessageRow) bool {
return msgc.Author().ID() == authorID
return msgc.Unwrap(false).Author.ID == authorID
})
}
// reuseAvatar tries to search past messages with the same author ID and URL for
// the image. It will fetch anew if there's none.
func (c *Container) reuseAvatar(authorID, avatarURL string, full *FullMessage) {
// Is this a message that we can work with? We have to assert to
// FullSendingMessage because that's where our messages are.
var lastAuthorMsg = c.findAuthorID(authorID)
// Borrow the avatar pixbuf, but only if the avatar URL is the same.
p, ok := lastAuthorMsg.(AvatarPixbufCopier)
if ok && lastAuthorMsg.Author().Avatar() == avatarURL {
if p.CopyAvatarPixbuf(full.Avatar.Image) {
full.Avatar.ManuallySetURL(avatarURL)
return
}
}
// We can't borrow, so we need to fetch it anew.
full.Avatar.SetURL(avatarURL)
}
// lastMessageIsAuthor removed - assuming index before insertion is harmful.
type authoredMessage interface {
cchat.MessageHeader
Author() cchat.Author
}
var (
_ authoredMessage = (cchat.MessageCreate)(nil)
_ authoredMessage = (input.PresendMessage)(nil)
_ authoredMessage = (container.MessageRow)(nil)
_ authoredMessage = (container.PresendMessageRow)(nil)
)
const splitDuration = 10 * time.Minute
// isCollapsible returns true if the given lastMsg has matching conditions with
// the given msg.
func isCollapsible(lastMsg container.MessageRow, msg authoredMessage) bool {
if lastMsg == nil || msg == nil {
func isCollapsible(last container.MessageRow, msg *message.State) bool {
if last == nil || msg.ID == "" {
return false
}
lastAuthor := lastMsg.Author()
thisAuthor := msg.Author()
lastMsg := last.Unwrap(false)
return true &&
lastAuthor.ID() == thisAuthor.ID() &&
lastAuthor.Name().String() == thisAuthor.Name().String() &&
lastMsg.Time().Add(splitDuration).After(msg.Time())
lastMsg.Author.ID == msg.ID &&
lastMsg.Time.Add(splitDuration).After(msg.Time)
}
func (c *Container) NewPresendMessage(state *message.PresendState) container.PresendMessageRow {
msgr := NewPresendMessage(state, c.LastMessage())
c.AddMessage(msgr)
return msgr
}
func (c *Container) CreateMessage(msg cchat.MessageCreate) {
gts.ExecAsync(func() {
// Create the message in the parent's handler. This handler will also
// wipe old messages.
row := c.ListContainer.CreateMessageUnsafe(msg)
state := message.NewState(msg)
msgr := NewMessage(state, c.LastMessage())
// Is this a full message? If so, then we should fetch the avatar when
// we can.
if full, ok := row.(*FullMessage); ok {
author := msg.Author()
avatarURL := author.Avatar()
// Try and reuse an existing avatar if the author has one.
if avatarURL != "" {
// Try reusing the avatar, but fetch it from the internet if we can't
// reuse. The reuse function does this for us.
c.reuseAvatar(author.ID(), avatarURL, full)
}
}
// Did the handler wipe old messages? It will only do so if the user is
// scrolled to the bottom.
if c.ListContainer.CleanMessages() {
// We need to uncollapse the first (top) message. No length check is
// needed here, as we just inserted a message.
c.uncompact(c.FirstMessage())
}
// If we've prepended the message, then see if we need to collapse the
// second message.
if first := c.ListContainer.FirstMessage(); first != nil && first.ID() == msg.ID() {
// If the author is the same, then collapse.
if sec := c.NthMessage(1); sec != nil && isCollapsible(sec, msg) {
c.compact(sec)
}
}
c.AddMessage(msgr)
})
}
// AddMessage adds the given message.
func (c *Container) AddMessage(msgr container.MessageRow) {
// Create the message in the parent's handler. This handler will also
// wipe old messages.
c.ListContainer.AddMessage(msgr)
// Did the handler wipe old messages? It will only do so if the user is
// scrolled to the bottom.
if c.ListContainer.CleanMessages() {
// We need to uncollapse the first (top) message. No length check is
// needed here, as we just inserted a message.
c.uncompact(c.FirstMessage())
}
// If we've prepended the message, then see if we need to collapse the
// second message.
if first := c.ListContainer.FirstMessage(); first != nil {
firstState := first.Unwrap(false)
msgState := msgr.Unwrap(false)
if firstState.ID == msgState.ID {
// If the author is the same, then collapse.
if sec := c.NthMessage(1); isCollapsible(sec, firstState) {
c.compact(sec)
}
}
}
}
func (c *Container) UpdateMessage(msg cchat.MessageUpdate) {
gts.ExecAsync(func() {
c.UpdateMessageUnsafe(msg)
})
gts.ExecAsync(func() { container.UpdateMessage(c, msg) })
}
func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
@ -202,10 +152,13 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
return
}
msgAuthorID := msg.Author().ID()
msgHeader := msg.Unwrap(false)
prevHeader := prev.Unwrap(false)
nextHeader := next.Unwrap(false)
// Check if the last message is the author's (relative to i):
if prev.Author().ID() == msgAuthorID {
if prevHeader.Author.ID == msgHeader.Author.ID {
// If the author is the same, then we don't need to uncollapse the
// message.
return
@ -213,7 +166,7 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
// If the next message (relative to i) is not the deleted message's
// author, then we don't need to uncollapse it.
if next.Author().ID() != msgAuthorID {
if nextHeader.Author.ID != msgHeader.Author.ID {
return
}
@ -223,33 +176,11 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
}
func (c *Container) uncompact(msg container.MessageRow) {
// We should only uncompact the message if it's compacted in the first
// place.
compact, ok := msg.(*CollapsedMessage)
if !ok {
return
}
// Start the "lengthy" uncollapse process.
full := WrapFullMessage(compact.Unwrap())
// Update the container to reformat everything including the timestamps.
message.RefreshContainer(full, full.GenericContainer)
// Update the avatar if needed be, since we're now showing it.
author := msg.Author()
c.reuseAvatar(author.ID(), author.Avatar(), full)
// Swap the old next message out for a new one.
full := WrapFullMessage(msg.Unwrap(true))
c.ListStore.SwapMessage(full)
}
func (c *Container) compact(msg container.MessageRow) {
full, ok := msg.(*FullMessage)
if !ok {
return
}
compact := WrapCollapsedMessage(full.Unwrap())
message.RefreshContainer(compact, compact.GenericContainer)
compact := WrapCollapsedMessage(msg.Unwrap(true))
c.ListStore.SwapMessage(compact)
}

View File

@ -1,12 +1,7 @@
package cozy
import (
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/humanize"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
"github.com/gotk3/gotk3/gtk"
)
@ -15,17 +10,12 @@ import (
// the header, and the avatar is invisible.
type CollapsedMessage struct {
// Author is still updated normally.
*message.GenericContainer
*message.State
Timestamp *gtk.Label
}
func NewCollapsedMessage(msg cchat.MessageCreate) *CollapsedMessage {
msgc := WrapCollapsedMessage(message.NewContainer(msg))
message.FillContainer(msgc, msg)
return msgc
}
func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage {
// WrapCollapsedMessage wraps the given message state to be a collapsed message.
func WrapCollapsedMessage(gc *message.State) *CollapsedMessage {
// Set Timestamp's padding accordingly to Avatar's.
ts := message.NewTimestamp()
ts.SetSizeRequest(AvatarSize, -1)
@ -42,37 +32,31 @@ func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage {
gc.SetClass("cozy-collapsed")
return &CollapsedMessage{
GenericContainer: gc,
Timestamp: ts,
State: gc,
Timestamp: ts,
}
}
func (c *CollapsedMessage) Collapsed() bool { return true }
func (c *CollapsedMessage) UpdateTimestamp(t time.Time) {
c.GenericContainer.UpdateTimestamp(t)
c.Timestamp.SetText(humanize.TimeAgoShort(t))
}
func (c *CollapsedMessage) Unwrap(revert bool) *message.State {
if revert {
// Remove State's widgets from the containers.
c.Remove(c.Timestamp)
c.Remove(c.Content)
}
func (c *CollapsedMessage) Unwrap() *message.GenericContainer {
// Remove GenericContainer's widgets from the containers.
c.Remove(c.Timestamp)
c.Remove(c.Content)
// Return after removing.
return c.GenericContainer
return c.State
}
type CollapsedSendingMessage struct {
*CollapsedMessage
message.PresendContainer
message.Presender
}
func NewCollapsedSendingMessage(msg input.PresendMessage) *CollapsedSendingMessage {
var msgc = message.NewPresendContainer(msg)
func WrapCollapsedSendingMessage(pstate *message.PresendState) *CollapsedSendingMessage {
return &CollapsedSendingMessage{
CollapsedMessage: WrapCollapsedMessage(msgc.GenericContainer),
PresendContainer: msgc,
CollapsedMessage: WrapCollapsedMessage(pstate.State),
Presender: pstate,
}
}

View File

@ -4,18 +4,13 @@ import (
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/humanize"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
"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/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/cairo"
"github.com/gotk3/gotk3/gtk"
)
@ -23,22 +18,20 @@ import (
const TopFullMargin = 4
type FullMessage struct {
*message.GenericContainer
*message.State
// Grid widgets.
Avatar *Avatar
MainBox *gtk.Box // wraps header and content
Header *labeluri.Label
timestamp string // markup
}
HeaderLabel *labeluri.Label
timestamp string // markup
type AvatarPixbufCopier interface {
CopyAvatarPixbuf(img httputil.SurfaceContainer) bool
// unwrap is used to removing label handlers.
unwrap func()
}
var (
_ AvatarPixbufCopier = (*FullMessage)(nil)
_ message.Container = (*FullMessage)(nil)
_ container.MessageRow = (*FullMessage)(nil)
)
@ -51,22 +44,16 @@ var avatarCSS = primitives.PrepareClassCSS("cozy-avatar", `
`)
func NewFullMessage(msg cchat.MessageCreate) *FullMessage {
msgc := WrapFullMessage(message.NewContainer(msg))
// Don't update the avatar. NewMessage in controller will try and reuse the
// pixbuf if possible.
msgc.UpdateAuthorName(msg.Author().Name())
msgc.UpdateTimestamp(msg.Time())
msgc.UpdateContent(msg.Content(), false)
return msgc
return WrapFullMessage(message.NewState(msg))
}
func WrapFullMessage(gc *message.GenericContainer) *FullMessage {
func WrapFullMessage(gc *message.State) *FullMessage {
header := labeluri.NewLabel(text.Rich{})
header.SetHAlign(gtk.ALIGN_START) // left-align
header.SetMaxWidthChars(100)
header.Show()
avatar := NewAvatar()
avatar := NewAvatar(gc.Row)
avatar.SetMarginTop(TopFullMargin / 2)
avatar.SetMarginStart(container.ColumnSpacing * 2)
avatar.Connect("clicked", func(w gtk.IWidget) {
@ -97,87 +84,61 @@ func WrapFullMessage(gc *message.GenericContainer) *FullMessage {
gc.PackStart(main, true, true, 0)
gc.SetClass("cozy-full")
return &FullMessage{
GenericContainer: gc,
msg := &FullMessage{
State: gc,
timestamp: formatLongTime(gc.Time),
Avatar: avatar,
MainBox: main,
Header: header,
Avatar: avatar,
MainBox: main,
HeaderLabel: header,
unwrap: gc.Author.Name.OnUpdate(func() {
avatar.SetImage(gc.Author.Name.Image())
header.SetLabel(gc.Author.Name.Label())
}),
}
header.SetRenderer(func(rich text.Rich) markup.RenderOutput {
cfg := markup.RenderConfig{}
cfg.NoReferencing = true
cfg.SetForegroundAnchor(gc.ContentBodyStyle)
output := markup.RenderCmplxWithConfig(rich, cfg)
output.Markup = `<span font_weight="600">` + output.Markup + "</span>"
output.Markup += msg.timestamp
return output
})
return msg
}
func (m *FullMessage) Collapsed() bool { return false }
func (m *FullMessage) Unwrap() *message.GenericContainer {
// Remove GenericContainer's widgets from the containers.
m.Header.Destroy()
m.MainBox.Remove(m.Content) // not ours, so don't destroy.
func (m *FullMessage) Unwrap(revert bool) *message.State {
if revert {
// Remove the handlers.
m.unwrap()
// Remove the message from the grid.
m.Avatar.Destroy()
m.MainBox.Destroy()
// Remove State's widgets from the containers.
m.HeaderLabel.Destroy()
m.MainBox.Remove(m.Content) // not ours, so don't destroy.
// Return after removing.
return m.GenericContainer
}
func (m *FullMessage) UpdateTimestamp(t time.Time) {
m.GenericContainer.UpdateTimestamp(t)
m.timestamp = " " +
`<span alpha="70%" size="small">` + humanize.TimeAgoLong(t) + `</span>`
// Update the timestamp.
m.Header.SetMarkup(m.Header.Output().Markup + m.timestamp)
}
func (m *FullMessage) UpdateAuthor(author cchat.Author) {
// Call the parent's method to update the state.
m.GenericContainer.UpdateAuthor(author)
m.UpdateAuthorName(author.Name())
m.Avatar.SetURL(author.Avatar())
}
func (m *FullMessage) UpdateAuthorName(name text.Rich) {
cfg := markup.RenderConfig{}
cfg.NoReferencing = true
cfg.SetForegroundAnchor(m.ContentBodyStyle)
output := markup.RenderCmplxWithConfig(name, cfg)
output.Markup = `<span font_weight="600">` + output.Markup + "</span>"
m.Header.SetMarkup(output.Markup + m.timestamp)
m.Header.SetUnderlyingOutput(output)
}
// CopyAvatarPixbuf sets the pixbuf into the given container. This shares the
// same pixbuf, but gtk.Image should take its own reference from the pixbuf.
func (m *FullMessage) CopyAvatarPixbuf(dst httputil.SurfaceContainer) bool {
switch img := m.Avatar.Image.GetImage(); img.GetStorageType() {
case gtk.IMAGE_PIXBUF:
dst.SetFromPixbuf(img.GetPixbuf())
case gtk.IMAGE_ANIMATION:
dst.SetFromAnimation(img.GetAnimation())
case gtk.IMAGE_SURFACE:
v, _ := img.GetProperty("surface")
dst.SetFromSurface(v.(*cairo.Surface))
default:
return false
// Remove the message from the grid.
m.Avatar.Destroy()
m.MainBox.Destroy()
}
return true
return m.State
}
func (m *FullMessage) AttachMenu(items []menu.Item) {
// Bind to parent's container as well.
m.GenericContainer.AttachMenu(items)
// Bind to the box.
// TODO lol
func formatLongTime(t time.Time) string {
return `<span alpha="70%" size="small">` + humanize.TimeAgoLong(t) + `</span>`
}
type FullSendingMessage struct {
message.PresendContainer
FullMessage
*FullMessage
message.Presender
}
var (
@ -185,51 +146,9 @@ var (
_ container.MessageRow = (*FullSendingMessage)(nil)
)
func NewFullSendingMessage(msg input.PresendMessage) *FullSendingMessage {
var msgc = message.NewPresendContainer(msg)
func WrapFullSendingMessage(pstate *message.PresendState) *FullSendingMessage {
return &FullSendingMessage{
PresendContainer: msgc,
FullMessage: *WrapFullMessage(msgc.GenericContainer),
FullMessage: WrapFullMessage(pstate.State),
Presender: pstate,
}
}
type Avatar struct {
roundimage.Button
Image *roundimage.StaticImage
url string
}
func NewAvatar() *Avatar {
img, _ := roundimage.NewStaticImage(nil, 0)
img.SetSizeRequest(AvatarSize, AvatarSize)
img.Show()
avatar, _ := roundimage.NewCustomButton(img)
avatar.SetVAlign(gtk.ALIGN_START)
// Default icon.
primitives.SetImageIcon(img, "user-available-symbolic", AvatarSize)
return &Avatar{*avatar, img, ""}
}
// SetURL updates the Avatar to be that URL. It does nothing if URL is empty or
// matches the existing one.
func (a *Avatar) SetURL(url string) {
// Check if the URL is the same. This will save us quite a few requests, as
// some methods rely on the side-effects of other methods, and they may call
// UpdateAuthor multiple times.
if a.url == url || url == "" {
return
}
a.url = url
a.Image.SetImageURL(url)
}
// ManuallySetURL sets the URL without downloading the image. It assumes the
// pixbuf is borrowed elsewhere.
func (a *Avatar) ManuallySetURL(url string) {
a.url = url
}

View File

@ -1,15 +1,15 @@
package container
import (
"log"
"strings"
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"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/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
)
@ -63,18 +63,19 @@ var messageListCSS = primitives.PrepareClassCSS("message-list", `
.message-list { background: transparent; }
`)
type ListStore struct {
ListBox *gtk.ListBox
var fallbackAuthor = message.NewCustomAuthor("", text.Plain("self"))
Construct Constructor
type ListStore struct {
ListBox *gtk.ListBox
Controller Controller
resetMe bool
self *message.Author
resetMe bool
messages map[messageKey]*messageRow
}
func NewListStore(ctrl Controller, constr Constructor) *ListStore {
func NewListStore(ctrl Controller) *ListStore {
listBox, _ := gtk.ListBoxNew()
listBox.SetSelectionMode(gtk.SELECTION_SINGLE)
listBox.Show()
@ -82,9 +83,9 @@ func NewListStore(ctrl Controller, constr Constructor) *ListStore {
listStore := ListStore{
ListBox: listBox,
Construct: constr,
Controller: ctrl,
messages: make(map[messageKey]*messageRow, BacklogLimit+1),
self: &fallbackAuthor,
}
var selected bool
@ -113,9 +114,29 @@ func NewListStore(ctrl Controller, constr Constructor) *ListStore {
return &listStore
}
// Reset resets the list store.
func (c *ListStore) Reset() {
for _, msg := range c.messages {
destroyMsg(msg)
}
// Delegate removing children to the constructor.
c.messages = make(map[messageKey]*messageRow, BacklogLimit+1)
if c.self.ID != "" {
c.self.Name.Stop()
}
}
// SetSelf sets the current author to presend. If ID is empty or Namer is nil,
// then the fallback author is used instead. The given author will be stopped
// on reset.
func (c *ListStore) SetSelf(self *message.Author) {
if self != nil {
c.self = self
} else {
c.self = &fallbackAuthor
}
}
func (c *ListStore) MessagesLen() int {
@ -133,22 +154,27 @@ func (c *ListStore) SwapMessage(msg MessageRow) bool {
msg = m.MessageRow
}
msgState := msg.Unwrap(false)
// Get the current message's index.
oldMsg, ix := c.findIndex(msg.ID())
oldMsg, ix := c.findIndex(msgState.ID)
if ix == -1 {
return false
}
oldState := oldMsg.Unwrap(false)
// Remove the to-be-replaced message box. We should probably reuse the row.
c.ListBox.Remove(oldMsg.Row())
c.ListBox.Remove(oldState.Row)
// Add a row at index. The actual row we want to delete will be shifted
// downwards.
c.ListBox.Insert(msg.Row(), ix)
c.ListBox.Insert(msgState.Row, ix)
// Set the message into the map.
row := c.messages[idKey(msg.ID())]
row := c.messages[idKey(msgState.ID)]
row.MessageRow = msg
c.bindMessage(row)
return true
}
@ -168,8 +194,6 @@ func (c *ListStore) Around(id cchat.ID) (before, after MessageRow) {
}
func (c *ListStore) around(aroundID cchat.ID) (before, after *messageRow) {
c.ensureEmpty()
var last *messageRow
var next bool
@ -192,25 +216,8 @@ func (c *ListStore) around(aroundID cchat.ID) (before, after *messageRow) {
return
}
// LatestMessageFrom returns the latest message with the given user ID. This is
// used for the input prompt.
func (c *ListStore) LatestMessageFrom(userID string) (msgID string, ok bool) {
// FindMessage already looks from the latest messages.
var msg = c.FindMessage(func(msg MessageRow) bool {
return msg.Author().ID() == userID
})
if msg == nil {
return "", false
}
return msg.ID(), true
}
// findIndex searches backwards for id.
func (c *ListStore) findIndex(findID cchat.ID) (found *messageRow, index int) {
c.ensureEmpty()
// Faster implementation of findMessage: no map lookup is done until an ID
// match, so the worst case is a single string hash.
index = c.MessagesLen() - 1
@ -235,8 +242,6 @@ func (c *ListStore) findIndex(findID cchat.ID) (found *messageRow, index int) {
}
func (c *ListStore) findMessage(presend bool, fn func(*messageRow) bool) (*messageRow, int) {
c.ensureEmpty()
var r *messageRow
var i = c.MessagesLen() - 1
@ -316,6 +321,9 @@ func (c *ListStore) message(msgID cchat.ID, nonce string) *messageRow {
if nonce != "" {
// Things in this map are guaranteed to have presend != nil.
m, ok := c.messages[nonceKey(nonce)]
// This is honestly pretty dumb, but whatever.
// TODO: make message() getter not set.
if ok {
// Replace the nonce key with ID.
delete(c.messages, nonceKey(nonce))
@ -334,135 +342,69 @@ func (c *ListStore) message(msgID cchat.ID, nonce string) *messageRow {
return nil
}
// ensureEmpty ensures that if the message map is empty, then the container
// should also be.
func (c *ListStore) ensureEmpty() {
if len(c.messages) == 0 {
primitives.RemoveChildren(c.ListBox)
}
}
func (c *ListStore) bindMessage(msgc *messageRow) {
state := msgc.Unwrap(false)
// Bind the message ID to the row so we can easily do a lookup.
var key messageKey
if id := msgc.ID(); id != "" {
key.id = id
} else {
key.id = msgc.Nonce()
key := messageKey{
id: state.ID,
}
if state.Nonce != "" {
key.id = state.Nonce
key.nonce = true
}
msgc.Row().SetName(key.name())
msgc.SetReferenceHighlighter(c)
state.Row.SetName(key.name())
msgc.MessageRow.SetReferenceHighlighter(c)
c.Controller.BindMenu(msgc.MessageRow)
}
// AddPresendMessage inserts an input.PresendMessage into the container and
// returning a wrapped widget interface.
func (c *ListStore) AddPresendMessage(msg input.PresendMessage) PresendMessageRow {
c.ensureEmpty()
func (c *ListStore) AddMessage(msg MessageRow) {
state := msg.Unwrap(false)
before := c.LastMessage()
if before != nil {
log.Println("Found before:", before.Author().Name())
} else {
log.Println("Before is nil")
}
presend := c.Construct.NewPresendMessage(msg, before)
msgc := &messageRow{
MessageRow: presend,
presend: presend,
}
// Set the message into the list.
c.ListBox.Insert(msgc.Row(), c.MessagesLen())
// Set the NONCE into the message map.
c.messages[nonceKey(msgc.Nonce())] = msgc
c.bindMessage(msgc)
return presend
}
// Many attempts were made to have CreateMessageUnsafe return an index. That is
// unreliable. The index might be off if the message buffer is cleaned up. Don't
// rely on it.
func (c *ListStore) CreateMessageUnsafe(msg cchat.MessageCreate) MessageRow {
// Call the event handler last.
defer c.Controller.AuthorEvent(msg.Author())
defer c.Controller.AuthorEvent(state.Author.ID)
// Do not attempt to update before insertion (aka upsert).
if msgc := c.message(msg.ID(), msg.Nonce()); msgc != nil {
msgc.UpdateAuthor(msg.Author())
msgc.UpdateContent(msg.Content(), false)
msgc.UpdateTimestamp(msg.Time())
c.bindMessage(msgc)
return msgc.MessageRow
if msgc := c.message(state.ID, state.Nonce); msgc != nil {
// This is kind of expensive, but it shouldn't really matter.
c.SwapMessage(msg)
return
}
c.ensureEmpty()
msgTime := msg.Time()
// Iterate and compare timestamp to find where to insert a message. Note
// that "before" is the message that will go before the to-be-inserted
// method.
before, index := c.findMessage(true, func(before *messageRow) bool {
return msgTime.After(before.Time())
return before.Unwrap(false).Time.After(state.Time)
})
msgc := &messageRow{
MessageRow: c.Construct.NewMessage(msg, unwrapRow(before)),
MessageRow: msg,
}
// Add the message. If before is nil, then the to-be-inserted message is the
// earliest message, therefore we prepend it.
if before == nil {
index = 0
c.ListBox.Prepend(msgc.Row())
c.ListBox.Prepend(state.Row)
} else {
index++ // insert right after
// Fast path: Insert did appear a lot on profiles, so we can try and use
// Add over Insert when we know.
if c.MessagesLen() == index {
c.ListBox.Add(msgc.Row())
c.ListBox.Add(state.Row)
} else {
c.ListBox.Insert(msgc.Row(), index)
c.ListBox.Insert(state.Row, index)
}
}
// Set the ID into the message map.
c.messages[idKey(msgc.ID())] = msgc
c.messages[idKey(state.ID)] = msgc
c.bindMessage(msgc)
return msgc.MessageRow
}
func (c *ListStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) {
// Call the event handler last.
defer c.Controller.AuthorEvent(msg.Author())
if msgc := c.Message(msg.ID(), ""); msgc != nil {
if author := msg.Author(); author != nil {
msgc.UpdateAuthor(author)
}
if content := msg.Content(); !content.IsEmpty() {
msgc.UpdateContent(content, true)
}
}
return
}
func (c *ListStore) DeleteMessageUnsafe(msg cchat.MessageDelete) {
c.PopMessage(msg.ID())
}
// PopMessage deletes a message off of the list and return the deleted message.
@ -475,7 +417,8 @@ func (c *ListStore) PopMessage(id cchat.ID) (msg MessageRow) {
msg = gridMsg.MessageRow
// Remove off of the Gtk grid.
gridMsg.Row().Destroy()
destroyMsg(gridMsg)
// Delete off the map.
delete(c.messages, idKey(id))
@ -489,22 +432,23 @@ func (c *ListStore) DeleteEarliest(n int) {
return
}
c.ensureEmpty()
// Since container/list nils out the next element, we can't just call Next
// after deleting, so we have to call Next manually before Removing.
primitives.ForeachChild(c.ListBox, func(v interface{}) (stop bool) {
id := parseKeyFromNamer(v.(primitives.Namer))
gridMsg := c.message(id.expand())
if id := gridMsg.ID(); id != "" {
delete(c.messages, idKey(id))
}
if nonce := gridMsg.Nonce(); nonce != "" {
delete(c.messages, nonceKey(nonce))
state := gridMsg.Unwrap(false)
if state.ID != "" {
delete(c.messages, idKey(state.ID))
}
gridMsg.Row().Destroy()
if state.Nonce != "" {
delete(c.messages, nonceKey(state.Nonce))
}
destroyMsg(gridMsg)
n--
return n == 0
@ -519,10 +463,16 @@ func (c *ListStore) HighlightReference(ref markup.ReferenceSegment) {
}
func (c *ListStore) Highlight(msg MessageRow) {
gts.ExecLater(func() {
row := msg.Row()
row.GrabFocus()
c.ListBox.DragHighlightRow(row)
gts.ExecAsync(func() {
state := msg.Unwrap(false)
state.Row.GrabFocus()
c.ListBox.DragHighlightRow(state.Row)
gts.DoAfter(2*time.Second, c.ListBox.DragUnhighlightRow)
})
}
func destroyMsg(row *messageRow) {
state := row.Unwrap(true)
state.Author.Name.Stop()
state.Row.Destroy()
}

View File

@ -272,8 +272,8 @@ var deleteAttBtnCSS = primitives.PrepareCSS(`
func (c *Container) addPreview(name string, thumbnail *cairo.Surface) {
// Make a fallback image first.
gimg, _ := roundimage.NewImage(4) // border-radius: 4px
primitives.SetImageIcon(gimg.Image, iconFromName(name), IconSize)
gimg := roundimage.NewImage(4) // border-radius: 4px
primitives.SetImageIcon(&gimg.Image, iconFromName(name), IconSize)
gimg.SetSizeRequest(ThumbSize, ThumbSize)
gimg.SetVAlign(gtk.ALIGN_CENTER)
gimg.SetHAlign(gtk.ALIGN_CENTER)

View File

@ -6,11 +6,14 @@ import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/username"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gtk"
@ -19,10 +22,12 @@ import (
// Controller is an interface to control message containers.
type Controller interface {
AddPresendMessage(msg PresendMessage) (onErr func(error))
LatestMessageFrom(userID cchat.ID) (messageID cchat.ID, ok bool)
MessageAuthor(msgID cchat.ID) cchat.Author
Author(authorID cchat.ID) cchat.Author
LatestMessageFrom(userID cchat.ID) container.MessageRow
MessageAuthor(msgID cchat.ID) *message.Author
Author(authorID cchat.ID) (name rich.LabelStateStorer)
// SendMessage asynchronously sends the given message.
SendMessage(msg message.PresendMessage)
}
// LabelBorrower is an interface that allows the caller to borrow a label.
@ -333,13 +338,13 @@ func (f *Field) StartReplyingTo(msgID cchat.ID) {
f.sendIcon.SetFromIconName(replyButtonIcon, gtk.ICON_SIZE_BUTTON)
if author := f.ctrl.MessageAuthor(msgID); author != nil {
label := author.Name.Label()
// Extract the name from the author's rich text and only render the area
// with the MessageReference.
name := author.Name()
for _, seg := range name.Segments {
for _, seg := range label.Segments {
if seg.AsMessageReferencer() != nil || seg.AsMentioner() != nil {
mention := markup.Render(markup.SubstringSegment(name, seg))
mention := markup.Render(markup.SubstringSegment(label, seg))
f.indicator.BorrowLabel("Replying to " + mention)
return
}

View File

@ -48,12 +48,14 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
}
// Try and find the latest message ID that is ours.
id, ok := f.ctrl.LatestMessageFrom(f.UserID)
if !ok {
msgr := f.ctrl.LatestMessageFrom(f.UserID)
if msgr == nil {
// No messages found, so we can passthrough normally.
return false
}
id := msgr.Unwrap(false).ID
// If we don't support message editing, then passthrough events.
if !f.Editable(id) {
return false

View File

@ -8,10 +8,9 @@ import (
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
"github.com/pkg/errors"
"github.com/twmb/murmur3"
)
@ -63,17 +62,9 @@ func (f *Field) sendInput() {
return
}
// Derive the author. Prefer the author of the current user from the message
// buffer over the one in the username feed, unless we can't find any.
var author = f.ctrl.Author(f.UserID)
if author == nil {
author = newAuthor(f)
}
f.SendMessage(SendMessageData{
f.ctrl.SendMessage(SendMessageData{
time: time.Now().UTC(),
content: text,
author: author,
nonce: f.generateNonce(),
replyID: f.replyingID,
files: attachments,
@ -86,19 +77,23 @@ func (f *Field) sendInput() {
f.text.GrabFocus()
}
func (f *Field) SendMessage(data PresendMessage) {
// presend message into the container through the controller
var onErr = f.ctrl.AddPresendMessage(data)
// func (f *Field) SendMessage(data message.PresendMessage) {
// f.ctrl.SendMessage(data)
// Copy the sender to prevent race conditions.
var sender = f.Sender
gts.Async(func() (func(), error) {
if err := sender.Send(data); err != nil {
return func() { onErr(err) }, errors.Wrap(err, "Failed to send message")
}
return nil, nil
})
}
// // message.NewPresendState(f.Username.State, data)
// // // presend message into the container through the controller
// // var onErr = f.ctrl.AddPresendMessage(data)
// // // Copy the sender to prevent race conditions.
// // var sender = f.Sender
// // gts.Async(func() (func(), error) {
// // if err := sender.Send(data); err != nil {
// // return func() { onErr(err) }, errors.Wrap(err, "Failed to send message")
// // }
// // return nil, nil
// // })
// }
// Files is a list of attachments.
type Files []attachment.File
@ -117,56 +112,23 @@ func (files Files) Attachments() []cchat.MessageAttachment {
type SendMessageData struct {
time time.Time
content string
author cchat.Author
nonce string
replyID cchat.ID
files Files
}
var _ cchat.SendableMessage = (*SendMessageData)(nil)
// PresendMessage is an interface for any message about to be sent.
type PresendMessage interface {
cchat.MessageHeader // returns nonce and time
cchat.SendableMessage
cchat.Noncer
// These methods are reserved for internal use.
Author() cchat.Author
Files() []attachment.File
}
var _ PresendMessage = (*SendMessageData)(nil)
var (
_ cchat.SendableMessage = (*SendMessageData)(nil)
_ message.PresendMessage = (*SendMessageData)(nil)
)
// ID returns a pseudo ID for internal use.
func (s SendMessageData) ID() string { return s.nonce }
func (s SendMessageData) Time() time.Time { return s.time }
func (s SendMessageData) Content() string { return s.content }
func (s SendMessageData) Author() cchat.Author { return s.author }
func (s SendMessageData) AsNoncer() cchat.Noncer { return s }
func (s SendMessageData) Nonce() string { return s.nonce }
func (s SendMessageData) Files() []attachment.File { return s.files }
func (s SendMessageData) AsAttacher() cchat.Attacher { return s.files }
func (s SendMessageData) AsReplier() cchat.Replier { return s }
func (s SendMessageData) ReplyingTo() cchat.ID { return s.replyID }
type sendableAuthor struct {
id cchat.ID
name text.Rich
avatarURL string
}
func newAuthor(f *Field) sendableAuthor {
return sendableAuthor{
f.UserID,
f.Username.GetLabel(),
f.Username.GetIconURL(),
}
}
var _ cchat.Author = (*sendableAuthor)(nil)
func (a sendableAuthor) ID() string { return a.id }
func (a sendableAuthor) Name() text.Rich { return a.name }
func (a sendableAuthor) Avatar() string { return a.avatarURL }

View File

@ -2,12 +2,14 @@ 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/messages/message"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
)
const AvatarSize = 24
@ -24,21 +26,20 @@ func init() {
}
type Container struct {
*gtk.Revealer
gtk.Revealer
State *message.Author
main *gtk.Box
avatar *rich.Icon
avatar *roundimage.Image
label *rich.Label
}
var (
_ cchat.LabelContainer = (*Container)(nil)
_ cchat.IconContainer = (*Container)(nil)
)
var usernameCSS = primitives.PrepareCSS(`
.username-view { margin: 0 5px }
`)
var fallbackAuthor = message.NewCustomAuthor("", text.Plain("self"))
func NewContainer() *Container {
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
box.Show()
@ -56,13 +57,32 @@ func NewContainer() *Container {
// thread.
currentRevealer = rev.SetRevealChild
container := Container{
Revealer: rev,
author := message.NewCustomAuthor("", text.Plain("self"))
u := Container{
Revealer: *rev,
State: &author,
main: box,
}
container.Reset()
u.Reset()
return &container
u.avatar = roundimage.NewImage(0)
u.avatar.SetSize(AvatarSize)
u.avatar.SetPlaceholderIcon("user-available-symbolic", AvatarSize)
u.avatar.Show()
rich.BindRoundImage(u.avatar, &u.State.Name, false)
u.label = rich.NewLabel(&u.State.Name)
u.label.SetEllipsize(pango.ELLIPSIZE_END)
u.label.SetMaxWidthChars(35)
u.label.Show()
primitives.RemoveChildren(u.main)
u.main.PackStart(u.avatar, false, false, 0)
u.main.PackStart(u.label, false, false, 0)
return &u
}
func (u *Container) SetRevealChild(reveal bool) {
@ -72,75 +92,39 @@ func (u *Container) SetRevealChild(reveal bool) {
// shouldReveal returns whether or not the container should reveal.
func (u *Container) shouldReveal() bool {
return (!u.label.GetLabel().IsEmpty() || u.avatar.URL() != "") && showUser
show := false
show = !u.State.Name.Label().IsEmpty()
show = show || u.avatar.GetImageURL() != ""
show = show || showUser
return true
}
func (u *Container) Reset() {
u.SetRevealChild(false)
u.avatar = rich.NewIcon(AvatarSize)
u.avatar.SetPlaceholderIcon("user-available-symbolic", AvatarSize)
u.avatar.Show()
u.label = rich.NewLabel(text.Rich{})
u.label.SetMaxWidthChars(35)
u.label.Show()
primitives.RemoveChildren(u.main)
u.main.PackStart(u.avatar, false, false, 0)
u.main.PackStart(u.label, false, false, 0)
u.State.Name.Stop()
}
// Update is not thread-safe.
func (u *Container) Update(session cchat.Session, messenger cchat.Messenger) {
// Set the fallback username.
u.label.SetLabelUnsafe(session.Name())
u.State.Name.BindNamer(u.main, "destroy", session)
// Reveal the name if it's not empty.
u.SetRevealChild(true)
// Does messenger implement Nicknamer? If yes, use it.
if nicknamer := messenger.AsNicknamer(); nicknamer != nil {
u.label.AsyncSetLabel(nicknamer.Nickname, "Error fetching server nickname")
}
// Does session implement an icon? Update if yes.
if iconer := session.AsIconer(); iconer != nil {
u.avatar.AsyncSetIconer(iconer, "Error fetching session icon URL")
u.State.Name.BindNamer(u.main, "destroy", nicknamer)
}
}
// GetLabel is not thread-safe.
func (u *Container) GetLabel() text.Rich {
return u.label.GetLabel()
// Label returns the underlying label.
func (u *Container) Label() text.Rich {
return u.State.Name.Label()
}
// GetLabelMarkup is not thread-safe.
// LabelMarkup returns the underlying label's markup.
func (u *Container) GetLabelMarkup() string {
return u.label.Label.GetLabel()
}
// SetLabel is thread-safe.
func (u *Container) SetLabel(content text.Rich) {
gts.ExecAsync(func() {
u.label.SetLabelUnsafe(content)
// Reveal if the name is not empty.
u.SetRevealChild(true)
})
}
// SetIcon is thread-safe.
func (u *Container) 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.
u.SetRevealChild(true)
})
}
// GetIconURL is not thread-safe.
func (u *Container) GetIconURL() string {
return u.avatar.URL()
return u.label.Output().Markup
}

View File

@ -3,7 +3,6 @@ package memberlist
import (
"context"
"fmt"
"strings"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
@ -128,20 +127,22 @@ func (c *Container) SetSectionsUnsafe(sections []cchat.MemberSection) {
// to this function instead of Reset to not halt for too long.
primitives.RemoveChildren(c.Main)
var newSections = make([]*Section, len(sections))
newSections := make([]*Section, len(sections))
oldSections := c.Sections
for i, section := range sections {
sc, ok := c.Sections[section.ID()]
if !ok {
sc = NewSection(section, &c.eventQueue)
} else {
sc.Update(section.Name(), section.Total())
sc.Update(section)
}
newSections[i] = sc
}
// Remove all old sections.
for id := range c.Sections {
delete(c.Sections, id)
}
@ -152,6 +153,16 @@ func (c *Container) SetSectionsUnsafe(sections []cchat.MemberSection) {
c.Sections[section.ID] = section
}
// Destroy old sections.
for _, section := range oldSections {
_, notOld := c.Sections[section.ID]
if notOld {
continue
}
section.Destroy()
}
c.ctrl.MemberListUpdated(c)
}
@ -173,7 +184,7 @@ type Section struct {
ID string
// state
name text.Rich
name rich.NameContainer
total int
Header *rich.Label
@ -197,26 +208,40 @@ var sectionBodyCSS = primitives.PrepareClassCSS("section-body", `
`)
func NewSection(sect cchat.MemberSection, evq EventQueuer) *Section {
header := rich.NewLabel(text.Rich{})
header.Show()
sectionHeaderCSS(header)
section := &Section{
ID: sect.ID(),
name: rich.NameContainer{},
}
body, _ := gtk.ListBoxNew()
body.SetSelectionMode(gtk.SELECTION_NONE)
body.SetActivateOnSingleClick(true)
body.SetSortFunc(listSortNameAsc) // A-Z
body.Show()
sectionBodyCSS(body)
section.Header = rich.NewLabel(&section.name)
section.Header.Show()
sectionHeaderCSS(section.Header)
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
box.PackStart(header, false, false, 0)
box.PackStart(body, false, false, 0)
box.Show()
section.Header.SetRenderer(func(rich text.Rich) markup.RenderOutput {
out := markup.RenderCmplx(rich)
if section.total > 0 {
out.Markup += fmt.Sprintf("—%d", section.total)
}
return out
})
section.Body, _ = gtk.ListBoxNew()
section.Body.SetSelectionMode(gtk.SELECTION_NONE)
section.Body.SetActivateOnSingleClick(true)
section.Body.SetSortFunc(listSortNameAsc) // A-Z
section.Body.Show()
sectionBodyCSS(section.Body)
section.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
section.Box.PackStart(section.Header, false, false, 0)
section.Box.PackStart(section.Body, false, false, 0)
section.Box.Show()
var members = map[string]*Member{}
// On row click, show the mention popup if any.
body.Connect("row-activated", func(_ *gtk.ListBox, r *gtk.ListBoxRow) {
section.Body.Connect("row-activated", func(_ *gtk.ListBox, r *gtk.ListBoxRow) {
var i = r.GetIndex()
// Cold path; we can afford searching in the map.
for _, member := range members {
@ -226,32 +251,20 @@ func NewSection(sect cchat.MemberSection, evq EventQueuer) *Section {
}
})
section := &Section{
ID: sect.ID(),
Box: box,
Header: header,
Body: body,
Members: members,
}
section.Update(sect.Name(), sect.Total())
section.name.QueueNamer(context.Background(), sect)
section.Header.Connect("destroy", section.name.Stop)
return section
}
func (s *Section) Update(name text.Rich, total int) {
s.name = name
s.total = total
func (s *Section) Destroy() {
s.name.Stop()
s.Box.Destroy()
}
var content = s.name.Content
if total > 0 {
content += fmt.Sprintf("—%d", total)
}
s.Header.SetLabelUnsafe(text.Rich{
Content: content,
Segments: s.name.Segments,
})
func (s *Section) Update(sect cchat.MemberSection) {
s.total = sect.Total()
s.name.QueueNamer(context.Background(), sect)
}
func (s *Section) SetMember(member cchat.ListMember) {
@ -292,14 +305,16 @@ type Member struct {
*gtk.ListBoxRow
Main *gtk.Box
Avatar *rich.Icon
Name *gtk.Label
output markup.RenderOutput
Avatar *roundimage.StillImage
Name *rich.Label
name rich.LabelState
second text.Rich
status cchat.Status
parent *gtk.ListBox
}
const AvatarSize = 34
const AvatarSize = 32
var memberRowCSS = primitives.PrepareClassCSS("member-row", `
.member-row {
@ -320,46 +335,61 @@ var avatarMemberCSS = primitives.PrepareClassCSS("avatar-member", `
`)
func NewMember(member cchat.ListMember) *Member {
m := Member{}
evb, _ := gtk.EventBoxNew()
evb.AddEvents(int(gdk.EVENT_ENTER_NOTIFY) | int(gdk.EVENT_LEAVE_NOTIFY))
evb.Show()
img, _ := roundimage.NewStaticImage(evb, 0)
img.Show()
m.Avatar = roundimage.NewStillImage(evb, 9999)
m.Avatar.SetSize(AvatarSize)
m.Avatar.SetPlaceholderIcon("user-info-symbolic", AvatarSize)
m.Avatar.Show()
avatarMemberCSS(m.Avatar)
icon := rich.NewCustomIcon(img, AvatarSize)
icon.SetPlaceholderIcon("user-info-symbolic", AvatarSize)
icon.Show()
avatarMemberCSS(icon)
rich.BindRoundImage(m.Avatar, &m.name, true)
lbl, _ := gtk.LabelNew("")
lbl.SetUseMarkup(true)
lbl.SetXAlign(0)
lbl.SetEllipsize(pango.ELLIPSIZE_END)
lbl.Show()
m.Name = rich.NewLabel(&m.name)
m.Name.SetUseMarkup(true)
m.Name.SetXAlign(0)
m.Name.SetEllipsize(pango.ELLIPSIZE_END)
m.Name.Show()
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
box.PackStart(icon, false, false, 0)
box.PackStart(lbl, true, true, 0)
box.Show()
memberBoxCSS(box)
m.Name.SetRenderer(func(rich text.Rich) markup.RenderOutput {
out := markup.RenderCmplx(rich)
evb.Add(box)
if m.status != cchat.StatusUnknown {
out.Markup = fmt.Sprintf(
`<span color="#%06X" size="large">●</span> %s`,
statusColors(member.Status()), out.Markup,
)
}
r, _ := gtk.ListBoxRowNew()
memberRowCSS(r)
r.Add(evb)
if !m.second.IsEmpty() {
out.Markup += fmt.Sprintf(
"\n"+`<span alpha="85%%"><sup>%s</sup></span>`,
markup.Render(m.second),
)
}
m := &Member{
ListBoxRow: r,
Main: box,
Avatar: icon,
Name: lbl,
}
return out
})
m.Main, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
m.Main.PackStart(m.Avatar, false, false, 0)
m.Main.PackStart(m.Name, true, true, 0)
m.Main.Show()
memberBoxCSS(m.Main)
evb.Add(m.Main)
m.ListBoxRow, _ = gtk.ListBoxRowNew()
m.ListBoxRow.Add(evb)
memberRowCSS(m.ListBoxRow)
m.Update(member)
return m
return &m
}
var noMentionLinks = markup.RenderConfig{
@ -368,38 +398,22 @@ var noMentionLinks = markup.RenderConfig{
}
func (m *Member) Update(member cchat.ListMember) {
m.status = member.Status()
m.second = member.Secondary()
m.name.SetLabel(member.Name())
m.ListBoxRow.SetName(member.Name().Content)
if iconer := member.AsIconer(); iconer != nil {
m.Avatar.AsyncSetIconer(iconer, "Failed to get member list icon")
}
m.output = markup.RenderCmplxWithConfig(member.Name(), noMentionLinks)
txt := strings.Builder{}
txt.WriteString(fmt.Sprintf(
`<span color="#%06X" size="large">●</span> %s`,
statusColors(member.Status()), m.output.Markup,
))
if bot := member.Secondary(); !bot.IsEmpty() {
txt.WriteByte('\n')
txt.WriteString(fmt.Sprintf(
`<span alpha="85%%"><sup>%s</sup></span>`,
markup.Render(bot),
))
}
m.Name.SetMarkup(txt.String())
}
// Popup pops up the mention popover if any.
func (m *Member) Popup(evq EventQueuer) {
if len(m.output.Mentions) == 0 {
out := m.Name.Output()
if len(out.Mentions) == 0 {
return
}
p := labeluri.NewPopoverMentioner(m, m.output.Input, m.output.Mentions[0])
p := labeluri.NewPopoverMentioner(m, out.Input, out.Mentions[0])
if p == nil {
return
}

View File

@ -1,50 +1,36 @@
package message
import (
"context"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat/text"
)
// Author implements cchat.Author. It effectively contains a copy of
// cchat.Author.
type Author struct {
id cchat.ID
name text.Rich
avatarURL string
ID cchat.ID
Name rich.NameContainer
}
var _ cchat.Author = (*Author)(nil)
// NewAuthor creates a new Author that is a copy of the given author.
func NewAuthor(author cchat.Author) Author {
a := Author{}
a.Update(author)
func NewAuthor(author cchat.User) Author {
a := Author{ID: author.ID()}
a.Name.QueueNamer(context.Background(), author)
return a
}
// NewCustomAuthor creates a new Author from the given parameters.
func NewCustomAuthor(id cchat.ID, name text.Rich, avatar string) Author {
func NewCustomAuthor(id cchat.ID, name text.Rich) Author {
return Author{
id,
name,
avatar,
ID: id,
Name: rich.NameContainer{LabelState: *rich.NewLabelState(name)},
}
}
func (a *Author) Update(author cchat.Author) {
a.id = author.ID()
a.name = author.Name()
a.avatarURL = author.Avatar()
}
func (a Author) ID() string {
return a.id
}
func (a Author) Name() text.Rich {
return a.name
}
func (a Author) Avatar() string {
return a.avatarURL
// Update sets a new name.
func (author *Author) Update(user cchat.Namer) {
author.Name.QueueNamer(context.Background(), user)
}

View File

@ -1,6 +1,7 @@
package message
import (
"context"
"time"
"github.com/diamondburned/cchat"
@ -8,70 +9,65 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
)
// Container describes a message container that wraps a state. These methods are
// made for containers to override; methods not meant to be override are not
// exposed and will be done directly on the State.
type Container interface {
ID() cchat.ID
Time() time.Time
Author() cchat.Author
Nonce() string
// Unwrap unwraps the message container and, if revert is true, revert the
// state to a clean version. Containers must implement this method by
// itself.
Unwrap(revert bool) *State
UpdateAuthor(cchat.Author)
UpdateContent(c text.Rich, edited bool)
UpdateTimestamp(time.Time)
// UpdateContent updates the underlying content widget.
UpdateContent(content text.Rich, edited bool)
// SetReferenceHighlighter sets the reference highlighter into the message.
SetReferenceHighlighter(refer labeluri.ReferenceHighlighter)
}
// FillContainer sets the container's contents to the one from MessageCreate.
func FillContainer(c Container, msg cchat.MessageCreate) {
c.UpdateAuthor(msg.Author())
c.UpdateContent(msg.Content(), false)
c.UpdateTimestamp(msg.Time())
}
// RefreshContainer sets the container's contents to the one from
// GenericContainer. This is mainly used for transferring between different
// containers.
func RefreshContainer(c Container, gc *GenericContainer) {
c.UpdateTimestamp(gc.time)
}
// GenericContainer provides a single generic message container for subpackages
// State provides a single generic message container for subpackages
// to use.
type GenericContainer struct {
*gtk.Box
row *gtk.ListBoxRow // contains Box
type State struct {
gtk.Box
Row *gtk.ListBoxRow // contains Box
class string
id string
time time.Time
author Author
nonce string
ID cchat.ID
Time time.Time
Nonce string
Author *Author
Content *gtk.Box
ContentBody *labeluri.Label
ContentBodyStyle *gtk.StyleContext
menuItems []menu.Item
MenuItems []menu.Item
}
var _ Container = (*GenericContainer)(nil)
// NewState creates a new message state with the given ID and nonce. It does not
// update the widgets, so FillContainer should be called afterwards.
func NewState(msg cchat.MessageCreate) *State {
author := msg.Author()
// NewContainer creates a new message container with the given ID and nonce. It
// does not update the widgets, so FillContainer should be called afterwards.
func NewContainer(msg cchat.MessageCreate) *GenericContainer {
c := NewEmptyContainer()
c.id = msg.ID()
c.time = msg.Time()
c.nonce = msg.Nonce()
c.author.Update(msg.Author())
c := NewEmptyState()
c.Author.ID = author.ID()
c.Author.Name.QueueNamer(context.Background(), author)
c.ID = msg.ID()
c.Time = msg.Time()
c.Nonce = msg.Nonce()
return c
}
func NewEmptyContainer() *GenericContainer {
// NewEmptyState creates a new empty message state. The author should be set
// immediately afterwards; it is invalid once the state is used.
func NewEmptyState() *State {
ctbody := labeluri.NewLabel(text.Rich{})
ctbody.SetVExpand(true)
ctbody.SetHAlign(gtk.ALIGN_START)
@ -100,9 +96,10 @@ func NewEmptyContainer() *GenericContainer {
row.Show()
primitives.AddClass(row, "message-row")
gc := &GenericContainer{
Box: box,
row: row,
gc := &State{
Box: *box,
Row: row,
Author: &Author{},
Content: ctbox,
ContentBody: ctbody,
@ -110,81 +107,49 @@ func NewEmptyContainer() *GenericContainer {
// Time is important, as it is used to sort messages, so we have to be
// careful with this.
time: time.Now(),
Time: time.Now(),
}
// This may either work, or it may cause memory leaks.
row.Connect("destroy", func() { gc.Author.Name.Stop() })
// Bind the custom popup menu to the content label.
gc.ContentBody.Connect("populate-popup", func(l *gtk.Label, m *gtk.Menu) {
menu.MenuSeparator(m)
menu.MenuItems(m, gc.menuItems)
menu.MenuItems(m, gc.MenuItems)
})
return gc
}
// Row returns the internal list box row. It is used to satisfy MessageRow.
func (m *GenericContainer) Row() *gtk.ListBoxRow { return m.row }
// SetClass sets the internal row's class.
func (m *GenericContainer) SetClass(class string) {
func (m *State) SetClass(class string) {
if m.class != "" {
primitives.RemoveClass(m.row, m.class)
primitives.RemoveClass(m.Row, m.class)
}
primitives.AddClass(m.row, class)
primitives.AddClass(m.Row, class)
m.class = class
}
// SetReferenceHighlighter sets the reference highlighter into the message.
func (m *GenericContainer) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) {
func (m *State) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) {
m.ContentBody.SetReferenceHighlighter(r)
}
func (m *GenericContainer) ID() string {
return m.id
}
func (m *GenericContainer) Time() time.Time {
return m.time
}
func (m *GenericContainer) Author() cchat.Author {
return m.author
}
func (m *GenericContainer) Nonce() string {
return m.nonce
}
func (m *GenericContainer) UpdateTimestamp(t time.Time) {
m.time = t
}
func (m *GenericContainer) UpdateAuthor(author cchat.Author) {
m.author.Update(author)
}
func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) {
m.ContentBody.SetLabelUnsafe(content)
// UpdateContent replaces the internal content and the widget.
func (m *State) UpdateContent(content text.Rich, edited bool) {
m.ContentBody.SetLabel(content)
if edited {
markup := m.ContentBody.Output().Markup
markup += " " + rich.Small("(edited)")
m.ContentBody.SetMarkup(markup)
m.ContentBody.SetRenderer(func(content text.Rich) markup.RenderOutput {
output := markup.RenderCmplx(content)
output.Markup += rich.Small(text.Plain("(edited)")).Markup
return output
})
}
}
// AttachMenu connects signal handlers to handle a list of menu items from
// the container.
func (m *GenericContainer) AttachMenu(newItems []menu.Item) {
m.menuItems = newItems
}
// MenuItems returns the list of menu items for this message.
func (m *GenericContainer) MenuItems() []menu.Item {
return m.menuItems
}
func (m *GenericContainer) Focusable() gtk.IWidget {
func (m *State) Focusable() gtk.IWidget {
return m.Content
}

View File

@ -4,8 +4,8 @@ import (
"fmt"
"html"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/humanize"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
@ -16,33 +16,51 @@ var EmptyContentPlaceholder = fmt.Sprintf(
`<span alpha="25%%">%s</span>`, html.EscapeString("<empty>"),
)
type PresendContainer interface {
SetDone(id string)
// Presender describes actions doable on a presend message container.
type Presender interface {
SendingMessage() PresendMessage
SetDone(id cchat.ID)
SetLoading()
SetSentError(err error)
}
// PresendGenericContainer is the generic container with extra methods
// implemented for stateful mutability of the generic message container.
type GenericPresendContainer struct {
*GenericContainer
// PresendMessage is an interface for any message about to be sent.
type PresendMessage interface {
cchat.MessageHeader
cchat.SendableMessage
cchat.Noncer
// These methods are reserved for internal use.
Files() []attachment.File
}
// PresendState is the generic state with extra methods implemented for stateful
// mutability of the generic message state.
type PresendState struct {
*State
// states; to be cleared on SetDone()
presend input.PresendMessage
presend PresendMessage
uploads *attachment.MessageUploader
}
var _ PresendContainer = (*GenericPresendContainer)(nil)
var (
_ Presender = (*PresendState)(nil)
)
func NewPresendContainer(msg input.PresendMessage) *GenericPresendContainer {
c := NewEmptyContainer()
c.nonce = msg.Nonce()
c.UpdateAuthor(msg.Author())
c.UpdateTimestamp(msg.Time())
type SendMessageData struct {
}
p := &GenericPresendContainer{
GenericContainer: c,
// NewPresendState creates a new presend state.
func NewPresendState(self *Author, msg PresendMessage) *PresendState {
c := NewEmptyState()
c.Author = self
c.Nonce = msg.Nonce()
c.Time = msg.Time()
p := &PresendState{
State: c,
presend: msg,
uploads: attachment.NewMessageUploader(msg.Files()),
}
@ -51,14 +69,18 @@ func NewPresendContainer(msg input.PresendMessage) *GenericPresendContainer {
return p
}
func (m *GenericPresendContainer) SetSensitive(sensitive bool) {
func (m *PresendState) SendingMessage() PresendMessage { return m.presend }
// SetSensitive sets the sensitivity of the content.
func (m *PresendState) SetSensitive(sensitive bool) {
m.Content.SetSensitive(sensitive)
}
func (m *GenericPresendContainer) SetDone(id string) {
// SetDone sets the status of the state.
func (m *PresendState) SetDone(id cchat.ID) {
// Apply the received ID.
m.id = id
m.nonce = ""
m.ID = id
m.Nonce = ""
// Reset the state to be normal. Especially setting presend to nil should
// free it from memory.
@ -76,7 +98,8 @@ func (m *GenericPresendContainer) SetDone(id string) {
m.SetSensitive(true)
}
func (m *GenericPresendContainer) SetLoading() {
// SetLoading greys the message to indicate that it's loading.
func (m *PresendState) SetLoading() {
m.SetSensitive(false)
m.Content.SetTooltipText("")
@ -100,7 +123,8 @@ func (m *GenericPresendContainer) SetLoading() {
}
}
func (m *GenericPresendContainer) SetSentError(err error) {
// SetSentError sets the error into the message to notify the user.
func (m *PresendState) SetSentError(err error) {
m.SetSensitive(true) // allow events incl right clicks
m.Content.SetTooltipText(err.Error())
@ -132,6 +156,6 @@ func (m *GenericPresendContainer) SetSentError(err error) {
}
// clearBox clears everything inside the content container.
func (m *GenericPresendContainer) clearBox() {
func (m *PresendState) clearBox() {
primitives.RemoveChildren(m.Content)
}

View File

@ -84,11 +84,11 @@ func (mc *MessageControl) Enable(msg container.MessageRow, names MessageItemName
mc.SetSensitive(true)
mc.SetRevealChild(true && !mc.hide)
items := msg.MenuItems()
unwrap := msg.Unwrap(false)
mc.Reply.bind(menu.FindItemFunc(items, names.Reply))
mc.Edit.bind(menu.FindItemFunc(items, names.Edit))
mc.Delete.bind(menu.FindItemFunc(items, names.Delete))
mc.Reply.bind(menu.FindItemFunc(unwrap.MenuItems, names.Reply))
mc.Edit.bind(menu.FindItemFunc(unwrap.MenuItems, names.Edit))
mc.Delete.bind(menu.FindItemFunc(unwrap.MenuItems, names.Delete))
}
// SetHidden sets whether or not the control should be hidden.

View File

@ -1,17 +1,25 @@
package typing
import (
"context"
"sort"
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/pkg/errors"
)
type typer struct {
cchat.User
s *rich.NameContainer
t time.Time
}
type State struct {
// states
typers []cchat.Typer
typers []typer
timeout time.Duration
canceler func()
invalidated bool
@ -67,35 +75,54 @@ func (s *State) loop() {
// Call the event handler if things are invalidated.
if s.invalidated {
s.changed(s, len(s.typers) == 0)
s.update()
s.invalidated = false
}
}
// update force-runs th callback.
func (s *State) update() {
s.changed(s, len(s.typers) == 0)
}
// 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())
return s.typers[i].t.Before(s.typers[j].t)
})
s.invalidated = true
}
// AddTyper is thread-safe.
func (s *State) AddTyper(typer cchat.Typer) {
func (s *State) AddTyper(user cchat.User) {
now := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
state := rich.NameContainer{}
state.QueueNamer(ctx, user)
gts.ExecAsync(func() {
defer cancel()
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() {
if t.ID() == user.ID() {
s.typers[i] = t
return
}
}
s.typers = append(s.typers, typer)
state.OnUpdate(s.update)
s.typers = append(s.typers, typer{
User: user,
s: &state,
t: now,
})
})
}
@ -108,19 +135,24 @@ 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
if t.ID() != typerID {
continue
}
// Invalidate the typer's label state.
t.s.Stop()
// Remove the quick way. Sort will take care of ordering.
l := len(s.typers) - 1
s.typers[i] = s.typers[l]
s.typers[l] = typer{}
s.typers = s.typers[:l]
return
}
}
func filterTypers(typers []cchat.Typer, timeout time.Duration) ([]cchat.Typer, bool) {
func filterTypers(typers []typer, timeout time.Duration) ([]typer, bool) {
// Fast path.
if len(typers) == 0 || timeout == 0 {
return nil, false
@ -130,14 +162,15 @@ func filterTypers(typers []cchat.Typer, timeout time.Duration) ([]cchat.Typer, b
var cut int
for _, t := range typers {
if now.Sub(t.Time()) < timeout {
if now.Sub(t.t) < timeout {
typers[cut] = t
cut++
}
}
for i := cut; i < len(typers); i++ {
typers[i] = nil
typers[i].s.Stop()
typers[i] = typer{}
}
var changed = cut != len(typers)

View File

@ -137,8 +137,8 @@ func (c *Container) Unborrow() {
}
}
func (c *Container) RemoveAuthor(author cchat.Author) {
c.state.removeTyper(author.ID())
func (c *Container) RemoveAuthor(userID cchat.ID) {
c.state.removeTyper(userID)
}
func (c *Container) TrySubscribe(svmsg cchat.Messenger) bool {
@ -155,7 +155,7 @@ var noMentionLinks = markup.RenderConfig{
NoMentionLinks: true,
}
func render(typers []cchat.Typer) string {
func render(typers []typer) string {
// fast path
if len(typers) == 0 {
return ""
@ -164,7 +164,7 @@ func render(typers []cchat.Typer) string {
var builder strings.Builder
for i, typer := range typers {
output := markup.RenderCmplxWithConfig(typer.Name(), noMentionLinks)
output := markup.RenderCmplxWithConfig(typer.s.Label(), noMentionLinks)
builder.WriteString("<b>")
builder.WriteString(output.Markup)

View File

@ -15,12 +15,16 @@ 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/memberlist"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
"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/primitives/drag"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gtk"
@ -280,7 +284,7 @@ func (v *View) MemberListUpdated(c *memberlist.Container) {
}
// JoinServer is not thread-safe, but it calls backend functions asynchronously.
func (v *View) JoinServer(session cchat.Session, server cchat.Server, bc traverse.Breadcrumber) {
func (v *View) JoinServer(ses *session.Row, srv *server.ServerRow, bc traverse.Breadcrumber) {
// Set the screen to loading.
v.FaceView.SetLoading()
v.ctrl.OnMessageBusy()
@ -289,19 +293,22 @@ func (v *View) JoinServer(session cchat.Session, server cchat.Server, bc travers
v.reset()
// Get the messenger once.
var messenger = server.AsMessenger()
var messenger = srv.Server.AsMessenger()
// Exit if this server is not a messenger.
if messenger == nil {
return
}
// Bind the state.
v.state.bind(session, server, messenger)
v.state.bind(ses.Session, srv.Server, messenger)
// We're setting this variable before actually calling JoinServer. This is
// because new messages created by JoinServer will use this state for things
// such as determinining if it's deletable or not.
v.InputView.SetMessenger(session, messenger)
v.InputView.SetMessenger(ses.Session, messenger)
// Bind the container's self user to what we just set.
v.Container.SetSelf(v.InputView.Username.State)
go func() {
// We can use a background context here, as the user can't go anywhere
@ -363,79 +370,84 @@ func (v *View) FetchBacklog() {
v.Container.Highlight(firstMsg)
}
firstID := firstMsg.Unwrap(false).ID
gts.Async(func() (func(), error) {
ctx, cancel := context.WithTimeout(context.TODO(), 3*time.Second)
defer cancel()
err := backlogger.Backlog(ctx, firstMsg.ID(), v.Container)
err := backlogger.Backlog(ctx, firstID, v.Container)
return done, errors.Wrap(err, "Failed to get messages before ID")
})
}
func (v *View) AddPresendMessage(msg input.PresendMessage) func(error) {
var presend = v.Container.AddPresendMessage(msg)
return func(err error) {
// Set the retry message.
presend.SetSentError(err)
// Only attach the menu once. Further retries do not need to be
// reattached.
presend.AttachMenu([]menu.Item{
menu.SimpleItem("Retry", func() {
presend.SetLoading()
v.retryMessage(msg, presend)
}),
})
}
}
// AuthorEvent should be called on message create/update/delete.
func (v *View) AuthorEvent(author cchat.Author) {
func (v *View) AuthorEvent(authorID cchat.ID) {
// Remove the author from the typing list if it's not nil.
if author != nil {
v.Typing.RemoveAuthor(author)
if authorID != "" {
v.Typing.RemoveAuthor(authorID)
}
}
// MessageAuthor returns the author from the message with the given ID.
func (v *View) MessageAuthor(msgID cchat.ID) cchat.Author {
func (v *View) MessageAuthor(msgID cchat.ID) *message.Author {
msg := v.Container.Message(msgID, "")
if msg == nil {
return nil
}
return msg.Author()
return msg.Unwrap(false).Author
}
// Author returns the author from the message list with the given author ID.
func (v *View) Author(authorID cchat.ID) cchat.Author {
func (v *View) Author(authorID cchat.ID) rich.LabelStateStorer {
msg := v.Container.FindMessage(func(msg container.MessageRow) bool {
return msg.Author().ID() == authorID
return msg.Unwrap(false).Author.ID == authorID
})
if msg == nil {
return nil
}
return msg.Author()
state := msg.Unwrap(false)
return &state.Author.Name
}
// LatestMessageFrom returns the last message ID with that author.
func (v *View) LatestMessageFrom(userID string) (msgID string, ok bool) {
return v.Container.LatestMessageFrom(userID)
func (v *View) LatestMessageFrom(userID cchat.ID) container.MessageRow {
return container.LatestMessageFrom(v.Container, userID)
}
func (v *View) SendMessage(msg message.PresendMessage) {
state := message.NewPresendState(v.InputView.Username.State, msg)
msgr := v.Container.NewPresendMessage(state)
v.retryMessage(msgr)
}
// retryMessage sends the message.
func (v *View) retryMessage(msg input.PresendMessage, presend container.PresendMessageRow) {
func (v *View) retryMessage(presend container.PresendMessageRow) {
var sender = v.InputView.Sender
if sender == nil {
return
}
go func() {
if err := sender.Send(msg); err != nil {
if err := sender.Send(presend.SendingMessage()); err != nil {
// Set the message's state to errored again, but we don't need to
// rebind the menu.
gts.ExecAsync(func() { presend.SetSentError(err) })
gts.ExecAsync(func() {
// Set the retry message.
presend.SetSentError(err)
// Only attach the menu once. Further retries do not need to be
// reattached.
state := presend.Unwrap(false)
state.MenuItems = []menu.Item{
menu.SimpleItem("Retry", func() {
presend.SetLoading()
v.retryMessage(presend)
}),
}
})
}
}()
}
@ -449,33 +461,35 @@ var messageItemNames = MessageItemNames{
// BindMenu attaches the menu constructor into the message with the needed
// states and callbacks.
func (v *View) BindMenu(msg container.MessageRow) {
state := msg.Unwrap(false)
// Add 1 for the edit menu item.
var mitems = []menu.Item{
menu.SimpleItem(
"Reply", func() { v.InputView.StartReplyingTo(msg.ID()) },
"Reply", func() { v.InputView.StartReplyingTo(state.ID) },
),
}
// Do we have editing capabilities? If yes, append a button to allow it.
if v.InputView.Editable(msg.ID()) {
if v.InputView.Editable(state.ID) {
mitems = append(mitems, menu.SimpleItem(
"Edit", func() { v.InputView.StartEditing(msg.ID()) },
"Edit", func() { v.InputView.StartEditing(state.ID) },
))
}
// Do we have any custom actions? If yes, append it.
if v.hasActions() {
var actions = v.actioner.Actions(msg.ID())
var actions = v.actioner.Actions(state.ID)
var items = make([]menu.Item, len(actions))
for i, action := range actions {
items[i] = v.makeActionItem(action, msg.ID())
items[i] = v.makeActionItem(action, state.ID)
}
mitems = append(mitems, items...)
}
msg.AttachMenu(mitems)
state.MenuItems = mitems
}
// makeActionItem creates a new menu callback that's called on menu item

View File

@ -189,7 +189,7 @@ func (c *Completer) update() []gtk.IWidget {
lbox.Show()
// Label for the primary text.
l := rich.NewLabel(entry.Text)
l := rich.NewStaticLabel(entry.Text)
l.Show()
lbox.PackStart(l, false, false, 0)
@ -198,7 +198,7 @@ func (c *Completer) update() []gtk.IWidget {
if !entry.Secondary.IsEmpty() {
size = ImageLarge
s := rich.NewLabel(text.Rich{})
s := rich.NewStaticLabel(text.Plain(""))
s.SetMarkup(fmt.Sprintf(
`<span alpha="50%%" size="small">%s</span>`,
markup.Render(entry.Secondary),

View File

@ -1,7 +1,6 @@
package primitives
import (
"context"
"runtime/debug"
"github.com/diamondburned/cchat-gtk/internal/gts"
@ -179,27 +178,13 @@ func NewImageIconPx(icon string, sizepx int) *gtk.Image {
}
type ImageIconSetter interface {
SetProperty(name string, value interface{}) error
GetSizeRequest() (w, h int)
SetSizeRequest(w, h int)
SetFromIconName(string, gtk.IconSize)
SetPixelSize(int)
}
func SetImageIcon(img ImageIconSetter, icon string, sizepx int) {
// Prioritize SetSize()
if setter, ok := img.(interface{ SetSize(int) }); ok {
setter.SetSize(sizepx)
} else {
img.SetProperty("pixel-size", sizepx)
}
// Prioritize SetIconName().
if setter, ok := img.(interface{ SetIconName(string) }); ok {
setter.SetIconName(icon)
} else {
img.SetProperty("icon-name", icon)
}
img.SetSizeRequest(sizepx, sizepx)
img.SetFromIconName(icon, gtk.ICON_SIZE_BUTTON)
img.SetPixelSize(sizepx)
}
func PrependMenuItems(menu interface{ Prepend(gtk.IMenuItem) }, items []gtk.IMenuItem) {
@ -240,12 +225,6 @@ type Connector interface {
var _ Connector = (*glib.Object)(nil)
func HandleDestroyCtx(ctx context.Context, connector Connector) context.Context {
ctx, cancel := context.WithCancel(ctx)
connector.Connect("destroy", func(interface{}) { cancel() })
return ctx
}
func OnRightClick(connector Connector, fn func()) {
connector.Connect("button-press-event", func(c Connector, ev *gdk.Event) {
if gts.EventIsRightClick(ev) {

View File

@ -28,6 +28,7 @@ type Avatar struct {
pixbuf *gdk.Pixbuf
url string
size int
cancel context.CancelFunc
}
// Make a better API that allows scaling.
@ -49,6 +50,7 @@ func NewAvatar(size int) *Avatar {
return &avatar
}
// GetSizeRequest returns the virtual size.
func (a *Avatar) GetSizeRequest() (int, int) {
return a.size, a.size
}
@ -66,7 +68,6 @@ func (a *Avatar) SetSizeRequest(w, h int) {
}
func (a *Avatar) loadFunc(size int) *gdk.Pixbuf {
// No URL, draw nothing.
if a.url == "" {
return nil
}
@ -75,44 +76,54 @@ func (a *Avatar) loadFunc(size int) *gdk.Pixbuf {
return a.pixbuf
}
// Refetch and rescale.
a.size = size
// Technically, this will recurse. However, we're changing the size, so
// eventually it should stop.
httputil.AsyncImage(context.Background(), a, a.url)
a.refetch()
if a.pixbuf == nil {
return nil
return nil
}
func (a *Avatar) refetch() {
a.cancelCtx()
ctx, cancel := context.WithCancel(context.Background())
a.cancel = cancel
httputil.AsyncImage(ctx, a, a.url)
}
func (a *Avatar) cancelCtx() {
if a.cancel != nil {
a.cancel()
a.cancel = nil
}
// // Temporarily resize for now.
// p, err := a.pixbuf.ScaleSimple(size, size, gdk.INTERP_HYPER)
// if err != nil {
// p = a.pixbuf
// }
return a.pixbuf
}
// SetRadius is a no-op.
func (a *Avatar) SetRadius(float64) {}
// SetImageURL sets the avatar's source URL and reloads it asynchronously.
func (a *Avatar) SetImageURL(url string) {
a.url = url
a.Avatar.SetImageLoadFunc(a.loadFunc)
a.refetch()
}
// SetFromPixbuf sets the pixbuf.
func (a *Avatar) SetFromPixbuf(pb *gdk.Pixbuf) {
a.cancelCtx()
a.pixbuf = pb
a.Avatar.SetImageLoadFunc(a.loadFunc)
// a.Avatar.SetImageLoadFunc(a.loadFunc)
a.Avatar.QueueDraw()
}
// SetFromAnimation sets the first frame of the animation.
func (a *Avatar) SetFromAnimation(pa *gdk.PixbufAnimation) {
a.cancelCtx()
a.pixbuf = pa.GetStaticImage()
a.Avatar.SetImageLoadFunc(a.loadFunc)
// a.Avatar.SetImageLoadFunc(a.loadFunc)
a.Avatar.QueueDraw()
}
// GetPixbuf returns the underlying pixbuf.
func (a *Avatar) GetPixbuf() *gdk.Pixbuf {
return a.pixbuf
}
@ -127,6 +138,7 @@ func (a *Avatar) GetImage() *gtk.Image {
return nil
}
// GetStorageType always returns IMAGE_PIXBUF.
func (a *Avatar) GetStorageType() gtk.ImageType {
return gtk.IMAGE_PIXBUF
}

View File

@ -5,6 +5,8 @@ import (
"github.com/gotk3/gotk3/gtk"
)
// TODO: move roundimage.Button to rich.ImageButton.
// Button implements a rounded button with a rounded image. This widget only
// supports a full circle for rounding.
type Button struct {
@ -20,7 +22,7 @@ var roundButtonCSS = primitives.PrepareClassCSS("round-button", `
`)
func NewButton() (*Button, error) {
image, _ := NewImage(0)
image := NewImage(0)
image.Show()
b := NewEmptyButton()
@ -38,7 +40,7 @@ func NewEmptyButton() *Button {
}
// NewCustomButton creates a new rounded button with the given Imager. If the
// given Imager implements the Connector interface (aka *StaticImage), then the
// given Imager implements the Connector interface (aka *StillImage), then the
// function will implicitly connect its handlers to the button.
func NewCustomButton(img Imager) (*Button, error) {
b := NewEmptyButton()

View File

@ -2,10 +2,12 @@ package roundimage
import (
"context"
"log"
"math"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/handy"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/cairo"
"github.com/gotk3/gotk3/gdk"
@ -39,83 +41,201 @@ type Imager interface {
GetImage() *gtk.Image
}
// Image represents an image with abstractions for asynchronously fetching
// images from a URL as well as having interchangeable fallbacks.
type Image struct {
*gtk.Image
gtk.Image
Radius float64
style *gtk.StyleContext
procs []imgutil.Processor
ifNone func(context.Context)
icon struct {
name string
size int
}
cancel context.CancelFunc
imgURL string
show bool
}
var _ Imager = (*Image)(nil)
// NewImage creates a new round image. If radius is 0, then it will be half the
// dimensions. If the radius is less than 0, then nothing is rounded.
func NewImage(radius float64) (*Image, error) {
func NewImage(radius float64) *Image {
i, err := gtk.ImageNew()
if err != nil {
return nil, err
log.Panicln("failed to create new roundimage.Image:", err)
}
image := &Image{Image: i, Radius: radius}
style, _ := i.GetStyleContext()
image := &Image{
Image: *i,
Radius: radius,
style: style,
}
// Connect to the draw callback and clip the context.
i.Connect("draw", image.drawer)
// Backup plan if Cairo's Surface is weird.
// var width, height int
// i.Connect("size-allocate", func(i *gtk.Image) {
// w := i.GetAllocatedWidth()
// h := i.GetAllocatedHeight()
// if width != w || height != h {
// log.Println("Image allocate", width, height, i.GetScaleFactor())
// width, height = w, h
// }
// })
return image, nil
return image
}
// NewSizedImage creates a new square image with the given square.
func NewSizedImage(radius float64, size int) *Image {
img := NewImage(radius)
img.SetSizeRequest(size, size)
return img
}
// AddProcessor adds image processors that will be processed on fetched images.
// Images generated internally, such as initials, won't use it.
func (i *Image) AddProcessor(procs ...imgutil.Processor) {
i.procs = append(i.procs, procs...)
}
// GetImage returns the underlying image widget.
func (i *Image) GetImage() *gtk.Image {
return i.Image
return &i.Image
}
// Size returns the minimum side's length. This method is used when Image is
// supposed to be a square/circle.
func (i *Image) Size() int {
w, h := i.GetSizeRequest()
if w > h {
return h
}
return w
}
// SetSIze sets the iamge's physical size. It is a convenient function for
// SetSizeRequest.
func (i *Image) SetSize(size int) {
i.SetSizeRequest(size, size)
}
// SetIfNone sets the callback to be used if an empty URL is given to the image.
// If nil is given, then a fallback icon is used.
func (i *Image) SetIfNone(ifNone func(context.Context)) {
i.ifNone = ifNone
}
// UpdateIfNone updates the image if the image currently does not have one
// fetched from the URL. It does nothing otherwise.
func (i *Image) UpdateIfNone() {
if i.ifNone == nil || i.imgURL != "" {
return
}
i.SetImageURL("")
}
// SetPlaceholderIcon sets the placeholder icon onto the image. The given icon
// size does not affect the image's physical size.
func (i *Image) SetPlaceholderIcon(iconName string, iconPx int) {
i.icon.name = iconName
i.icon.size = iconPx
if i.imgURL == "" {
i.SetImageURL("")
}
}
// GetImageURL gets the image's URL. It returns an empty string if the image
// does not have a URL set.
func (i *Image) GetImageURL() string {
return i.imgURL
}
// SetImageURL sets the image's URL. If the URL is empty, then the placeholder
// icon is used, or the IfNone callback is called, or the pixbuf is cleared.
func (i *Image) SetImageURL(url string) {
// No dynamic sizing support; yolo.
httputil.AsyncImage(context.Background(), i, url, i.procs...)
i.SetImageURLInto(url, i)
}
// SetImageURLInto is SetImageURL, but the image container is given as an
// argument. It is used by other widgets that extend on this Image.
func (i *Image) SetImageURLInto(url string, otherImage httputil.ImageContainer) {
i.imgURL = url
// TODO: fix this context leak: cancel not being called on all paths.
ctx := i.resetCtx()
if url != "" {
// No dynamic sizing support; yolo.
httputil.AsyncImage(ctx, otherImage, url, i.procs...)
return
}
if i.icon.name != "" {
primitives.SetImageIcon(i, i.icon.name, i.icon.size)
goto noImage
}
if i.ifNone != nil {
i.ifNone(ctx)
return
}
noImage:
i.Image.SetFromPixbuf(nil)
i.cancel()
}
func (i *Image) resetCtx() context.Context {
if i.cancel != nil {
i.cancel()
i.cancel = nil
}
// TODO: fix this context leak: cancel not being called on all paths.
ctx, cancel := context.WithCancel(context.Background())
i.cancel = cancel
return ctx
}
// SetRadius sets the radius to be drawn with. If 0 is given, then a full circle
// is drawn, which only works best for images guaranteed to be square.
// Otherwise, the radius is either the number given or the minimum of either the
// width or height.
func (i *Image) SetRadius(r float64) {
i.Radius = r
i.QueueDraw()
}
func (i *Image) drawer(_ interface{}, cc *cairo.Context) bool {
var w = float64(i.GetAllocatedWidth())
var h = float64(i.GetAllocatedHeight())
func (i *Image) drawer(image *gtk.Image, cc *cairo.Context) bool {
// Don't round if we're displaying a stock icon.
if i.imgURL == "" && i.icon.name != "" {
return false
}
var min = w
// Use the smallest side for radius calculation.
if h < w {
a := image.GetAllocation()
w := float64(a.GetWidth())
h := float64(a.GetHeight())
min := w
// Use the largest side for radius calculation.
if h > w {
min = h
}
// Copy the variables in case we need to change them.
var r = i.Radius
switch {
// If radius is less than 0, then don't round.
case r < 0:
case i.Radius < 0:
return false
// If radius is 0, then we have to calculate our own radius.:This only
// works if the image is a square.
case r == 0:
case i.Radius == 0:
// Calculate the radius by dividing a side by 2.
r = (min / 2)
r := (min / 2)
// Draw an arc from 0deg to 360deg.
cc.Arc(w/2, h/2, r, 0, circle)
@ -129,10 +249,13 @@ func (i *Image) drawer(_ interface{}, cc *cairo.Context) bool {
// If radius is more than 0, then we have to calculate the radius from
// the edges.
case r > 0:
case i.Radius > 0:
// StackOverflow is godly.
// https://stackoverflow.com/a/6959843.
// Copy the variables so we can change them later.
r := i.Radius
// Radius should be largest a single side divided by 2.
if max := min / 2; r > max {
r = max
@ -157,3 +280,25 @@ func (i *Image) drawer(_ interface{}, cc *cairo.Context) bool {
return false
}
// UseInitialsIfNone sets the given image to render an initial image if the
// image doesn't have a URL.
func (i *Image) UseInitialsIfNone(initialsFn func() string) {
i.SetIfNone(func(ctx context.Context) {
size := i.Size()
scale := i.GetScaleFactor()
a := handy.AvatarNew(size, initialsFn(), true)
p := a.DrawToPixbuf(size, scale)
if scale > 1 {
surface, _ := gdk.CairoSurfaceCreateFromPixbuf(p, scale, nil)
i.SetFromSurface(surface)
} else {
// Potentially save a copy.
i.SetFromPixbuf(p)
}
})
i.UpdateIfNone()
}

View File

@ -1,49 +1,44 @@
package roundimage
import (
"context"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/cairo"
"github.com/gotk3/gotk3/gdk"
)
// StaticImage is an image that only plays a GIF if it's hovered on top of.
type StaticImage struct {
// StillImage is an image that only plays a GIF if it's hovered on top of.
type StillImage struct {
*Image
animating bool
animation *gdk.PixbufAnimation
}
var (
_ Imager = (*StaticImage)(nil)
_ Connector = (*StaticImage)(nil)
_ httputil.ImageContainer = (*StaticImage)(nil)
_ Imager = (*StillImage)(nil)
_ Connector = (*StillImage)(nil)
_ httputil.ImageContainer = (*StillImage)(nil)
)
func NewStaticImage(parent primitives.Connector, radius float64) (*StaticImage, error) {
i, err := NewImage(radius)
if err != nil {
return nil, err
}
// NewStillImage creates a new static that binds to the parent's handler so
// that the image only animates when parent is hovered over.
func NewStillImage(parent primitives.Connector, radius float64) *StillImage {
i := NewImage(radius)
var s = &StaticImage{i, false, nil}
if parent != nil {
s.ConnectHandlers(parent)
}
s := StillImage{i, false, nil}
s.ConnectHandlers(parent)
return s, nil
return &s
}
func (s *StaticImage) ConnectHandlers(connector primitives.Connector) {
connector.Connect("enter-notify-event", func(interface{}) {
func (s *StillImage) ConnectHandlers(connector primitives.Connector) {
connector.Connect("enter-notify-event", func() {
if s.animation != nil && !s.animating {
s.animating = true
s.Image.SetFromAnimation(s.animation)
}
})
connector.Connect("leave-notify-event", func(interface{}) {
connector.Connect("leave-notify-event", func() {
if s.animation != nil && s.animating {
s.animating = false
s.Image.SetFromPixbuf(s.animation.GetStaticImage())
@ -51,26 +46,26 @@ func (s *StaticImage) ConnectHandlers(connector primitives.Connector) {
})
}
func (s *StaticImage) SetImageURL(url string) {
// No dynamic sizing support; yolo.
httputil.AsyncImage(context.Background(), s, url, s.Image.procs...)
// SetImageURL sets the image's URL.
func (s *StillImage) SetImageURL(url string) {
s.Image.SetImageURLInto(url, s)
}
func (s *StaticImage) SetFromPixbuf(pb *gdk.Pixbuf) {
func (s *StillImage) SetFromPixbuf(pb *gdk.Pixbuf) {
s.animation = nil
s.Image.SetFromPixbuf(pb)
}
func (s *StaticImage) SetFromSurface(sf *cairo.Surface) {
func (s *StillImage) SetFromSurface(sf *cairo.Surface) {
s.animation = nil
s.Image.SetFromSurface(sf)
}
func (s *StaticImage) SetFromAnimation(anim *gdk.PixbufAnimation) {
func (s *StillImage) SetFromAnimation(anim *gdk.PixbufAnimation) {
s.animation = anim
s.Image.SetFromPixbuf(anim.GetStaticImage())
}
func (s *StaticImage) GetAnimation() *gdk.PixbufAnimation {
func (s *StillImage) GetAnimation() *gdk.PixbufAnimation {
return s.animation
}

View File

@ -1,219 +1,35 @@
package rich
import (
"context"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
type IconerFn = func(context.Context, cchat.IconContainer) (func(), error)
type RoundIconContainer interface {
gtk.IWidget
primitives.ImageIconSetter
roundimage.RadiusSetter
import "log"
// ImageURLSetter describes an image that can be set a URL.
type ImageURLSetter interface {
SetImageURL(url string)
GetStorageType() gtk.ImageType
GetPixbuf() *gdk.Pixbuf
GetAnimation() *gdk.PixbufAnimation
}
var (
_ RoundIconContainer = (*roundimage.Image)(nil)
_ RoundIconContainer = (*roundimage.StaticImage)(nil)
)
// Icon represents a rounded image container.
type Icon struct {
*gtk.Revealer // TODO move out
Image RoundIconContainer
// state
url string
type tooltipSetter interface {
SetTooltipText(text string)
}
const DefaultIconSize = 16
// BindRoundImage binds a round image to a rich label state store.
func BindRoundImage(img ImageURLSetter, state LabelStateStorer, tooltip bool) {
var setTooltip func(string)
var _ cchat.IconContainer = (*Icon)(nil)
func NewIcon(sizepx int) *Icon {
img, _ := roundimage.NewImage(0)
img.Show()
return NewCustomIcon(img, sizepx)
}
func NewCustomIcon(img RoundIconContainer, sizepx int) *Icon {
if sizepx == 0 {
sizepx = DefaultIconSize
}
rev, _ := gtk.RevealerNew()
rev.Add(img)
rev.SetRevealChild(false)
rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT)
rev.SetTransitionDuration(50)
i := &Icon{
Revealer: rev,
Image: img,
}
i.SetSize(sizepx)
return i
}
// URL is not thread-safe.
func (i *Icon) URL() string {
return i.url
}
func (i *Icon) CopyPixbuf(dst httputil.ImageContainer) {
switch i.Image.GetStorageType() {
case gtk.IMAGE_PIXBUF:
dst.SetFromPixbuf(i.Image.GetPixbuf())
case gtk.IMAGE_ANIMATION:
dst.SetFromAnimation(i.Image.GetAnimation())
}
}
// Thread-unsafe setter methods should only be called right after construction.
// SetPlaceholderIcon is not thread-safe.
func (i *Icon) SetPlaceholderIcon(iconName string, iconSzPx int) {
i.Image.SetRadius(-1) // square
i.SetRevealChild(true)
if iconName != "" {
primitives.SetImageIcon(i.Image, iconName, iconSzPx)
}
}
// SetSize is not thread-safe.
func (i *Icon) SetSize(szpx int) {
i.Image.SetSizeRequest(szpx, szpx)
}
// Size returns the minimum of the image size. It is not thread-safe.
func (i *Icon) Size() int {
w, h := i.Image.GetSizeRequest()
if h < w {
return h
}
return w
}
// SetIcon is thread-safe.
func (i *Icon) SetIcon(url string) {
gts.ExecAsync(func() { i.SetIconUnsafe(url) })
}
func (i *Icon) AsyncSetIconer(iconer cchat.Iconer, errwrap string) {
// Reveal to show the placeholder.
i.SetRevealChild(true)
// I have a hunch this will never work; as long as Go keeps a reference with
// iconer.Icon, then destroy will never be triggered.
ctx := primitives.HandleDestroyCtx(context.Background(), i)
gts.Async(func() (func(), error) {
f, err := iconer.Icon(ctx, i)
if err != nil {
return nil, errors.Wrap(err, "failed to load iconer")
if tooltip {
tooltipper, ok := img.(tooltipSetter)
if !ok {
log.Panicf("img of type %T is not tooltipSetter", img)
}
return func() { i.Connect("destroy", func(interface{}) { f() }) }, nil
setTooltip = tooltipper.SetTooltipText
}
state.OnUpdate(func() {
image := state.Image()
img.SetImageURL(image.URL)
if setTooltip != nil {
setTooltip(state.Label().String())
}
})
}
// SetIconUnsafe is not thread-safe.
func (i *Icon) SetIconUnsafe(url string) {
// Setting the radius here since we resetted it for a placeholder icon.
i.Image.SetRadius(0)
i.SetRevealChild(true)
i.url = url
i.Image.SetImageURL(i.url)
}
// type EventIcon struct {
// *gtk.EventBox
// Icon *Icon
// }
// func NewEventIcon(sizepx int) *EventIcon {
// icn := NewIcon(sizepx)
// return WrapEventIcon(icn)
// }
// func WrapEventIcon(icn *Icon) *EventIcon {
// icn.Show()
// evb, _ := gtk.EventBoxNew()
// evb.Add(icn)
// return &EventIcon{
// EventBox: evb,
// Icon: icn,
// }
// }
type ToggleButtonImage struct {
gtk.ToggleButton
Labeler
cchat.IconContainer
Label *gtk.Label
Image *Icon
Box *gtk.Box
}
var (
_ gtk.IWidget = (*ToggleButton)(nil)
_ cchat.LabelContainer = (*ToggleButton)(nil)
)
func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
img, _ := roundimage.NewStaticImage(nil, 0)
img.Show()
return NewCustomToggleButtonImage(img, content)
}
func NewCustomToggleButtonImage(img RoundIconContainer, content text.Rich) *ToggleButtonImage {
l := NewLabel(content)
l.Show()
i := NewCustomIcon(img, 0)
i.Show()
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
box.PackStart(i, false, false, 0)
box.PackStart(l, true, true, 5)
box.Show()
b, _ := gtk.ToggleButtonNew()
b.Add(box)
if connector, ok := img.(roundimage.Connector); ok {
connector.ConnectHandlers(b)
}
return &ToggleButtonImage{
ToggleButton: *b,
Labeler: l, // easy inheritance of methods
IconContainer: i,
Label: &l.Label,
Image: i,
Box: box,
}
}

View File

@ -1,134 +1,94 @@
package rich
import (
"context"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
"github.com/pkg/errors"
)
type Labeler interface {
// thread-safe
cchat.LabelContainer // thread-safe
// LabelRenderer is the input/output function to render a rich text segment to
// Pango markup.
type LabelRenderer = func(text.Rich) markup.RenderOutput
// not thread-safe
SetLabelUnsafe(text.Rich)
GetLabel() text.Rich
GetText() string
}
// SuperLabeler represents a label that inherits the current labeler.
type SuperLabeler interface {
SetLabelUnsafe(text.Rich)
}
type LabelerFn = func(context.Context, cchat.LabelContainer) (func(), error)
type Label struct {
gtk.Label
Current text.Rich
// super unexported field for inheritance
super SuperLabeler
}
var (
_ gtk.IWidget = (*Label)(nil)
_ Labeler = (*Label)(nil)
)
func NewLabel(content text.Rich) *Label {
label, _ := gtk.LabelNew("")
label.SetMarkup(markup.Render(content))
label.SetXAlign(0) // left align
label.SetEllipsize(pango.ELLIPSIZE_END)
l := &Label{
Label: *label,
Current: content,
}
return l
}
// NewInheritLabel creates a new label wrapper for structs that inherit this
// label.
func NewInheritLabel(super SuperLabeler) *Label {
l := NewLabel(text.Rich{})
l.super = super
return l
}
func (l *Label) validsuper() bool {
_, ok := l.super.(*Label)
// supers must not be the current struct and must not be nil.
return !ok && l.super != nil
}
func (l *Label) AsyncSetLabel(fn LabelerFn, info string) {
ctx := primitives.HandleDestroyCtx(context.Background(), l)
gts.Async(func() (func(), error) {
f, err := fn(ctx, l)
if err != nil {
return nil, errors.Wrap(err, "failed to load iconer")
}
return func() { l.Connect("destroy", func(interface{}) { f() }) }, nil
// RenderSkipImages is a label renderer that skips images.
func RenderSkipImages(rich text.Rich) markup.RenderOutput {
return markup.RenderCmplxWithConfig(rich, markup.RenderConfig{
SkipImages: true,
NoMentionLinks: true,
})
}
// SetLabel is thread-safe.
func (l *Label) SetLabel(content text.Rich) {
gts.ExecAsync(func() { l.SetLabelUnsafe(content) })
// Label provides an abstraction around a regular GTK label that can be
// self-updated. Widgets that extend off of this (such as ToggleButton) does not
// need to manually
type Label struct {
gtk.Label
label text.Rich
output markup.RenderOutput
render LabelRenderer
}
// SetLabelUnsafe sets the label in the current thread, meaning it's not
// thread-safe. If this label has a super, then it will call that struct's
// SetLabelUnsafe instead of its own.
func (l *Label) SetLabelUnsafe(content text.Rich) {
l.Current = content
var _ gtk.IWidget = (*Label)(nil)
if l.validsuper() {
l.super.SetLabelUnsafe(content)
} else {
l.SetMarkup(markup.Render(content))
// NewStaticLabel creates a static, non-updating label.
func NewStaticLabel(rich text.Rich) *Label {
label, _ := gtk.LabelNew("")
label.SetXAlign(0) // left align
label.SetEllipsize(pango.ELLIPSIZE_END)
if !rich.IsEmpty() {
label.SetMarkup(markup.Render(rich))
}
return &Label{Label: *label}
}
// GetLabel is NOT thread-safe.
func (l *Label) GetLabel() text.Rich {
return l.Current
// NewLabel creates a self-updating label.
func NewLabel(state LabelStateStorer) *Label {
return NewLabelWithRenderer(state, nil)
}
// GetText is NOT thread-safe.
func (l *Label) GetText() string {
return l.Current.Content
// NewLabelWithRenderer creates a self-updating label using the given renderer.
func NewLabelWithRenderer(state LabelStateStorer, r LabelRenderer) *Label {
l := NewStaticLabel(text.Plain(""))
l.render = r
state.OnUpdate(func() { l.SetLabel(state.Label()) })
return l
}
type ToggleButton struct {
gtk.ToggleButton
Label
// Output returns the rendered output.
func (l *Label) Output() markup.RenderOutput {
return l.output
}
var (
_ gtk.IWidget = (*ToggleButton)(nil)
_ cchat.LabelContainer = (*ToggleButton)(nil)
)
// SetLabel sets the label in the current thread, meaning it's not thread-safe.
func (l *Label) SetLabel(content text.Rich) {
// Save a call if the content is empty.
if content.IsEmpty() {
l.label = content
l.output = markup.RenderOutput{}
func NewToggleButton(content text.Rich) *ToggleButton {
l := NewLabel(content)
l.Show()
return
}
b, _ := gtk.ToggleButtonNew()
primitives.BinLeftAlignLabel(b)
l.label = content
b.Add(l)
var out markup.RenderOutput
if l.render != nil {
out = l.render(content)
} else {
out = markup.RenderCmplx(content)
}
return &ToggleButton{*b, *l}
l.output = out
l.SetMarkup(out.Markup)
l.SetTooltipMarkup(out.Markup)
}
// SetRenderer sets a custom renderer. If the given renderer is nil, then the
// default markup renderer is used instead. The label is automatically updated.
func (l *Label) SetRenderer(renderer LabelRenderer) {
l.render = renderer
l.SetLabel(l.label)
}

View File

@ -31,69 +31,35 @@ const (
MaxHeight = 500
)
type WidgetConnector interface {
gtk.IWidget
primitives.Connector
}
var _ WidgetConnector = (*gtk.Label)(nil)
// Labeler implements a rich label that stores an output state.
type Labeler interface {
WidgetConnector
rich.Labeler
Output() markup.RenderOutput
}
// // Labeler implements a rich label that stores an output state.
// type Labeler interface {
// WidgetConnector
// rich.Labeler
// Output() markup.RenderOutput
// }
// Label implements a label that's already bounded to the markup URI handlers.
type Label struct {
*rich.Label
*BoundBox
output markup.RenderOutput
}
var (
_ Labeler = (*Label)(nil)
_ rich.SuperLabeler = (*Label)(nil)
)
// var (
// _ Labeler = (*Label)(nil)
// _ rich.SuperLabeler = (*Label)(nil)
// )
func NewLabel(txt text.Rich) *Label {
l := &Label{}
l.Label = rich.NewInheritLabel(l)
l.Label.SetLabelUnsafe(txt) // test
l.Label = rich.NewStaticLabel(txt)
// Bind and return.
l.BoundBox = BindRichLabel(l)
l.BoundBox = BindRichLabel(l.Label)
return l
}
func (l *Label) Reset() {
l.output = markup.RenderOutput{}
}
func (l *Label) SetLabelUnsafe(content text.Rich) {
l.output = markup.RenderCmplx(content)
l.SetMarkup(l.output.Markup)
}
// Output returns the label's markup output. This function is NOT
// thread-safe.
func (l *Label) Output() markup.RenderOutput {
return l.output
}
// SetOutput sets the internal output and label. It preserves the tail if
// any.
func (l *Label) SetOutput(o markup.RenderOutput) {
l.output = o
l.SetMarkup(o.Markup)
}
// SetUnderlyingOutput sets the output state without changing the label's
// markup. This is useful for internal use cases where the label is updated
// separately.
func (l *Label) SetUnderlyingOutput(o markup.RenderOutput) {
l.output = o
l.Label.SetLabel(text.Plain(""))
}
type ReferenceHighlighter interface {
@ -103,11 +69,11 @@ type ReferenceHighlighter interface {
// BoundBox is a box wrapping elements that can be interacted with from the
// parsed labels.
type BoundBox struct {
label Labeler
label *rich.Label
refer ReferenceHighlighter
}
func BindRichLabel(label Labeler) *BoundBox {
func BindRichLabel(label *rich.Label) *BoundBox {
bound := BoundBox{label: label}
bind(label, bound.activate)
return &bound
@ -206,7 +172,7 @@ func NewPopoverMentioner(rel gtk.IWidget, input string, segment text.Segment) *g
l.Show()
// Enable images???
BindActivator(l)
BindImagePreview(l)
// Make a scrolling text.
scr := scrollinput.NewVScroll(PopoverWidth)
@ -259,13 +225,23 @@ func popoverImg(url string, round bool) gtk.IWidget {
return btn
}
func BindActivator(connector WidgetConnector) {
// WidgetConnector describes a connector that is also a widget.
type WidgetConnector interface {
gtk.IWidget
primitives.Connector
}
var _ WidgetConnector = (*gtk.Label)(nil)
// BindImagePreview binds activate-link to the label callback so that images
// have a popover preview.
func BindImagePreview(connector WidgetConnector) {
bind(connector, nil)
}
// bind connects activate-link. If activator returns true, then nothing is done.
// Activator can be nil.
func bind(connector WidgetConnector, activator func(uri string, r gdk.Rectangle) bool) {
func bind(c WidgetConnector, activator func(uri string, r gdk.Rectangle) bool) {
// This implementation doesn't seem like a good idea. First off, is the
// closure really garbage collected? If it's not, then we have some huge
// issues. Second, if the closure is garbage collected, then when? If it's
@ -273,11 +249,11 @@ func bind(connector WidgetConnector, activator func(uri string, r gdk.Rectangle)
// message, but we're also keeping alive the widget.
var x, y float64
connector.Connect("motion-notify-event", func(_ interface{}, ev *gdk.Event) {
c.Connect("motion-notify-event", func(_ interface{}, ev *gdk.Event) {
x, y = gdk.EventMotionNewFromEvent(ev).MotionVal()
})
connector.Connect("activate-link", func(c WidgetConnector, uri string) bool {
c.Connect("activate-link", func(c WidgetConnector, uri string) bool {
// Make a new rectangle to use in the popover.
r := gdk.Rectangle{}
r.SetX(int(x))
@ -297,8 +273,8 @@ func bind(connector WidgetConnector, activator func(uri string, r gdk.Rectangle)
if !round {
img, _ = gtk.ImageNew()
} else {
r, _ := roundimage.NewImage(0)
img = r.Image
r := roundimage.NewImage(0)
img = &r.Image
}
img.SetSizeRequest(w, h)

View File

@ -123,6 +123,10 @@ type RenderConfig struct {
// mentions.
NoReferencing bool
// SkipImages skips rendering any image markup. This is useful for widgets
// that already render an outside image.
SkipImages bool
// AnchorColor forces all anchors to be of a certain color. This is used if
// the boolean is true. Else, all mention links will not work and regular
// links will be of the default color.
@ -197,7 +201,7 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput {
}
// Only inline images if start == end per specification.
if start == end {
if !cfg.SkipImages && start == end {
if imager := segment.AsImager(); imager != nil {
appended.Open(start, composeImageMarkup(imager))
}

View File

@ -1,22 +1,236 @@
package rich
import (
"context"
"html"
"image"
"runtime"
"sync"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
func Small(text string) string {
return `<span size="small" color="#808080">` + text + "</span>"
// Small is a renderer that makes the plain text small.
func Small(content text.Rich) markup.RenderOutput {
v := `<span size="small" alpha="50%">` + html.EscapeString(content.Content) + "</span>"
return markup.RenderOutput{
Markup: v,
Input: content.Content,
}
}
func MakeRed(content text.Rich) string {
return `<span color="red">` + html.EscapeString(content.Content) + `</span>`
// MakeRed is a renderer that makes the plain text red.
func MakeRed(content text.Rich) markup.RenderOutput {
v := `<span color="red">` + html.EscapeString(content.Content) + `</span>`
return markup.RenderOutput{
Markup: v,
Input: content.Content,
}
}
// used for grabbing text without changing state
type nullLabel struct {
text.Rich
// LabelStateStorer is an interface for LabelState.
type LabelStateStorer interface {
Label() text.Rich
Image() LabelImage
OnUpdate(func()) (remove func())
}
func (n *nullLabel) SetLabel(t text.Rich) { n.Rich = t }
var _ LabelStateStorer = (*LabelState)(nil)
// NameContainer contains a reusable LabelState for cchat.Namer.
type NameContainer struct {
LabelState
state *containerState // for alignment
}
type containerState struct {
// stop is the stored callback.
stop func()
// current is the context stopper being used.
current context.CancelFunc
}
// Stop stops the name container. Calling Stop twice when no new namers are set
// will do nothing.
func (namec *NameContainer) Stop() {
if namec.state != nil {
namec.state.Stop()
namec.LabelState.setLabel(text.Plain(""))
}
}
func (state *containerState) Stop() {
if state.current != nil {
state.current()
state.current = nil
}
if state.stop != nil {
state.stop()
state.stop = nil
}
}
// QueueNamer tries using the namer in the background and queue the setter onto
// the next GLib loop iteration.
func (namec *NameContainer) QueueNamer(ctx context.Context, namer cchat.Namer) {
if namec.state == nil {
namec.state = &containerState{}
runtime.SetFinalizer(namec.state, (*containerState).Stop)
}
namec.Stop()
ctx, cancel := context.WithCancel(ctx)
namec.state.current = cancel
go func() {
stop, err := namer.Name(ctx, namec)
if err != nil {
log.Error(errors.Wrap(err, "failed to activate namer"))
}
gts.ExecAsync(func() {
namec.state.current()
namec.state.current = nil
namec.state.stop = stop
})
}()
}
// BindNamer binds a destructor signal to the name container to cancel a
// context.
func (namec *NameContainer) BindNamer(w primitives.Connector, sig string, namer cchat.Namer) {
namec.QueueNamer(context.Background(), namer)
// TODO: I have a hunch that everything below this will leak to hell. Just a
// hunch.
// namec.Stop()
// ctx, cancel := context.WithCancel(context.Background())
// namec.current = cancel
// // TODO: this might leak, because namec.Stop references the fns list which
// // might reference w indirectly.
// w.Connect(sig, namec.Stop)
// go func() {
// stop, err := namer.Name(ctx, namec)
// if err != nil {
// log.Error(errors.Wrap(err, "failed to activate namer"))
// }
// gts.ExecAsync(func() {
// namec.current()
// namec.current = nil
// namec.stop = stop // nil is OK.
// })
// }()
}
// LabelState provides a container for labels that allow other containers to
// extend upon. A zero-value instance is a valid instance.
type LabelState struct {
// don't copy LabelState.
_ [0]sync.Mutex
label text.Rich
fns map[int]func()
serial int
}
var _ cchat.LabelContainer = (*LabelState)(nil)
// NewLabelState creates a new label state.
func NewLabelState(l text.Rich) *LabelState {
return &LabelState{label: l}
}
// String returns the inside label in plain text.
func (state *LabelState) String() string {
return state.label.Content
}
// Label returns the inside label.
func (state *LabelState) Label() text.Rich {
return state.label
}
// OnUpdate subscribes the given callback. The returned callback removes the
// given callback from the registry.
func (state *LabelState) OnUpdate(fn func()) (remove func()) {
if state.fns == nil {
state.fns = make(map[int]func(), 1)
}
id := state.serial
state.fns[id] = fn
state.serial++
if !state.label.IsEmpty() {
fn()
}
return func() { delete(state.fns, id) }
}
// SetLabel is called by cchat to update the state. The internal subscribed
// callbacks will be called in the main thread.
func (state *LabelState) SetLabel(label text.Rich) {
gts.ExecAsync(func() { state.setLabel(label) })
}
func (state *LabelState) setLabel(label text.Rich) {
state.label = label
for _, fn := range state.fns {
fn()
}
}
// LabelImage is the first image from a label. If
type LabelImage struct {
URL string
Text string
Size image.Point
Avatar bool
}
// HasImage returns true if the label has an image.
func (labelImage LabelImage) HasImage() bool {
return labelImage.URL != ""
}
// Image returns the image, if any. Otherwise, an empty string is returned.
func (state *LabelState) Image() LabelImage {
for _, segment := range state.label.Segments {
if imager := segment.AsImager(); imager != nil {
return LabelImage{
URL: imager.Image(),
Text: imager.ImageText(),
Size: image.Pt(imager.ImageSize()),
}
}
if avatarer := segment.AsAvatarer(); avatarer != nil {
size := avatarer.AvatarSize()
return LabelImage{
URL: avatarer.Avatar(),
Text: avatarer.AvatarText(),
Size: image.Pt(size, size),
Avatar: true,
}
}
}
return LabelImage{}
}

View File

@ -4,15 +4,12 @@ package config
import (
"fmt"
"hash/fnv"
"io"
"strconv"
"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/menu"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
@ -31,17 +28,17 @@ func Restore(conf Configurator) {
gts.Async(func() (func(), error) {
c, err := conf.Configuration()
if err != nil {
return nil, errors.Wrapf(err, "failed to get %s config", conf.Name())
return nil, errors.Wrapf(err, "failed to get %s config", conf.ID())
}
file := serviceFile(conf)
if err := config.UnmarshalFromFile(file, &c); err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal %s config", conf.Name())
return nil, errors.Wrapf(err, "failed to unmarshal %s config", conf.ID())
}
if err := conf.SetConfiguration(c); err != nil {
return nil, errors.Wrapf(err, "failed to set %s config", conf.Name())
return nil, errors.Wrapf(err, "failed to set %s config", conf.ID())
}
return nil, nil
@ -52,16 +49,16 @@ func Spawn(conf Configurator) error {
gts.Async(func() (func(), error) {
c, err := conf.Configuration()
if err != nil {
return nil, errors.Wrapf(err, "failed to get %s config", conf.Name())
return nil, errors.Wrapf(err, "failed to get %s config", conf.ID())
}
file := serviceFile(conf)
err = config.UnmarshalFromFile(file, &c)
err = errors.Wrapf(err, "failed to unmarshal %s config", conf.Name())
err = errors.Wrapf(err, "failed to unmarshal %s config", conf.ID())
return func() {
spawn(conf.Name().String(), c, func(finalized bool) error {
spawn(conf, c, func(finalized bool) error {
if err := conf.SetConfiguration(c); err != nil {
return err
}
@ -81,16 +78,10 @@ func Spawn(conf Configurator) error {
}
func serviceFile(conf Configurator) string {
return fmt.Sprintf("service-%s.json", dumbHash(conf.Name()))
return fmt.Sprintf("services/%s.json", conf.ID())
}
func dumbHash(name text.Rich) string {
hash := fnv.New32a()
io.WriteString(hash, name.String())
return strconv.FormatUint(uint64(hash.Sum32()), 36)
}
func spawn(name string, conf map[string]string, apply func(final bool) error) {
func spawn(c Configurator, conf map[string]string, apply func(final bool) error) {
container := newContainer(conf, func() error { return apply(false) })
container.Grid.SetVAlign(gtk.ALIGN_START)
@ -107,20 +98,25 @@ func spawn(name string, conf map[string]string, apply func(final bool) error) {
b.PackStart(container.ErrHeader, false, false, 0)
b.Show()
var title = "Configure " + name
h, _ := gtk.HeaderBarNew()
h.SetTitle(title)
h.SetTitle("Configure " + c.ID())
h.SetShowCloseButton(true)
h.Show()
var state rich.NameContainer
state.OnUpdate(func() {
h.SetTitle("Configure " + state.String())
})
d, _ := gts.NewEmptyModalDialog()
d.SetDefaultSize(400, 300)
d.Add(b)
d.SetTitle(title)
d.SetTitlebar(h)
d.Connect("destroy", func(*gtk.Dialog) { apply(true) })
// Bind the title.
state.BindNamer(d, "response", c)
// Bind the updater.
d.Connect("response", func(*gtk.Dialog) { apply(true) })
d.Show()
}

View File

@ -131,7 +131,10 @@ type sizeBinder interface {
var _ sizeBinder = (*List)(nil)
func (h *Header) AppMenuBindSize(c sizeBinder) {
c.Connect("size-allocate", func(c sizeBinder) {
update := func(c sizeBinder) {
h.AppMenu.SetSizeRequest(c.GetAllocatedWidth(), -1)
})
}
c.Connect("size-allocate", update)
update(c)
}

View File

@ -35,7 +35,8 @@ var _ ListController = (*List)(nil)
var listCSS = primitives.PrepareClassCSS("service-list", `
.service-list {
padding: 0;
background-color: mix(@theme_bg_color, @theme_fg_color, 0.03);
background-color: @theme_bg_color;
/* background-color: mix(@theme_bg_color, @theme_fg_color, 0.03); */
}
`)

View File

@ -1,6 +1,7 @@
package service
import (
"context"
"fmt"
"github.com/diamondburned/cchat"
@ -11,7 +12,6 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat-gtk/internal/ui/service/config"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
@ -19,7 +19,7 @@ import (
"github.com/gotk3/gotk3/gtk"
)
const IconSize = 48
const IconSize = 42
type ListController interface {
// ClearMessenger is called when a nil slice of servers is set.
@ -43,13 +43,14 @@ type Service struct {
*gtk.Box
Button *gtk.ToggleButton
Icon *rich.Icon
Icon *roundimage.StillImage
Menu *actions.Menu
BodyRev *gtk.Revealer // revealed
BodyList *session.List // not really supposed to be here
service cchat.Service // state
Name rich.NameContainer
Configurator cchat.Configurator
}
@ -67,7 +68,7 @@ var serviceCSS = primitives.PrepareClassCSS("service", `
var serviceButtonCSS = primitives.PrepareClassCSS("service-button", `
.service-button {
padding: 2px;
padding: 6px;
margin: 0;
}
@ -78,58 +79,51 @@ var serviceButtonCSS = primitives.PrepareClassCSS("service-button", `
.service-button:checked {
border-radius: 14px 14px 0 0;
background-color: alpha(@theme_fg_color, 0.2);
/* background-color: alpha(@theme_fg_color, 0.2); */
}
`)
var serviceIconCSS = primitives.PrepareClassCSS("service-icon", `
.service-icon { padding: 4px }
`)
var serviceIconCSS = primitives.PrepareClassCSS("service-icon", ``)
func NewService(svc cchat.Service, svclctrl ListController) *Service {
service := &Service{
service := Service{
service: svc,
ListController: svclctrl,
}
service.Name.QueueNamer(context.Background(), svc)
service.BodyList = session.NewList(service)
service.BodyList = session.NewList(&service)
service.BodyList.Show()
service.BodyRev, _ = gtk.RevealerNew()
service.BodyRev.SetRevealChild(false) // TODO persistent state
service.BodyRev.SetTransitionDuration(50)
service.BodyRev.SetTransitionDuration(100)
service.BodyRev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_DOWN)
service.BodyRev.Add(service.BodyList)
service.BodyRev.Show()
// TODO: have it so the button changes to the session avatar when collapsed
avatar := roundimage.NewAvatar(IconSize)
avatar.SetText(svc.Name().String())
avatar.Show()
service.Icon = rich.NewCustomIcon(avatar, IconSize)
service.Icon.Show()
// potentially nonstandard
service.Icon.SetPlaceholderIcon("text-html-symbolic", IconSize)
// TODO: hover for name. We use tooltip for now.
service.Icon.SetTooltipMarkup(markup.Render(svc.Name()))
serviceIconCSS(service.Icon)
if iconer := svc.AsIconer(); iconer != nil {
service.Icon.AsyncSetIconer(iconer, "Failed to set service icon")
}
service.Button, _ = gtk.ToggleButtonNew()
service.Button.Add(service.Icon)
service.Button.SetRelief(gtk.RELIEF_NONE)
service.Button.Show()
serviceButtonCSS(service.Button)
service.Icon = roundimage.NewStillImage(service.Button, 0)
service.Icon.SetSize(IconSize)
service.Icon.UseInitialsIfNone(service.Name.String)
service.Icon.Show()
serviceIconCSS(service.Icon)
rich.BindRoundImage(service.Icon, &service.Name, true)
// TODO: hover for name. We use tooltip for now.
service.Button.Add(service.Icon)
service.Button.Connect("clicked", func(tb *gtk.ToggleButton) {
revealed := !service.GetRevealChild()
service.SetRevealChild(revealed)
tb.SetActive(revealed)
})
serviceButtonCSS(service.Button)
// Bind session.* actions into row.
service.Menu = actions.NewMenu("service")
@ -153,9 +147,9 @@ func NewService(svc cchat.Service, svclctrl ListController) *Service {
serviceCSS(service.Box)
// Bind a drag and drop on the button instead of the entire box.
drag.BindDraggable(service, "network-workgroup", svclctrl.MoveService, service.Button)
drag.BindDraggable(&service, "network-workgroup", svclctrl.MoveService, service.Button)
return service
return &service
}
// SetRevealChild sets whether or not the service should reveal all sessions.
@ -204,7 +198,7 @@ func (s *Service) AddSession(ses cchat.Session) *session.Row {
}
func (s *Service) ID() string {
return s.service.Name().Content
return s.service.ID()
}
func (s *Service) Service() cchat.Service {
@ -232,7 +226,7 @@ func (s *Service) MoveSession(id, movingID string) {
}
func (s *Service) Breadcrumb() string {
return s.service.Name().Content
return s.Name.String()
}
func (s *Service) ParentBreadcrumb() traverse.Breadcrumber {
@ -241,15 +235,13 @@ func (s *Service) ParentBreadcrumb() traverse.Breadcrumber {
func (s *Service) SaveAllSessions() {
var sessions = s.BodyList.Sessions()
var keyrings = make([]keyring.Session, 0, len(sessions))
var keyrings = keyring.NewService(s.service, len(sessions))
for _, s := range sessions {
if k := keyring.ConvertSession(s.Session); k != nil {
keyrings = append(keyrings, *k)
}
for _, session := range sessions {
keyrings.Add(session.Session, s.Name.String())
}
keyring.SaveSessions(s.service, keyrings)
keyrings.Save()
}
func (s *Service) RestoreSession(row *session.Row, id string) {
@ -265,7 +257,7 @@ func (s *Service) RestoreSession(row *session.Row, id string) {
log.Error(fmt.Errorf(
"Missing keyring for service %s, session ID %s",
s.service.Name().Content, id,
s.service.ID(), id,
))
}
@ -276,8 +268,10 @@ func (s *Service) restoreAll() {
return
}
// Session is not a pointer, so we can pass it into arguments safely.
for _, ses := range keyring.RestoreSessions(s.service) {
service := keyring.Restore(s.service)
for _, ses := range service.Sessions {
// Session is not a pointer, so we can pass it into arguments safely.
row := s.AddLoadingSession(ses.ID, ses.Name)
row.RestoreSession(rs, ses)
}

View File

@ -1,11 +1,9 @@
package button
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
)
@ -13,20 +11,32 @@ const UnreadColorDefs = `
@define-color mentioned rgb(240, 71, 71);
`
type ToggleButtonImage struct {
*rich.ToggleButtonImage
const IconSize = 38
type ToggleButton struct {
gtk.ToggleButton
Label *rich.Label
labelRev *gtk.Revealer
// These fields are nil if image is false.
Image *roundimage.StillImage
imageRev *gtk.Revealer
Box *gtk.Box
state rich.LabelStateStorer
clicked func(bool)
readcss primitives.ClassEnum
err error
icon string // whether or not the button has an icon
iconSz int
icon string // whether or not the button has an icon
label bool
}
var _ cchat.IconContainer = (*ToggleButtonImage)(nil)
var serverButtonCSS = primitives.PrepareClassCSS("server-button", `
.server-button {
min-width: 0px;
}
.selected-server {
border-left: 2px solid mix(@theme_base_color, @theme_fg_color, 0.1);
background-color: mix(@theme_base_color, @theme_fg_color, 0.1);
@ -34,14 +44,14 @@ var serverButtonCSS = primitives.PrepareClassCSS("server-button", `
}
.read {
color: alpha(@theme_fg_color, 0.5);
/* color: alpha(@theme_fg_color, 0.5); */
border-left: 2px solid transparent;
}
.unread {
color: @theme_fg_color;
border-left: 2px solid alpha(@theme_fg_color, 0.75);
background-color: alpha(@theme_fg_color, 0.05);
/* background-color: alpha(@theme_fg_color, 0.05); */
}
.mentioned {
@ -52,34 +62,55 @@ var serverButtonCSS = primitives.PrepareClassCSS("server-button", `
`+UnreadColorDefs)
func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
b := rich.NewToggleButtonImage(content)
return WrapToggleButtonImage(b)
}
// NewToggleButton creates a new toggle button.
func NewToggleButton(state rich.LabelStateStorer) *ToggleButton {
label := rich.NewLabelWithRenderer(state, rich.RenderSkipImages)
label.SetMarginStart(5)
label.Show()
func WrapToggleButtonImage(b *rich.ToggleButtonImage) *ToggleButtonImage {
b.Show()
labelRev, _ := gtk.RevealerNew()
labelRev.Add(label)
labelRev.SetRevealChild(true)
labelRev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT)
labelRev.SetTransitionDuration(100)
labelRev.Show()
tb := &ToggleButtonImage{
ToggleButtonImage: b,
clicked: func(bool) {},
}
type activeGetter interface {
GetActive() bool
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
box.SetHAlign(gtk.ALIGN_START)
box.PackStart(labelRev, false, false, 0)
box.Show()
button, _ := gtk.ToggleButtonNew()
button.SetRelief(gtk.RELIEF_NONE)
button.Add(box)
button.Show()
tb := &ToggleButton{
ToggleButton: *button,
Label: label,
labelRev: labelRev,
Box: box,
state: state,
clicked: func(bool) {},
}
tb.SetShowLabel(true)
tb.Connect("clicked", func(w *gtk.ToggleButton) { tb.clicked(w.GetActive()) })
serverButtonCSS(tb)
// Ensure that we display an icon when we receive one.
state.OnUpdate(func() {
if state.Image().HasImage() {
tb.ensureImage()
}
})
return tb
}
func (b *ToggleButtonImage) SetSelected(selected bool) {
// Set the clickability the opposite as the boolean.
// b.SetSensitive(!selected)
b.SetInconsistent(selected)
// SetSelected sets the button's intermediate state and appearance to look
// like it's clicked without triggering the callback.
func (b *ToggleButton) SetSelected(selected bool) {
if selected {
primitives.AddClass(b, "selected-server")
} else {
@ -92,11 +123,36 @@ func (b *ToggleButtonImage) SetSelected(selected bool) {
}
}
func (b *ToggleButtonImage) SetClicked(clicked func(bool)) {
// SetShowLabel sets whether or not to show the button's label. If the button
// does not have an image, then the label is always shown.
func (b *ToggleButton) SetShowLabel(showLabel bool) {
b.label = showLabel
// Enforce particular rules that are unfeasible at the moment. When these
// conditions change elsewhere, this function will be called again.
if b.imageRev != nil {
showLabel = showLabel || !b.imageRev.GetRevealChild()
} else {
showLabel = true
}
// Expand the box when we're showing the label.
b.SetHExpand(showLabel)
b.labelRev.SetRevealChild(showLabel)
}
// GetShowLabel gets whether or not the label is being shown.
func (b *ToggleButton) GetShowLabel() bool {
return b.labelRev.GetRevealChild() || b.label
}
// SetClicked sets the callback to run when clicked. It overrides the previous
// callback.
func (b *ToggleButton) SetClicked(clicked func(bool)) {
b.clicked = clicked
}
func (b *ToggleButtonImage) SetClickedIfTrue(clickedIfTrue func()) {
func (b *ToggleButton) SetClickedIfTrue(clickedIfTrue func()) {
b.clicked = func(clicked bool) {
if clicked {
clickedIfTrue()
@ -104,33 +160,74 @@ func (b *ToggleButtonImage) SetClickedIfTrue(clickedIfTrue func()) {
}
}
func (b *ToggleButtonImage) SetNormal() {
b.SetLabelUnsafe(b.GetLabel())
// b.menu.SetItems(b.extraMenu)
func (b *ToggleButton) ensureImage() {
if b.Image != nil {
return
}
if b.icon != "" {
b.Image.SetPlaceholderIcon(b.icon, b.Image.Size())
b.Image = roundimage.NewStillImage(b, 0)
b.Image.SetSizeRequest(IconSize, IconSize)
b.Image.Show()
// TODO: tooltip false once hover is implemented.
rich.BindRoundImage(b.Image, b.state, true)
b.imageRev, _ = gtk.RevealerNew()
b.imageRev.Add(b.Image)
b.imageRev.SetRevealChild(true)
b.imageRev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT)
b.imageRev.SetTransitionDuration(75)
b.imageRev.Show()
b.Box.PackStart(b.imageRev, false, false, 0)
b.Box.ReorderChild(b.imageRev, 0)
// Set the callback to render the user's initials if there is no name.
b.Image.UseInitialsIfNone(func() string {
return b.state.Label().String()
})
// Restore the label's visible state now that we have an image.
b.SetShowLabel(b.label)
}
// UseEmptyIcon forces the ToggleButton to show an icon, even if it's a
// placeholder.
func (b *ToggleButton) UseEmptyIcon() {
b.ensureImage()
}
// SetNormal sets the button's state to normal from either loading or failed.
func (b *ToggleButton) SetNormal() {
b.Label.SetRenderer(rich.RenderSkipImages)
b.SetSensitive(true)
if b.Image != nil && b.icon != "" {
b.SetPlaceholderIcon(b.icon, b.Image.Size())
}
}
func (b *ToggleButtonImage) SetLoading() {
b.SetLabelUnsafe(b.GetLabel())
// SetLoading sets the button's state to loading.
func (b *ToggleButton) SetLoading() {
b.Label.SetRenderer(rich.RenderSkipImages)
b.SetSensitive(false)
if b.icon != "" {
b.Image.SetPlaceholderIcon("content-loading-symbolic", b.Image.Size())
if b.Image != nil && b.icon != "" {
b.SetPlaceholderIcon("content-loading-symbolic", b.Image.Size())
}
}
func (b *ToggleButtonImage) SetFailed(err error, retry func()) {
b.Label.SetMarkup(rich.MakeRed(b.GetLabel()))
func (b *ToggleButton) SetFailed(err error, retry func()) {
b.Label.SetRenderer(rich.MakeRed)
b.SetSensitive(true)
// If we have an icon set, then we can use the failed icon.
if b.icon != "" {
b.Image.SetPlaceholderIcon("computer-fail-symbolic", b.Image.Size())
if b.Image != nil && b.icon != "" {
b.SetPlaceholderIcon("computer-fail-symbolic", b.Image.Size())
}
}
func (b *ToggleButtonImage) SetUnreadUnsafe(unread, mentioned bool) {
func (b *ToggleButton) SetUnreadUnsafe(unread, mentioned bool) {
switch {
// Prioritize mentions over unreads.
case mentioned:
@ -142,16 +239,8 @@ func (b *ToggleButtonImage) SetUnreadUnsafe(unread, mentioned bool) {
}
}
func (b *ToggleButtonImage) SetPlaceholderIcon(iconName string, iconSzPx int) {
func (b *ToggleButton) SetPlaceholderIcon(iconName string, iconSzPx int) {
b.icon = iconName
b.ensureImage()
b.Image.SetPlaceholderIcon(iconName, iconSzPx)
}
func (b *ToggleButtonImage) SetIcon(url string) {
gts.ExecAsync(func() { b.SetIconUnsafe(url) })
}
func (b *ToggleButtonImage) SetIconUnsafe(url string) {
b.icon = ""
b.Image.SetIconUnsafe(url)
}

View File

@ -0,0 +1,3 @@
package button
type CollapsibleLabel struct{}

View File

@ -13,22 +13,28 @@ import (
)
type Controller interface {
ClearMessenger()
MessengerSelected(*ServerRow)
// SelectColumnatedLister is called when the user clicks a server lister
// with its with Columnate method returning true. If lister is nil, then the
// impl should clear it.
SelectColumnatedLister(*ServerRow, cchat.Lister)
}
// Children is a children server with a reference to the parent. By default, a
// children will contain hollow rows. They are rows that do not yet have any
// widgets. This changes as soon as Row's Load is called.
type Children struct {
*gtk.Box
gtk.Box
Controller
load *loading.Button // only not nil while loading
loading bool
Rows []*ServerRow
Rows []*ServerRow
expand bool
Parent traverse.Breadcrumber
rowctrl Controller
Parent traverse.Breadcrumber
// Unreadable state for children rows to use. The parent row that has this
// Children will bind a handler to this.
@ -37,8 +43,6 @@ type Children struct {
var childrenCSS = primitives.PrepareClassCSS("server-children", `
.server-children {
margin: 0;
margin-top: 3px;
border-radius: 0;
}
`)
@ -47,8 +51,8 @@ var childrenCSS = primitives.PrepareClassCSS("server-children", `
// widgets.
func NewHollowChildren(p traverse.Breadcrumber, ctrl Controller) *Children {
return &Children{
Parent: p,
rowctrl: ctrl,
Parent: p,
Controller: ctrl,
}
}
@ -60,7 +64,7 @@ func NewChildren(p traverse.Breadcrumber, ctrl Controller) *Children {
}
func (c *Children) IsHollow() bool {
return c.Box == nil
return c.Box.Object == nil
}
// Init ensures that the children container is not hollow. It does nothing after
@ -70,10 +74,13 @@ func (c *Children) IsHollow() bool {
// Nothing but ServerRow should call this method.
func (c *Children) Init() {
if c.IsHollow() {
c.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
c.Box.SetMarginStart(ChildrenMargin)
c.Box.SetHExpand(true)
childrenCSS(c.Box)
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
box.SetMarginStart(ChildrenMargin)
c.Box = *box
childrenCSS(box)
// Show all margins by default.
c.SetExpand(true)
// Check if we're still loading. This is effectively restoring the
// state that was set before we had widgets.
@ -90,7 +97,7 @@ func (c *Children) Init() {
func (c *Children) Reset() {
// If the children container isn't hollow, then we have to remove the known
// rows from the container box.
if c.Box != nil {
if c.Box.Object != nil {
// Remove old servers from the list.
for _, row := range c.Rows {
if row.IsHollow() {
@ -104,6 +111,26 @@ func (c *Children) Reset() {
c.Rows = nil
}
// SetExpand sets whether or not to expand the margin and show the labels.
func (c *Children) SetExpand(expand bool) {
AssertUnhollow(c)
c.expand = expand
if expand {
primitives.AddClass(c, "expand")
} else {
primitives.RemoveClass(c, "expand")
}
for _, row := range c.Rows {
// Ensure that the row is initialized. If we're expanded, then show the
// label.
if row.Button != nil {
row.SetShowLabel(expand)
}
}
}
// setLoading shows the loading circle as a list child. If hollow, this function
// will only update the state.
func (c *Children) setLoading() {
@ -168,7 +195,7 @@ func (c *Children) SetServersUnsafe(servers []cchat.Server) {
if server == nil {
log.Panicln("one of given servers in SetServers is nil at ", i)
}
c.Rows[i] = NewHollowServer(c, server, c.rowctrl)
c.Rows[i] = NewHollowServer(c, server, c)
}
// We should not unhollow everything here, but rather on uncollapse.
@ -205,7 +232,7 @@ func (c *Children) UpdateServerUnsafe(update cchat.ServerUpdate) {
prevID, replace := update.PreviousID()
// TODO: I don't think this code unhollows a new server.
var newServer = NewHollowServer(c, update, c.rowctrl)
var newServer = NewHollowServer(c, update, c)
var i, oldRow = c.findID(prevID)
// If we're appending a new row, then replace is false.
@ -246,23 +273,13 @@ func (c *Children) LoadAll() {
// Restore expansion if possible.
savepath.Restore(row, row.Button)
}
}
// Check if we have icons.
var hasIcon bool
// ForceIcons forces all of the children's row to show icons.
func (c *Children) ForceIcons() {
for _, row := range c.Rows {
if row.HasIcon() {
hasIcon = true
break
}
}
// If we have an icon, then show all other possibly empty icons. HdyAvatar
// will generate a placeholder.
if hasIcon {
for _, row := range c.Rows {
row.UseEmptyIcon()
}
row.UseEmptyIcon()
row.SetShowLabel(c.expand)
}
}

View File

@ -5,19 +5,20 @@ import (
"fmt"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/gotk3/gotk3/gtk"
)
// Buffer represents an unbuffered API around the text buffer.
type Buffer struct {
*gtk.TextBuffer
name string
name rich.LabelStateStorer
cmder cchat.Commander
}
// NewBuffer creates a new buffer with the given SessionCommander, or returns
// nil if cmder is nil.
func NewBuffer(name string, cmder cchat.Commander) *Buffer {
func NewBuffer(name rich.LabelStateStorer, cmder cchat.Commander) *Buffer {
if cmder == nil {
return nil
}

View File

@ -39,10 +39,14 @@ func SpawnDialog(buf *Buffer) {
s.Show()
h, _ := gtk.HeaderBarNew()
h.SetTitle("Commander: " + buf.name)
h.SetShowCloseButton(true)
h.Show()
rm := buf.name.OnUpdate(func() {
h.SetTitle("Commander: " + buf.name.Label().Content)
})
h.Connect("destroy", rm)
d, _ := gts.NewEmptyModalDialog()
d.SetDefaultSize(450, 250)
d.SetTitlebar(h)

View File

@ -1,25 +1,24 @@
package server
import (
"context"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/actions"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/service/savepath"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/button"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/commander"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
const ChildrenMargin = 24
const IconSize = 32
const ChildrenMargin = 0 // refer to style.css
func AssertUnhollow(hollower interface{ IsHollow() bool }) {
if hollower.IsHollow() {
@ -27,14 +26,21 @@ func AssertUnhollow(hollower interface{ IsHollow() bool }) {
}
}
// ParentController controls ServerRow's container, which is the Children
// struct.
type ParentController interface {
Controller
ForceIcons()
}
type ServerRow struct {
*gtk.Box
Avatar *roundimage.Avatar
Button *button.ToggleButtonImage
Button *button.ToggleButton
ActionsMenu *actions.Menu
Server cchat.Server
ctrl Controller
name rich.NameContainer
ctrl ParentController
parentcrumb traverse.Breadcrumber
@ -46,10 +52,12 @@ type ServerRow struct {
childrev *gtk.Revealer
children *Children
serverList cchat.Lister
serverStop func()
// State that's updated even when stale. Initializations will use these.
unread bool
mentioned bool
showLabel bool
// callback to cancel unread indicator
cancelUnread func()
@ -59,25 +67,28 @@ var serverCSS = primitives.PrepareClassCSS("server", `
/* Ignore first child because .server-children already covers this */
.server:not(:first-child) {
margin: 0;
margin-top: 3px;
border-radius: 0;
}
.server.active-column {
background-color: mix(@theme_bg_color, @theme_selected_bg_color, 0.25);
}
`)
// NewHollowServer creates a new hollow ServerRow. It will automatically create
// hollow children containers and rows for the given server.
func NewHollowServer(p traverse.Breadcrumber, sv cchat.Server, ctrl Controller) *ServerRow {
var serverRow = &ServerRow{
func NewHollowServer(p traverse.Breadcrumber, sv cchat.Server, ctrl ParentController) *ServerRow {
serverRow := ServerRow{
parentcrumb: p,
ctrl: ctrl,
Server: sv,
cancelUnread: func() {},
}
var (
lister = sv.AsLister()
messenger = sv.AsMessenger()
)
serverRow.name.QueueNamer(context.Background(), sv)
lister := sv.AsLister()
messenger := sv.AsMessenger()
switch {
case lister != nil:
@ -87,7 +98,7 @@ func NewHollowServer(p traverse.Breadcrumber, sv cchat.Server, ctrl Controller)
case messenger != nil:
if unreader := messenger.AsUnreadIndicator(); unreader != nil {
gts.Async(func() (func(), error) {
c, err := unreader.UnreadIndicate(serverRow)
c, err := unreader.UnreadIndicate(&serverRow)
if err != nil {
return nil, errors.Wrap(err, "Failed to use unread indicator")
}
@ -97,7 +108,7 @@ func NewHollowServer(p traverse.Breadcrumber, sv cchat.Server, ctrl Controller)
}
}
return serverRow
return &serverRow
}
// Init brings the row out of the hollow state. It loads the children (if any),
@ -108,19 +119,13 @@ func (r *ServerRow) Init() {
}
// Initialize the row, which would fill up the button and others as well.
r.Avatar = roundimage.NewAvatar(IconSize)
r.Avatar.SetText(r.Server.Name().Content)
r.Avatar.Show()
btn := rich.NewCustomToggleButtonImage(r.Avatar, r.Server.Name())
btn.Show()
r.Button = button.WrapToggleButtonImage(btn)
r.Button.Box.SetHAlign(gtk.ALIGN_START)
r.Button.SetRelief(gtk.RELIEF_NONE)
r.Button = button.NewToggleButton(&r.name)
r.Button.SetShowLabel(r.showLabel)
r.Button.Show()
r.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
r.Box.SetHAlign(gtk.ALIGN_FILL)
r.Box.PackStart(r.Button, false, false, 0)
serverCSS(r.Box)
@ -131,9 +136,6 @@ func (r *ServerRow) Init() {
// Ensure errors are displayed.
r.childrenSetErr(r.childrenErr)
// Try to set an icon.
r.SetIconer(r.Server)
// Connect the destroyer, if any.
r.Connect("destroy", func(interface{}) { r.cancelUnread() })
@ -141,7 +143,7 @@ func (r *ServerRow) Init() {
r.Button.SetUnreadUnsafe(r.unread, r.mentioned) // update with state
if cmder := r.Server.AsCommander(); cmder != nil {
r.cmder = commander.NewBuffer(r.Server.Name().String(), cmder)
r.cmder = commander.NewBuffer(&r.name, cmder)
r.ActionsMenu.AddAction("Command Prompt", r.cmder.ShowDialog)
}
@ -152,13 +154,21 @@ func (r *ServerRow) Init() {
}
})
// Bring up the icons of all the current level's rows if we have one.
r.name.OnUpdate(func() {
if r.name.Image().HasImage() {
r.ctrl.ForceIcons()
}
})
var (
lister = r.Server.AsLister()
columnate = lister != nil && lister.Columnate()
messenger = r.Server.AsMessenger()
)
switch {
case lister != nil:
case lister != nil && !columnate:
primitives.AddClass(r, "server-list")
r.children.Init()
r.children.Show()
@ -171,15 +181,35 @@ func (r *ServerRow) Init() {
r.Box.PackStart(r.childrev, false, false, 0)
r.Button.SetClicked(r.SetRevealChild)
case lister != nil && columnate:
primitives.AddClass(r, "server-list")
primitives.AddClass(r, "server-columnate")
r.Button.SetClicked(func(active bool) {
if active {
r.ctrl.SelectColumnatedLister(r, lister)
} else {
r.ctrl.SelectColumnatedLister(r, nil)
}
})
case messenger != nil:
primitives.AddClass(r, "server-message")
r.Button.SetClicked(func(bool) { r.ctrl.MessengerSelected(r) })
r.Button.SetClicked(func(active bool) {
if active {
r.ctrl.MessengerSelected(r)
} else {
r.ctrl.ClearMessenger()
}
})
}
// Restore the label visibility state.
r.SetShowLabel(r.showLabel)
}
// GetActiveServerMessage returns true if the row is currently selected AND it
// IsActiveServerMessage returns true if the row is currently selected AND it
// is a message row.
func (r *ServerRow) GetActiveServerMessage() bool {
func (r *ServerRow) IsActiveServerMessage() bool {
// If the button is nil, then that probably means we're still in a hollow
// state. This obviously means nothing is being selected.
if r.Button == nil {
@ -196,7 +226,7 @@ func (r *ServerRow) SetUnread(unread, mentioned bool) {
func (r *ServerRow) SetUnreadUnsafe(unread, mentioned bool) {
// We're never unread if we're reading this current server.
if r.GetActiveServerMessage() {
if r.IsActiveServerMessage() {
unread, mentioned = false, false
}
@ -243,12 +273,14 @@ func (r *ServerRow) load(finish func(error)) {
}
go func() {
var err = list.Servers(children)
stop, err := list.Servers(children)
if err != nil {
log.Error(errors.Wrap(err, "Failed to get servers"))
}
gts.ExecAsync(func() {
r.serverStop = stop
// Announce that we're not loading anymore.
r.children.setNotLoading()
@ -273,6 +305,11 @@ func (r *ServerRow) Reset() {
r.children.Destroy()
}
if r.serverStop != nil {
r.serverStop()
r.serverStop = nil
}
// Reset the state.
r.ActionsMenu.Reset()
r.serverList = nil
@ -298,14 +335,36 @@ func (r *ServerRow) childrenSetErr(err error) {
// UseEmptyIcon forces the row to show a placeholder icon.
func (r *ServerRow) UseEmptyIcon() {
AssertUnhollow(r)
r.Button.Image.SetSize(IconSize)
r.Button.Image.SetRevealChild(true)
r.Button.UseEmptyIcon()
}
// HasIcon returns true if the current row has an icon.
func (r *ServerRow) HasIcon() bool {
return !r.IsHollow() && r.Button.Image.GetRevealChild()
return !r.IsHollow() && r.Button.Image != nil
}
// SetShowLabel sets whether or not to show the button's (and its children's, if
// any)'s icons.
func (r *ServerRow) SetShowLabel(showLabel bool) {
r.showLabel = showLabel
if r.IsHollow() {
return
}
r.Button.SetShowLabel(showLabel)
// We'd want the button to be wide if we're showing the label. Otherwise,
// it can be small.
if r.Button.GetShowLabel() {
r.Box.SetHAlign(gtk.ALIGN_FILL)
} else {
r.Box.SetHAlign(gtk.ALIGN_START)
}
if r.children != nil && !r.children.IsHollow() {
r.children.SetExpand(showLabel)
}
}
func (r *ServerRow) ParentBreadcrumb() traverse.Breadcrumber {
@ -316,28 +375,18 @@ func (r *ServerRow) Breadcrumb() string {
if r.IsHollow() {
return ""
}
return r.Button.GetText()
return r.name.String()
}
// ID returns the server ID.
func (r *ServerRow) ID() cchat.ID {
return r.Server.ID()
}
func (r *ServerRow) SetLabelUnsafe(name text.Rich) {
AssertUnhollow(r)
r.Button.SetLabelUnsafe(name)
r.Avatar.SetText(name.Content)
}
// SetIconer takes in a Namer for AsIconer.
func (r *ServerRow) SetIconer(v cchat.Namer) {
AssertUnhollow(r)
if iconer := v.AsIconer(); iconer != nil {
r.Button.Image.SetSize(IconSize)
r.Button.Image.AsyncSetIconer(iconer, "Error getting server icon URL")
}
// Name returns the name state.
func (r *ServerRow) Name() rich.LabelStateStorer {
return &r.name
}
// SetLoading is called by the parent struct.
@ -357,7 +406,6 @@ func (r *ServerRow) SetFailed(err error, retry func()) {
r.SetSensitive(true)
r.SetTooltipText(err.Error())
r.Button.SetFailed(err, retry)
r.Button.Label.SetMarkup(rich.MakeRed(r.Button.GetLabel()))
r.ActionsMenu.Reset()
r.ActionsMenu.AddAction("Retry", retry)
}
@ -372,14 +420,6 @@ func (r *ServerRow) SetDone() {
r.SetTooltipText("")
}
// func (r *ServerRow) SetNormalExtraMenu(items []menu.Item) {
// AssertUnhollow(r)
// r.Button.SetNormalExtraMenu(items)
// r.SetSensitive(true)
// r.SetTooltipText("")
// }
// SetSelected is used for highlighting the current message server.
func (r *ServerRow) SetSelected(selected bool) {
AssertUnhollow(r)
@ -411,6 +451,12 @@ func (r *ServerRow) SetRevealChild(reveal bool) {
// Save the path.
savepath.Update(r, reveal)
if reveal {
primitives.AddClass(r, "expanded")
} else {
primitives.RemoveClass(r, "expanded")
}
// If this isn't a reveal, then we don't need to load.
if !reveal {
return

View File

@ -22,6 +22,8 @@ type Breadcrumber interface {
type BreadcrumbNamer interface {
// Breadcrumb returns the breadcrumb name.
Breadcrumb() string
// TODO: make BreadcrumbNamer return LabelState.
}
// Traverse traverses the given breadcrumber recursively. If traverser returns

View File

@ -0,0 +1,64 @@
package serverpane
import (
"github.com/gotk3/gotk3/gtk"
)
// Paned replaces gtk.Paned or gtk.Box to allow an optional second pane.
type Paned struct {
gtk.IWidget
Box *gtk.Box
orien gtk.Orientation
w1 gtk.IWidget
w2 gtk.IWidget
}
// NewPaned creates a new empty pane.
func NewPaned(w1 gtk.IWidget, o gtk.Orientation) *Paned {
// box holds either paned or w1.
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
box.PackStart(w1, true, true, 0)
return &Paned{
IWidget: box,
Box: box,
orien: o,
w1: w1,
}
}
func (p *Paned) Destroy() {
p.Box.Destroy()
*p = Paned{}
}
func (p *Paned) Show() {
p.Box.Show()
}
// AddSide adds a side widget. If a second widget is already added, then it is
// removed from the pane.
func (p *Paned) AddSide(w gtk.IWidget) {
if p.w2 != nil {
p.Box.Remove(p.w2)
}
p.w2 = w
p.Box.PackStart(p.w2, true, true, 0)
p.Box.SetChildPacking(p.w1, false, false, 0, gtk.PACK_START)
}
// Remove removes either w1 or w2. If neither matches, then nothing is done.
func (p *Paned) Remove(w gtk.IWidget) {
switch w {
case p.w1:
panic("p.w1 must not be removed")
case p.w2:
p.Box.Remove(p.w2)
p.w2 = nil
p.Box.SetChildPacking(p.w1, true, true, 0, gtk.PACK_START)
}
}

View File

@ -10,6 +10,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/serverpane"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
)
@ -20,59 +21,102 @@ const ListWidth = 200
// SessionController extends server.Controller to add needed methods that the
// specific top-level servers container needs.
type SessionController interface {
server.Controller
ClearMessenger()
MessengerSelected(*server.ServerRow)
}
// Servers wraps around a list of servers inherited from Children. It's the
// container that's displayed on the right of the service sidebar.
// Servers wraps around a list of servers inherited from Children to display a
// Lister in its own box instead of as a nested list. It's the container that's
// displayed on the right of the service sidebar.
type Servers struct {
*gtk.Box
gtk.Stack
SessionController
spinner *spinner.Boxed
// Main is the horizontal box containing the current struct's list of
// servers columnated with the same level. The second item in the box should
// be the selected server.
Main *serverpane.Paned
// Lister is the current lister belonging to this server.
Lister cchat.Lister
stopLs func()
// Children is main's lhs.
Children *server.Children
spinner *spinner.Boxed // non-nil if loading.
ctrl SessionController
// state
ServerList cchat.Lister
// NextColumn is main's rhs.
NextColumn *Servers // nil
detachNext func()
}
var toplevelCSS = primitives.PrepareClassCSS("top-level", `
.top-level {
margin: 0 3px;
margin-top: 3px;
}
`)
// NewServers creates a new Servers instance that holds only the given column
// number and its children. Any servers with a different columnate ID will be in
// the children pane.
func NewServers(p traverse.Breadcrumber, ctrl SessionController) *Servers {
c := server.NewChildren(p, ctrl)
c.SetMarginStart(0) // children is top level; there is no main row
c.SetVExpand(true)
c.Show()
toplevelCSS(c)
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
return &Servers{
Box: b,
Children: c,
ctrl: ctrl,
servers := Servers{
SessionController: ctrl,
}
servers.Children = server.NewChildren(p, &servers)
servers.Children.SetVExpand(true)
servers.Children.Show()
toplevelCSS(servers.Children)
servers.Main = serverpane.NewPaned(servers.Children, gtk.ORIENTATION_VERTICAL)
servers.Main.Show()
stack, _ := gtk.StackNew()
servers.Stack = *stack
servers.Stack.SetVAlign(gtk.ALIGN_START)
servers.Stack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE)
servers.Stack.SetTransitionDuration(75)
servers.Stack.AddNamed(servers.Main, "main")
servers.Stack.Show()
return &servers
}
// Destroy destroys and invalidates this instance permanently.
func (s *Servers) Destroy() {
s.Reset()
s.Stack.Destroy()
}
func (s *Servers) Reset() {
// Reset isn't necessarily called while loading, so we do a check.
if s.spinner != nil {
s.spinner.Stop()
s.spinner.Destroy()
s.spinner = nil
}
// Close the right server column if any.
if s.NextColumn != nil {
if s.detachNext != nil {
s.detachNext()
}
s.Main.Remove(s.NextColumn)
s.NextColumn.Destroy()
s.NextColumn = nil
}
// Call the destructor if any.
if s.stopLs != nil {
s.stopLs()
s.stopLs = nil
}
// Reset the state.
s.ServerList = nil
// Remove all children.
primitives.RemoveChildren(s)
s.Lister = nil
// Reset the children container.
s.Children.Reset()
s.Stack.SetVisibleChild(s.Main)
}
// IsLoading returns true if the servers container is loading.
@ -83,8 +127,12 @@ func (s *Servers) IsLoading() bool {
// SetList indicates that the server list has been loaded. Unlike
// server.Children, this method will load immediately.
func (s *Servers) SetList(slist cchat.Lister) {
primitives.RemoveChildren(s)
s.ServerList = slist
if s.stopLs != nil {
s.stopLs()
s.stopLs = nil
}
s.Lister = slist
s.load()
}
@ -98,11 +146,12 @@ func (s *Servers) load() {
s.setLoading()
go func() {
err := s.ServerList.Servers(s)
stop, err := s.Lister.Servers(s)
gts.ExecAsync(func() {
if err != nil {
s.setFailed(err)
} else {
s.stopLs = stop
s.setDone()
}
})
@ -114,8 +163,8 @@ func (s *Servers) SetServers(servers []cchat.Server) {
gts.ExecAsync(func() {
s.Children.SetServersUnsafe(servers)
if len(servers) == 0 {
s.ctrl.ClearMessenger()
if servers == nil {
s.ClearMessenger()
return
}
@ -131,37 +180,38 @@ func (s *Servers) UpdateServer(update cchat.ServerUpdate) {
// setDone changes the view to show the servers.
func (s *Servers) setDone() {
primitives.RemoveChildren(s)
s.SetVisibleChild(s.Main)
// stop the spinner.
s.spinner.Stop()
s.spinner.Destroy()
s.spinner = nil
s.Add(s.Children)
}
// setLoading shows a loading spinner. Use this after the session row is
// connected.
func (s *Servers) setLoading() {
primitives.RemoveChildren(s)
s.spinner = spinner.New()
s.spinner.SetSizeRequest(FaceSize, FaceSize)
s.spinner.Show()
s.spinner.Start()
s.Add(s.spinner)
s.AddNamed(s.spinner, "spinner")
s.SetVisibleChildName("spinner")
}
// setFailed shows a sad face with the error. Use this when the session row has
// failed to load.
func (s *Servers) setFailed(err error) {
primitives.RemoveChildren(s)
// stop the spinner. Let this SEGFAULT if nil, as that's undefined behavior.
s.spinner.Stop()
// stop the spinner. Let this SEGFAULT if nil.
s.spinner.Destroy()
s.spinner = nil
// Remove existing error widgets.
w, err := s.Stack.GetChildByName("error")
if err == nil {
s.Stack.Remove(w)
}
// Create a BLANK label for padding.
ltop, _ := gtk.LabelNew("")
ltop.Show()
@ -182,7 +232,49 @@ func (s *Servers) setFailed(err error) {
lerr.Show()
// Add these items into the box.
s.PackStart(ltop, false, false, 0)
s.PackStart(btn, false, false, 10) // pad
s.PackStart(lerr, false, false, 0)
b, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
b.PackStart(ltop, false, false, 0)
b.PackStart(btn, false, false, 10) // pad
b.PackStart(lerr, false, false, 0)
b.Show()
s.Stack.AddNamed(b, "error")
s.Stack.SetVisibleChildName("error")
}
// SelectColumnatedLister is called by children servers to open up a server list
// on the right.
func (s *Servers) SelectColumnatedLister(srv *server.ServerRow, lst cchat.Lister) {
if s.detachNext != nil {
s.Main.Remove(s.NextColumn) // run the deconstructor
s.detachNext()
s.NextColumn.Destroy()
}
if lst == nil {
return
}
s.NextColumn = NewServers(srv, s)
s.NextColumn.SetList(lst)
s.Main.AddSide(s.NextColumn)
update := func(box *gtk.Box) {
a := box.GetAllocation()
// Align the next column to the selected item.
s.NextColumn.SetMarginTop(a.GetY())
}
s.Children.SetExpand(false)
primitives.AddClass(srv, "active-column")
update(srv.Box)
sizeHandle := srv.Box.Connect("size-allocate", update)
s.detachNext = func() {
srv.Box.HandlerDisconnect(sizeHandle)
s.Children.SetExpand(true)
primitives.RemoveClass(srv, "active-column")
}
}

View File

@ -1,6 +1,8 @@
package session
import (
"context"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/keyring"
@ -9,9 +11,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/actions"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/button"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/commander"
@ -55,9 +55,9 @@ type Controller interface {
// Row represents a session row entry in the session List.
type Row struct {
*gtk.ListBoxRow
avatar *roundimage.Avatar
name rich.NameContainer
iconBox *gtk.EventBox
icon *rich.Icon // nillable
icon *roundimage.StillImage // nillable
ctrl Controller
parentcrumb traverse.Breadcrumber
@ -80,6 +80,10 @@ type Row struct {
var rowCSS = primitives.PrepareClassCSS("session-row",
button.UnreadColorDefs+`
.session-row {
padding: 6px;
}
.session-row:last-child {
border-radius: 0 0 14px 14px;
}
@ -117,32 +121,21 @@ var rowCSS = primitives.PrepareClassCSS("session-row",
}
`)
var rowIconCSS = primitives.PrepareClassCSS("session-icon", `
.session-icon {
padding: 4px;
margin: 0;
}
`)
var rowIconCSS = primitives.PrepareClassCSS("session-icon", ``)
const IconSize = 48
const IconName = "face-plain-symbolic"
func newIcon(img rich.RoundIconContainer) *rich.Icon {
icon := rich.NewCustomIcon(img, IconSize)
icon.SetPlaceholderIcon(IconName, IconSize)
icon.ShowAll()
rowIconCSS(icon)
return icon
}
const (
IconSize = 42
IconName = "face-plain-symbolic"
)
func New(parent traverse.Breadcrumber, ses cchat.Session, ctrl Controller) *Row {
row := newRow(parent, text.Rich{}, ctrl)
row := newRow(parent, text.Plain(""), ctrl)
row.SetSession(ses)
return row
}
func NewLoading(parent traverse.Breadcrumber, id, name string, ctrl Controller) *Row {
row := newRow(parent, text.Rich{Content: name}, ctrl)
row := newRow(parent, text.Plain(name), ctrl)
row.sessionID = id
row.SetLoading()
return row
@ -154,17 +147,32 @@ func newRow(parent traverse.Breadcrumber, name text.Rich, ctrl Controller) *Row
parentcrumb: parent,
}
row.avatar = roundimage.NewAvatar(IconSize)
row.avatar.SetText(name.Content)
row.avatar.Show()
if !name.IsEmpty() {
row.name.LabelState = *rich.NewLabelState(name)
}
row.iconBox, _ = gtk.EventBoxNew()
row.iconBox.Show()
row.icon = roundimage.NewStillImage(row.iconBox, 0)
row.icon.SetSizeRequest(IconSize, IconSize)
row.icon.SetPlaceholderIcon(IconName, IconSize/2)
row.icon.Show()
rowIconCSS(row.icon)
row.iconBox.Add(row.icon)
rich.BindRoundImage(row.icon, &row.name, true)
row.ListBoxRow, _ = gtk.ListBoxRowNew()
row.ListBoxRow.Add(row.iconBox)
row.ListBoxRow.Show()
rowCSS(row.ListBoxRow)
row.name.OnUpdate(func() {
// TODO: proper popovers instead of tooltips.
row.ListBoxRow.SetTooltipText(row.name.String())
})
// TODO: commander button
row.Servers = NewServers(row, row)
@ -216,6 +224,11 @@ func NewAddButton() *gtk.ListBoxRow {
return row
}
// Name returns the session row's name container.
func (r *Row) Name() rich.LabelStateStorer {
return &r.name
}
// Reset extends the server row's Reset function and resets additional states.
// It resets all states back to nil, but the session ID stays.
func (r *Row) Reset() {
@ -223,14 +236,10 @@ func (r *Row) Reset() {
r.ActionsMenu.Reset() // wipe menu items
r.ActionsMenu.AddAction("Remove", r.RemoveSession)
if r.icon == nil {
r.icon = newIcon(r.avatar)
r.iconBox.Add(r.icon)
}
// Set a lame placeholder icon.
r.icon.SetPlaceholderIcon("folder-remote-symbolic", IconSize)
r.name.Stop()
r.Session = nil
r.cmder = nil
}
@ -243,7 +252,8 @@ func (r *Row) Breadcrumb() string {
if r.Session == nil {
return ""
}
return r.Session.Name().Content
return r.name.String()
}
func (r *Row) ClearMessenger() {
@ -273,25 +283,11 @@ func (r *Row) Activate() {
func (r *Row) SetLoading() {
// Reset the state.
r.Session = nil
// Reset the icon.
primitives.RemoveChildren(r.iconBox)
r.icon = nil
// Remove everything from the row, including the icon.
primitives.RemoveChildren(r)
r.name.Stop()
// Remove the failed class.
primitives.RemoveClass(r, "failed")
// Add a loading circle.
spin := spinner.New()
spin.SetSizeRequest(IconSize, IconSize)
spin.Start()
spin.Show()
rowIconCSS(spin)
r.Add(spin)
r.SetSensitive(false) // no activate
}
@ -303,18 +299,8 @@ func (r *Row) SetFailed(err error) {
r.Session = nil
// Re-enable the row.
r.SetSensitive(true)
// Remove everything off the row.
primitives.RemoveChildren(r)
// Mark the row as failed.
primitives.AddClass(r, "failed")
if r.icon == nil {
r.icon = newIcon(r.avatar)
r.iconBox.Add(r.icon)
}
// Add the icon.
r.Add(r.iconBox)
// Set the button to a retry icon.
r.icon.SetPlaceholderIcon("view-refresh-symbolic", IconSize)
}
@ -338,25 +324,11 @@ func (r *Row) SetSession(ses cchat.Session) {
// Set the states.
r.Session = ses
r.sessionID = ses.ID()
r.SetTooltipMarkup(markup.Render(ses.Name()))
r.avatar.SetText(ses.Name().Content)
if r.icon == nil {
r.icon = newIcon(r.avatar)
r.iconBox.Add(r.icon)
}
r.icon.SetPlaceholderIcon(IconName, IconSize)
// If the session has an icon, then use it.
if iconer := ses.AsIconer(); iconer != nil {
r.icon.AsyncSetIconer(iconer, "failed to set session icon")
}
r.name.QueueNamer(context.Background(), ses)
// Update to indicate that we're done.
primitives.RemoveChildren(r)
r.SetSensitive(true)
r.Add(r.iconBox)
// Bind extra menu items before loading. These items won't be clickable
// during loading.
@ -368,7 +340,7 @@ func (r *Row) SetSession(ses cchat.Session) {
// returns nil. As such, we assert with an ignored ok bool, allowing cmd to
// be nil.
if cmder := ses.AsCommander(); cmder != nil {
r.cmder = commander.NewBuffer(ses.Name().String(), cmder)
r.cmder = commander.NewBuffer(&r.name, cmder)
// Show the command button if the session actually supports the
// commander.
r.ActionsMenu.AddAction("Command Prompt", r.ShowCommander)

View File

@ -3,8 +3,6 @@ package service
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/singlestack"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
"github.com/gotk3/gotk3/gtk"
@ -36,10 +34,6 @@ type View struct {
Services *List
ServerView *gtk.ScrolledWindow
ServerStack *singlestack.Stack
// Servers *session.Servers // nil by default; use .Servers
}
func NewView(ctrller Controller) *View {
@ -52,18 +46,10 @@ func NewView(ctrller Controller) *View {
view.Header.AppMenuBindSize(view.Services)
view.Header.Show()
// Make a stack for the middle panel.
view.ServerStack = singlestack.NewStack()
view.ServerStack.SetSizeRequest(150, -1) // min width
view.ServerStack.SetTransitionDuration(50)
view.ServerStack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE)
view.ServerStack.SetHomogeneous(true)
view.ServerStack.Show()
primitives.AddClass(view.ServerStack, "server-stack")
view.ServerView, _ = gtk.ScrolledWindowNew(nil, nil)
view.ServerView.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
view.ServerView.Add(view.ServerStack)
view.ServerView.SetHExpand(true)
view.ServerView.SetVExpand(true)
view.ServerView.Show()
view.BottomPane, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
@ -95,14 +81,8 @@ func (v *View) SessionSelected(svc *Service, srow *session.Row) {
}
}
// !!!: SHITTY HACK!!!
// We can do this, as we're keeping all the server lists in memory by Go's
// reference anyway. In fact, cchat REQUIRES us to do so.
if srow.Session != nil {
v.ServerStack.SetVisibleChild(srow.Servers)
} else {
v.ServerStack.SetVisibleChild(spinner.NewVisible())
}
primitives.RemoveChildren(v.ServerView)
v.ServerView.Add(srow.Servers)
v.Header.SetSessionMenu(srow)
v.Header.SetBreadcrumber(srow)

51
internal/ui/style.css Normal file
View File

@ -0,0 +1,51 @@
/*
* Global CSS
*/
/* Make CSS more consistent across themes */
headerbar { padding-left: 0 }
/* .appmenu { margin: 0 20px } */
popover > *:not(stack):not(button) { margin: 6px }
/* Hack to fix the input bar being high in Adwaita */
.input-field * { min-height: 0 }
/* Hide all scroll undershoots */
undershoot { background-size: 0 }
/*
* Server CSS
*/
.top-level .server-list.expanded {
background-color: @borders;
}
.top-level .server-button {
border-radius: 0;
background-color: transparent;
}
/* .top-level .server-button:checked + revealer { */
/* border-left: 2px solid alpha(@borders, 0.75); */
/* } */
.top-level .server-children {
transition: margin-left 100ms;
}
.top-level.expand .server-button:checked + revealer > .server-children {
margin-left: 18px;
}
/* Keep this consistent with server.go's background-color. */
.top-level + stack .top-level {
background-color: alpha(@theme_selected_bg_color, 0.25);
}
/* Prevent inconsistent color when hovering over an active button. */
.server.active-column .server-button:focus {
box-shadow: none;
}

View File

@ -17,28 +17,20 @@ import (
"github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
_ "embed"
)
//go:embed style.css
var styleCSS string
func init() {
// Load the local CSS.
gts.LoadCSS("main", `
/* Make CSS more consistent across themes */
headerbar { padding-left: 0 }
/* .appmenu { margin: 0 20px } */
popover > *:not(stack):not(button) { margin: 6px }
/* Hack to fix the input bar being high in Adwaita */
.input-field * { min-height: 0 }
/* Hide all scroll undershoots */
undershoot { background-size: 0 }
`)
gts.LoadCSS("main", styleCSS)
}
// constraints for the left panel
const leftCurrentWidth = 300
const leftCurrentWidth = 350
func clamp(n, min, max int) int {
switch {
@ -186,7 +178,7 @@ func (app *App) MessengerSelected(ses *session.Row, srv *server.ServerRow) {
app.lastSelector = srv.SetSelected
app.lastSelector(true)
app.MessageView.JoinServer(ses.Session, srv.Server, srv)
app.MessageView.JoinServer(ses, srv, srv)
}
// MessageView methods.
@ -207,8 +199,8 @@ func (app *App) OnMessageDone() {
}
func (app *App) AuthenticateSession(list *service.List, ssvc *service.Service) {
var svc = ssvc.Service()
auth.NewDialog(svc.Name(), svc.Authenticate(), func(ses cchat.Session) {
svc := ssvc.Service()
auth.NewDialog(ssvc.Name.Label(), svc.Authenticate(), func(ses cchat.Session) {
ssvc.AddSession(ses)
})
}
@ -219,14 +211,12 @@ func (app *App) Close() {
// done, the application would exit immediately. There's no need to update
// the GUI.
for _, s := range app.Services.Services.Services {
var service = s.Service().Name()
for _, session := range s.BodyList.Sessions() {
if session.Session == nil {
continue
}
log.Printlnf("Disconnecting %s session %s", service, session.ID())
log.Printlnf("Disconnecting %s session %s", s.ID(), session.ID())
if err := session.Session.Disconnect(); err != nil {
log.Error(errors.Wrap(err, "Failed to disconnect "+session.ID()))

View File

@ -10,11 +10,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/config"
"github.com/diamondburned/cchat/services"
// _ "github.com/diamondburned/gotk3-tcmalloc"
// "github.com/diamondburned/gotk3-tcmalloc/heapprofiler"
_ "github.com/diamondburned/cchat-discord"
_ "github.com/diamondburned/cchat-mock"
)
func init() {

15
profile.go Normal file
View File

@ -0,0 +1,15 @@
// Code generated by goprofiler. DO NOT EDIT.
package main
import (
"net/http"
_ "net/http/pprof"
)
func init() {
go func() {
println("Serving HTTP at 127.0.0.1:48574 for profiler at /debug/pprof")
panic(http.ListenAndServe("127.0.0.1:48574", nil))
}()
}

View File

@ -1,31 +1,11 @@
{ pkgs ? import <nixpkgs> {} }:
let nostrip = pkg: pkgs.enableDebugging (pkg.overrideAttrs(old: {
dontStrip = true;
doCheck = false;
NIX_CFLAGS_COMPILE = (old.NIX_CFLAGS_COMPILE or "") + " -g";
}));
libhandy = pkgs.libhandy.overrideAttrs(old: {
name = "libhandy-1.0.1";
src = builtins.fetchGit {
url = "https://gitlab.gnome.org/GNOME/libhandy.git";
rev = "5cee0927b8b39dea1b2a62ec6d19169f73ba06c6";
};
patches = [];
buildInputs = old.buildInputs ++ (with pkgs; [
(nostrip gnome3.librsvg)
(nostrip gdk-pixbuf)
]);
});
in pkgs.stdenv.mkDerivation rec {
pkgs.stdenv.mkDerivation rec {
name = "cchat-gtk";
version = "0.0.2";
buildInputs = [
libhandy
pkgs.libhandy
pkgs.gnome3.gspell
pkgs.gnome3.glib
pkgs.gnome3.gtk