From 0171ac6b52d64be9e98353473dd85c97904a1043 Mon Sep 17 00:00:00 2001 From: "diamondburned (Forefront)" Date: Fri, 5 Jun 2020 17:47:28 -0700 Subject: [PATCH] Added colors and stuff --- go.mod | 5 +- go.sum | 36 ++-- internal/gts/css.go | 64 +++++++ internal/gts/gts.go | 10 +- internal/log/log.go | 5 + internal/ui/header.go | 65 ++++++- internal/ui/message/compact/message.go | 6 +- internal/ui/message/cozy/cozy.go | 1 + internal/ui/message/input/input.go | 66 ++++--- internal/ui/rich/parser/parser.go | 82 ++++++++ internal/ui/rich/parser/parser_test.go | 55 ++++++ internal/ui/rich/rich.go | 20 +- internal/ui/service/breadcrumb/breadcrumb.go | 21 +++ internal/ui/service/service.go | 7 +- internal/ui/service/session/server/server.go | 177 ++++++++++++++++++ .../service/session/server/server_children.go | 87 --------- .../ui/service/session/server/server_row.go | 100 ---------- internal/ui/service/session/session.go | 13 +- internal/ui/style.css | 1 + internal/ui/ui.go | 8 +- pkged.go | 12 ++ 21 files changed, 590 insertions(+), 251 deletions(-) create mode 100644 internal/gts/css.go create mode 100644 internal/ui/message/cozy/cozy.go create mode 100644 internal/ui/rich/parser/parser_test.go create mode 100644 internal/ui/service/breadcrumb/breadcrumb.go create mode 100644 internal/ui/service/session/server/server.go delete mode 100644 internal/ui/service/session/server/server_children.go delete mode 100644 internal/ui/service/session/server/server_row.go create mode 100644 internal/ui/style.css create mode 100644 pkged.go diff --git a/go.mod b/go.mod index 47958bf..94741b5 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,11 @@ replace github.com/diamondburned/cchat-mock => ../cchat-mock/ require ( github.com/Xuanwo/go-locale v0.2.0 - github.com/davecgh/go-spew v1.1.1 // indirect github.com/diamondburned/cchat v0.0.15 - github.com/diamondburned/cchat-mock v0.0.0-20200604043646-de5384bd320d + github.com/diamondburned/cchat-mock v0.0.0-20200605224934-31a53c555ea2 github.com/goodsign/monday v1.0.0 github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194 + github.com/markbates/pkger v0.17.0 github.com/pkg/errors v0.9.1 github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717 - golang.org/x/tools v0.0.0-20200529172331-a64b76657301 // indirect ) diff --git a/go.sum b/go.sum index 4a71b48..f83441c 100644 --- a/go.sum +++ b/go.sum @@ -7,16 +7,12 @@ github.com/danieljoos/wincred v1.0.2/go.mod h1:SnuYRW9lp1oJrZX/dXJqr0cPK5gYXqx3E github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/diamondburned/cchat v0.0.10 h1:aiUVgGre5E/HV+Iw6tmBVbuGctQI+JndV9nIDoYuRPY= -github.com/diamondburned/cchat v0.0.10/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat v0.0.13 h1:p8SyFjiRVCTjvwSJ4FsICGVYVZ3g0Iu02FrwmLuKiKE= -github.com/diamondburned/cchat v0.0.13/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.15 h1:1o4OX8zw/CdSv3Idaylz7vjHVOZKEi/xkg8BpEvtsHY= github.com/diamondburned/cchat v0.0.15/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat-mock v0.0.0-20200529184140-47fa2491d2fc h1:xYSN3re1QOd5af5zG15pLKfBM+fergw7Rg62UHmE22g= -github.com/diamondburned/cchat-mock v0.0.0-20200529184140-47fa2491d2fc/go.mod h1:rQm5EKhNyBRYHKtirSkf+Db23nr3mTs2bnOThfTfzec= github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI= github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= +github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI= +github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/goodsign/monday v1.0.0 h1:Yyk/s/WgudMbAJN6UWSU5xAs8jtNewfqtVblAlw0yoc= @@ -27,6 +23,13 @@ github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194 h1:bB6XWpxMt2isCWqzj github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/markbates/pkger v0.17.0 h1:RFfyBPufP2V6cddUyyEVSHBpaAnM1WzaMNyqomeT+iY= +github.com/markbates/pkger v0.17.0/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= 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= @@ -39,35 +42,22 @@ github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 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= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -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-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1P+/ZFC/IKythI5RzrnRg= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200529172331-a64b76657301 h1:G6CNEgFU8/XwexSnuFw+Jq/WePjRitgy6ofBcPnAIPo= -golang.org/x/tools v0.0.0-20200529172331-a64b76657301/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -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= 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/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/gts/css.go b/internal/gts/css.go new file mode 100644 index 0000000..9bc7c81 --- /dev/null +++ b/internal/gts/css.go @@ -0,0 +1,64 @@ +package gts + +import ( + "bytes" + + "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" + "github.com/markbates/pkger" + "github.com/pkg/errors" +) + +var cssRepos = map[string]*gtk.CssProvider{} + +func getDefaultScreen() *gdk.Screen { + d, _ := gdk.DisplayGetDefault() + s, _ := d.GetDefaultScreen() + return s +} + +func loadProviders(screen *gdk.Screen) { + for file, repo := range cssRepos { + gtk.AddProviderForScreen( + screen, repo, + uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION), + ) + // mark as done + delete(cssRepos, file) + } +} + +func LoadCSS(files ...string) { + var buf bytes.Buffer + for _, file := range files { + buf.Reset() + + if err := readFile(&buf, file); err != nil { + log.Error(errors.Wrap(err, "Failed to load a CSS file")) + continue + } + + prov, _ := gtk.CssProviderNew() + if err := prov.LoadFromData(buf.String()); err != nil { + log.Error(errors.Wrap(err, "Failed to parse CSS "+file)) + continue + } + + cssRepos[file] = prov + } +} + +func readFile(buf *bytes.Buffer, file string) error { + f, err := pkger.Open(file) + if err != nil { + return errors.Wrap(err, "Failed to load a CSS file") + } + defer f.Close() + + if _, err := buf.ReadFrom(f); err != nil { + return errors.Wrap(err, "Failed to read file") + } + + return nil +} diff --git a/internal/gts/gts.go b/internal/gts/gts.go index eee4302..357a389 100644 --- a/internal/gts/gts.go +++ b/internal/gts/gts.go @@ -9,6 +9,8 @@ import ( "github.com/gotk3/gotk3/gtk" ) +const AppID = "com.github.diamondburned.cchat-gtk" + var Args = append([]string{}, os.Args...) var recvPool *sync.Pool @@ -27,7 +29,7 @@ func init() { }, } - App.Application, _ = gtk.ApplicationNew("com.github.diamondburned.cchat-gtk", 0) + App.Application, _ = gtk.ApplicationNew(AppID, 0) } type Windower interface { @@ -47,10 +49,16 @@ type WindowHeaderer interface { func Main(wfn func() WindowHeaderer) { App.Application.Connect("activate", func() { + // Load all CSS onto the default screen. + loadProviders(getDefaultScreen()) + App.Header, _ = gtk.HeaderBarNew() App.Header.SetShowCloseButton(true) App.Header.Show() + b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + App.Header.SetCustomTitle(b) + App.Window, _ = gtk.ApplicationWindowNew(App.Application) App.Window.SetDefaultSize(1000, 500) App.Window.SetTitlebar(App.Header) diff --git a/internal/log/log.go b/internal/log/log.go index e94c22e..c8d2ed6 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -2,6 +2,7 @@ package log import ( "fmt" + "log" "os" "sync" "time" @@ -59,3 +60,7 @@ func WriteEntry(entry Entry) { } }() } + +func Println(v ...interface{}) { + log.Println(v...) +} diff --git a/internal/ui/header.go b/internal/ui/header.go index 5ec342d..915974a 100644 --- a/internal/ui/header.go +++ b/internal/ui/header.go @@ -1,14 +1,73 @@ package ui -import "github.com/gotk3/gotk3/gtk" +import ( + "html" + "strings" + + "github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb" + "github.com/gotk3/gotk3/gtk" +) type header struct { *gtk.Box + left *gtk.Box // TODO + right *headerRight } func newHeader() *header { - box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + left, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + left.SetSizeRequest(LeftWidth, -1) + left.Show() + + right := newHeaderRight() + right.Show() + + separator, _ := gtk.SeparatorNew(gtk.ORIENTATION_VERTICAL) + separator.Show() + + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + box.PackStart(left, false, false, 0) + box.PackStart(separator, false, false, 0) + box.PackStart(right, true, true, 0) box.Show() + // TODO - return &header{box} + return &header{ + box, + left, + right, + } +} + +const BreadcrumbSlash = `/` + +func (h *header) SetBreadcrumb(b breadcrumb.Breadcrumb) { + for i := range b { + b[i] = html.EscapeString(b[i]) + } + + h.right.breadcrumb.SetMarkup( + BreadcrumbSlash + " " + strings.Join(b, " "+BreadcrumbSlash+" "), + ) +} + +type headerRight struct { + *gtk.Box + breadcrumb *gtk.Label +} + +func newHeaderRight() *headerRight { + bc, _ := gtk.LabelNew(BreadcrumbSlash) + bc.SetUseMarkup(true) + bc.SetXAlign(0.0) + bc.Show() + + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + box.PackStart(bc, true, true, 14) + box.Show() + + return &headerRight{ + Box: box, + breadcrumb: bc, + } } diff --git a/internal/ui/message/compact/message.go b/internal/ui/message/compact/message.go index b45cff8..0c034b1 100644 --- a/internal/ui/message/compact/message.go +++ b/internal/ui/message/compact/message.go @@ -5,6 +5,7 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/humanize" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser" "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/pango" @@ -104,10 +105,9 @@ func (m *Message) UpdateAuthor(author cchat.MessageAuthor) { } func (m *Message) updateAuthorName(name text.Rich) { - m.Username.SetLabel(name.Content) - m.Username.SetTooltipText(name.Content) + m.Username.SetMarkup(parser.RenderMarkup(name)) } func (m *Message) UpdateContent(content text.Rich) { - m.Content.SetLabel(content.Content) + m.Content.SetMarkup(parser.RenderMarkup(content)) } diff --git a/internal/ui/message/cozy/cozy.go b/internal/ui/message/cozy/cozy.go new file mode 100644 index 0000000..2284c72 --- /dev/null +++ b/internal/ui/message/cozy/cozy.go @@ -0,0 +1 @@ +package cozy diff --git a/internal/ui/message/input/input.go b/internal/ui/message/input/input.go index 041a569..6281e71 100644 --- a/internal/ui/message/input/input.go +++ b/internal/ui/message/input/input.go @@ -10,11 +10,48 @@ import ( "github.com/pkg/errors" ) +type usernameContainer struct { + *gtk.Revealer + label *rich.Label +} + +func newUsernameContainer() *usernameContainer { + label := rich.NewLabel(text.Rich{}) + label.SetMaxWidthChars(35) + label.SetVAlign(gtk.ALIGN_START) + label.SetMarginTop(inputmargin) + label.SetMarginBottom(inputmargin) + label.SetMarginStart(10) + label.SetMarginEnd(10) + label.Show() + + rev, _ := gtk.RevealerNew() + rev.SetRevealChild(false) + rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) + rev.SetTransitionDuration(50) + rev.Add(label) + + return &usernameContainer{rev, label} +} + +// GetLabel is not thread-safe. +func (u *usernameContainer) GetLabel() text.Rich { + return u.label.GetLabel() +} + +// SetLabel is thread-safe. +func (u *usernameContainer) SetLabel(content text.Rich) { + gts.ExecAsync(func() { + u.label.SetLabelUnsafe(content) + + // Reveal if the name is not empty. + u.SetRevealChild(!u.label.GetLabel().Empty()) + }) +} + type Field struct { *gtk.Box - - namerev *gtk.Revealer - username *rich.Label // TODO + username *usernameContainer TextScroll *gtk.ScrolledWindow text *gtk.TextView @@ -30,25 +67,12 @@ type Controller interface { PresendMessage(msg PresendMessage) (onErr func(error)) } -const inputmargin = 3 +const inputmargin = 4 func NewField(ctrl Controller) *Field { - username := rich.NewLabel(text.Rich{}) - username.SetMaxWidthChars(35) - username.SetVAlign(gtk.ALIGN_START) - username.SetMarginTop(inputmargin) - username.SetMarginBottom(inputmargin) - username.SetMarginStart(10) - username.SetMarginEnd(10) + username := newUsernameContainer() username.Show() - namerev, _ := gtk.RevealerNew() - namerev.SetRevealChild(false) - namerev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) - namerev.SetTransitionDuration(50) - namerev.Add(username) - namerev.Show() - text, _ := gtk.TextViewNew() text.SetSensitive(false) text.SetWrapMode(gtk.WRAP_WORD_CHAR) @@ -68,13 +92,12 @@ func NewField(ctrl Controller) *Field { sw.Show() box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) - box.PackStart(namerev, false, false, 0) + box.PackStart(username, false, false, 0) box.PackStart(sw, true, true, 0) box.Show() field := &Field{ Box: box, - namerev: namerev, username: username, TextScroll: sw, text: text, @@ -107,9 +130,6 @@ func (f *Field) SetSender(session cchat.Session, sender cchat.ServerMessageSende log.Warn(err) } - // Reveal if the name is not empty. - f.namerev.SetRevealChild(!f.username.GetLabel().Empty()) - // Set the sender. f.sender = sender f.text.SetSensitive(sender != nil) // grey if sender is nil diff --git a/internal/ui/rich/parser/parser.go b/internal/ui/rich/parser/parser.go index 0bfe2c2..a7ba7bf 100644 --- a/internal/ui/rich/parser/parser.go +++ b/internal/ui/rich/parser/parser.go @@ -1 +1,83 @@ package parser + +import ( + "bytes" + "fmt" + "html" + "sort" + + "github.com/diamondburned/cchat/text" +) + +type attrAppendMap struct { + appended map[int]string + indices []int +} + +func newAttrAppendedMap() attrAppendMap { + return attrAppendMap{ + appended: make(map[int]string), + indices: []int{}, + } +} + +func (a *attrAppendMap) add(ind int, attr string) { + if _, ok := a.appended[ind]; ok { + a.appended[ind] += attr + return + } + + a.appended[ind] = attr + a.indices = append(a.indices, ind) +} + +func (a attrAppendMap) get(ind int) string { + return a.appended[ind] +} + +func (a *attrAppendMap) finalize(strlen int) []int { + // make sure there's always a closing tag at the end so the entire string + // gets flushed. + a.add(strlen, "") + sort.Ints(a.indices) + return a.indices +} + +func RenderMarkup(content text.Rich) string { + buf := bytes.Buffer{} + buf.Grow(len(content.Content)) + + // // Sort so that all starting points are sorted incrementally. + // sort.Slice(content.Segments, func(i, j int) bool { + // i, _ = content.Segments[i].Bounds() + // j, _ = content.Segments[j].Bounds() + // return i < j + // }) + + // map to append strings to indices + var appended = newAttrAppendedMap() + + // Parse all segments. + for _, segment := range content.Segments { + start, end := segment.Bounds() + + switch segment := segment.(type) { + case text.Colorer: + appended.add(start, fmt.Sprintf("", segment.Color())) + appended.add(end, "") + } + } + + var lastIndex = 0 + + for _, index := range appended.finalize(len(content.Content)) { + // Write the content. + buf.WriteString(html.EscapeString(content.Content[lastIndex:index])) + // Write the tags. + buf.WriteString(appended.get(index)) + // Set the last index. + lastIndex = index + } + + return buf.String() +} diff --git a/internal/ui/rich/parser/parser_test.go b/internal/ui/rich/parser/parser_test.go new file mode 100644 index 0000000..995164c --- /dev/null +++ b/internal/ui/rich/parser/parser_test.go @@ -0,0 +1,55 @@ +package parser + +import ( + "strings" + "testing" + + "github.com/diamondburned/cchat-mock/segments" + "github.com/diamondburned/cchat/text" +) + +func TestRenderMarkup(t *testing.T) { + content := text.Rich{Content: "astolfo is the best trap"} + content.Segments = []text.Segment{ + segments.NewColored(content.Content, 0x55CDFC), + } + expect := `` + content.Content + "" + + if text := RenderMarkup(content); text != expect { + t.Fatal("Unexpected text:", text) + } +} + +func TestRenderMarkupPartial(t *testing.T) { + content := text.Rich{Content: "random placeholder text go brrr"} + content.Segments = []text.Segment{ + // This is absolutely jankery that should not work at all, but we'll try + // it anyway. + coloredSegment{0, 4, 0x55CDFC}, + coloredSegment{2, 6, 0xFFFFFF}, // naive parsing, so spans close unexpectedly. + coloredSegment{4, 6, 0xF7A8B8}, + } + const expect = "" + + `rand` + + `om` + + if text := RenderMarkup(content); !strings.HasPrefix(text, expect) { + t.Fatal("Unexpected text:", text) + } +} + +type coloredSegment struct { + start int + end int + color uint32 +} + +var _ text.Colorer = (*coloredSegment)(nil) + +func (c coloredSegment) Bounds() (start, end int) { + return c.start, c.end +} + +func (c coloredSegment) Color() uint32 { + return c.color +} diff --git a/internal/ui/rich/rich.go b/internal/ui/rich/rich.go index e1eba80..f49decc 100644 --- a/internal/ui/rich/rich.go +++ b/internal/ui/rich/rich.go @@ -4,6 +4,7 @@ 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/rich/parser" "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" ) @@ -13,6 +14,7 @@ import ( type Labeler interface { cchat.LabelContainer // thread-safe GetLabel() text.Rich // not thread-safe + GetText() string } type Label struct { @@ -26,7 +28,8 @@ var ( ) func NewLabel(content text.Rich) *Label { - label, _ := gtk.LabelNew(content.Content) + label, _ := gtk.LabelNew("") + label.SetMarkup(parser.RenderMarkup(content)) label.SetHAlign(gtk.ALIGN_START) return &Label{*label, content} } @@ -34,16 +37,27 @@ func NewLabel(content text.Rich) *Label { // SetLabel is thread-safe. func (l *Label) SetLabel(content text.Rich) { gts.ExecAsync(func() { - l.current = content - l.SetText(content.Content) + l.SetLabelUnsafe(content) }) } +// SetLabelUnsafe sets the label in the current thread, meaning it's not +// thread-safe. +func (l *Label) SetLabelUnsafe(content text.Rich) { + l.current = content + l.SetMarkup(parser.RenderMarkup(content)) +} + // GetLabel is NOT thread-safe. func (l *Label) GetLabel() text.Rich { return l.current } +// GetText is NOT thread-safe. +func (l *Label) GetText() string { + return l.current.Content +} + type ToggleButton struct { gtk.ToggleButton Label diff --git a/internal/ui/service/breadcrumb/breadcrumb.go b/internal/ui/service/breadcrumb/breadcrumb.go new file mode 100644 index 0000000..596ba17 --- /dev/null +++ b/internal/ui/service/breadcrumb/breadcrumb.go @@ -0,0 +1,21 @@ +package breadcrumb + +import "strings" + +type Breadcrumb []string + +func (b Breadcrumb) String() string { + return strings.Join([]string(b), "/") +} + +type Breadcrumber interface { + Breadcrumb() Breadcrumb +} + +// Try accepts a nilable breadcrumber and handles it appropriately. +func Try(i Breadcrumber, appended ...string) []string { + if i == nil { + return appended + } + return append(i.Breadcrumb(), appended...) +} diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 72874eb..4f4f27b 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -4,6 +4,7 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/rich" + "github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb" "github.com/diamondburned/cchat-gtk/internal/ui/service/session" "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" @@ -91,7 +92,7 @@ func NewContainer(svc cchat.Service, ctrl Controller) *Container { } func (c *Container) AddSession(ses cchat.Session) { - srow := session.New(ses, c.rowctrl) + srow := session.New(c, ses, c.rowctrl) c.children.addSessionRow(srow) } @@ -103,6 +104,10 @@ func (c *Container) Sessions() []cchat.Session { return sessions } +func (c *Container) Breadcrumb() breadcrumb.Breadcrumb { + return breadcrumb.Try(nil, c.header.reveal.GetText()) +} + type header struct { *gtk.Box reveal *rich.ToggleButtonImage // no rich text here but it's left aligned diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go new file mode 100644 index 0000000..85bfcc7 --- /dev/null +++ b/internal/ui/service/session/server/server.go @@ -0,0 +1,177 @@ +package server + +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/primitives" + "github.com/diamondburned/cchat-gtk/internal/ui/rich" + "github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb" + "github.com/diamondburned/cchat/text" + "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" +) + +const ChildrenMargin = 24 + +type Controller interface { + MessageRowSelected(*Row, cchat.ServerMessage) +} + +type Row struct { + *gtk.Box + Button *rich.ToggleButtonImage + Server cchat.Server + Parent breadcrumb.Breadcrumber + + ctrl Controller + + // enum 1 + message cchat.ServerMessage + + // enum 2 + children *Children +} + +func NewRow(parent breadcrumb.Breadcrumber, server cchat.Server, ctrl Controller) *Row { + button := rich.NewToggleButtonImage(text.Rich{}, "") + button.Box.SetHAlign(gtk.ALIGN_START) + button.SetRelief(gtk.RELIEF_NONE) + button.Show() + + if err := server.Name(button); err != nil { + log.Error(errors.Wrap(err, "Failed to get the server name")) + button.SetLabel(text.Rich{Content: "Unknown"}) + } + + box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + box.PackStart(button, false, false, 0) + box.Show() + + primitives.AddClass(box, "server") + + // TODO: images + + var row = &Row{ + Box: box, + Button: button, + Server: server, + Parent: parent, + ctrl: ctrl, + } + + switch server := server.(type) { + case cchat.ServerList: + row.children = NewChildren(row, server, ctrl) + box.PackStart(row.children, false, false, 0) + + primitives.AddClass(box, "server-list") + + case cchat.ServerMessage: + row.message = server + + primitives.AddClass(box, "server-message") + } + + button.Connect("clicked", row.onClick) + + return row +} + +func (row *Row) GetActive() bool { + return row.Button.GetActive() +} + +func (row *Row) onClick() { + switch { + + // If the server is a message server. We're only selected if the button is + // pressed. + case row.message != nil && row.GetActive(): + row.ctrl.MessageRowSelected(row, row.message) + + // If the server is a list of smaller servers. + case row.children != nil: + row.children.SetRevealChild(!row.children.GetRevealChild()) + } +} + +func (r *Row) Breadcrumb() breadcrumb.Breadcrumb { + return breadcrumb.Try(r.Parent, r.Button.GetText()) +} + +// Children is a children server with a reference to the parent. +type Children struct { + *gtk.Revealer + Main *gtk.Box + List cchat.ServerList + + rowctrl Controller + + Rows []*Row + Parent breadcrumb.Breadcrumber +} + +func NewChildren(parent breadcrumb.Breadcrumber, list cchat.ServerList, ctrl Controller) *Children { + main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + main.SetMarginStart(ChildrenMargin) + main.Show() + + rev, _ := gtk.RevealerNew() + rev.SetRevealChild(false) + rev.Add(main) + rev.Show() + + children := &Children{ + Revealer: rev, + Main: main, + List: list, + rowctrl: ctrl, + Parent: parent, + } + + if err := list.Servers(children); err != nil { + log.Error(errors.Wrap(err, "Failed to get servers")) + } + + return children +} + +func (c *Children) SetServers(servers []cchat.Server) { + gts.ExecAsync(func() { + // Save the current state. + var oldID string + for _, row := range c.Rows { + if row.GetActive() { + oldID = row.Server.ID() + break + } + } + + // Update the server list. + for _, row := range c.Rows { + c.Main.Remove(row) + } + + c.Rows = make([]*Row, len(servers)) + + for i, server := range servers { + row := NewRow(c, server, c.rowctrl) + c.Rows[i] = row + c.Main.Add(row) + } + + // Update parent reference? Only if it's activated. + if oldID != "" { + for _, row := range c.Rows { + if row.Server.ID() == oldID { + row.Button.SetActive(true) + } + } + } + }) +} + +func (c *Children) Breadcrumb() breadcrumb.Breadcrumb { + return breadcrumb.Try(c.Parent) +} diff --git a/internal/ui/service/session/server/server_children.go b/internal/ui/service/session/server/server_children.go deleted file mode 100644 index c89f981..0000000 --- a/internal/ui/service/session/server/server_children.go +++ /dev/null @@ -1,87 +0,0 @@ -package server - -import ( - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/gts" - "github.com/diamondburned/cchat-gtk/internal/log" - "github.com/gotk3/gotk3/gtk" - "github.com/pkg/errors" -) - -const ChildrenMargin = 24 - -type Controller interface { - MessageRowSelected(*Row, cchat.ServerMessage) -} - -// Children is a children server with a reference to the parent. -type Children struct { - *gtk.Revealer - Main *gtk.Box - List cchat.ServerList - - rowctrl Controller - - Rows []*Row - ParentRow *Row -} - -func NewChildren(parent *Row, list cchat.ServerList, ctrl Controller) *Children { - main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - main.SetMarginStart(ChildrenMargin) - main.Show() - - rev, _ := gtk.RevealerNew() - rev.SetRevealChild(false) - rev.Add(main) - rev.Show() - - children := &Children{ - Revealer: rev, - Main: main, - List: list, - rowctrl: ctrl, - ParentRow: parent, - } - - if err := list.Servers(children); err != nil { - log.Error(errors.Wrap(err, "Failed to get servers")) - } - - return children -} - -func (c *Children) SetServers(servers []cchat.Server) { - gts.ExecAsync(func() { - // Save the current state. - var oldID string - for _, row := range c.Rows { - if row.GetActive() { - oldID = row.Server.ID() - break - } - } - - // Update the server list. - for _, row := range c.Rows { - c.Main.Remove(row) - } - - c.Rows = make([]*Row, len(servers)) - - for i, server := range servers { - row := NewRow(c, server, c.rowctrl) - c.Rows[i] = row - c.Main.Add(row) - } - - // Update parent reference? Only if it's activated. - if oldID != "" { - for _, row := range c.Rows { - if row.Server.ID() == oldID { - row.Button.SetActive(true) - } - } - } - }) -} diff --git a/internal/ui/service/session/server/server_row.go b/internal/ui/service/session/server/server_row.go deleted file mode 100644 index e4f6703..0000000 --- a/internal/ui/service/session/server/server_row.go +++ /dev/null @@ -1,100 +0,0 @@ -package server - -import ( - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/log" - "github.com/diamondburned/cchat-gtk/internal/ui/primitives" - "github.com/diamondburned/cchat-gtk/internal/ui/rich" - "github.com/diamondburned/cchat/text" - "github.com/gotk3/gotk3/gtk" - "github.com/pkg/errors" -) - -type Row struct { - *gtk.Box - Button *rich.ToggleButtonImage - Server cchat.Server - Parent *Children - - ctrl Controller - - // enum 1 - message cchat.ServerMessage - - // enum 2 - children *Children -} - -func NewRow(parent *Children, server cchat.Server, ctrl Controller) *Row { - button := rich.NewToggleButtonImage(text.Rich{}, "") - button.Box.SetHAlign(gtk.ALIGN_START) - button.SetRelief(gtk.RELIEF_NONE) - button.Show() - - if err := server.Name(button); err != nil { - log.Error(errors.Wrap(err, "Failed to get the server name")) - button.SetLabel(text.Rich{Content: "Unknown"}) - } - - box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - box.PackStart(button, false, false, 0) - box.Show() - - primitives.AddClass(box, "server") - - // TODO: images - - var row = &Row{ - Box: box, - Button: button, - Server: server, - Parent: parent, - ctrl: ctrl, - } - - switch server := server.(type) { - case cchat.ServerList: - row.children = NewChildren(row, server, ctrl) - box.PackStart(row.children, false, false, 0) - - primitives.AddClass(box, "server-list") - - case cchat.ServerMessage: - row.message = server - - primitives.AddClass(box, "server-message") - } - - button.Connect("clicked", row.onClick) - - return row -} - -func (row *Row) GetActive() bool { - return row.Button.GetActive() -} - -func (row *Row) onClick() { - switch { - - // If the server is a message server. We're only selected if the button is - // pressed. - case row.message != nil && row.GetActive(): - row.ctrl.MessageRowSelected(row, row.message) - - // If the server is a list of smaller servers. - case row.children != nil: - row.children.SetRevealChild(!row.children.GetRevealChild()) - } -} - -func (r *Row) Breadcrumb() string { - var label = r.Button.GetLabel().Content - - // Does the row have a parent? - if r.Parent != nil { - return r.Parent.ParentRow.Breadcrumb() + "/" + label - } - - return label -} diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index c985ffe..49e94e9 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -5,6 +5,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/rich" + "github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" @@ -25,15 +26,17 @@ type Row struct { Servers *server.Children - ctrl Controller + ctrl Controller + parent breadcrumb.Breadcrumber } -func New(ses cchat.Session, ctrl Controller) *Row { +func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Row { row := &Row{ Session: ses, ctrl: ctrl, + parent: parent, } - row.Servers = server.NewChildren(ses, row) + row.Servers = server.NewChildren(row, ses, row) row.Button = rich.NewToggleButtonImage(text.Rich{}, "") row.Button.Box.SetHAlign(gtk.ALIGN_START) @@ -67,3 +70,7 @@ func New(ses cchat.Session, ctrl Controller) *Row { func (r *Row) MessageRowSelected(server *server.Row, smsg cchat.ServerMessage) { r.ctrl.MessageRowSelected(r, server, smsg) } + +func (r *Row) Breadcrumb() breadcrumb.Breadcrumb { + return breadcrumb.Try(r.parent, r.Button.GetLabel().Content) +} diff --git a/internal/ui/style.css b/internal/ui/style.css new file mode 100644 index 0000000..b78e073 --- /dev/null +++ b/internal/ui/style.css @@ -0,0 +1 @@ +headerbar { padding: 0; } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 3f9f454..87e10b9 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -10,8 +10,14 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/service/session" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" "github.com/gotk3/gotk3/gtk" + "github.com/markbates/pkger" ) +func init() { + // Load the local CSS. + gts.LoadCSS(pkger.Include("/internal/ui/style.css")) +} + const LeftWidth = 220 type App struct { @@ -54,7 +60,7 @@ func (app *App) MessageRowSelected(ses *session.Row, srv *server.Row, smsg cchat app.lastRowHighlighter = srv.Button.SetActive app.lastRowHighlighter(true) - log.Println("Breadcrumb:") + app.header.SetBreadcrumb(srv.Breadcrumb()) // Show the messages. app.window.MessageView.JoinServer(ses.Session, smsg) diff --git a/pkged.go b/pkged.go new file mode 100644 index 0000000..358f41f --- /dev/null +++ b/pkged.go @@ -0,0 +1,12 @@ +// Code generated by pkger; DO NOT EDIT. + +// +build !skippkger + +package main + +import ( + "github.com/markbates/pkger" + "github.com/markbates/pkger/pkging/mem" +) + +var _ = pkger.Apply(mem.UnmarshalEmbed([]byte(`1f8b08000000000000ffec58dd8fdb380eff570a3da7a366da29ae792eaeb88739146d713860512c688996b5d1d7ea639a7430fffb42b293c8f99ccc2eb058c00fb145f2474aa4258ae12391a6b5812c1e8990b14bcd0db39a7209da1ade246f9053c63a88af455c66d447e9c982d0ce6adcc0e857e6a58ba10756f019f98f76d6c7cf103bb278ce0433f25fd0481644833464463e5a461684ccc837f002e3c1cc37c2d2469a91852fd61e024f2ff11e22ebc8e2177243becfc8d7080ac922fa8403f105215843162464ea15478786a361ebc5abca21619bd4b6a02cedd02399914ff6df5261c886b32f37c266f37d3c0af77234a83411bd0145450c64769586b2e24a8d242f2b68cb96175134a07f900c4376f823bae26d935a69c98c34eb88d91766b5f318026d1544ac19e2a774853611a4414f950c7160e0aa8cfcda45bb1d50e82df60493ae43bfa3792de4017604b231c96fefeee61f0e18bb0821ff019e877d9852d245c9769c4e43456dd53d189ea2544744213551e14ea0f9dd8ec87a15c5de5544ed40e8603ea26eefde8fe8bbf96d45ef4d195515a7d5dd9b0f638abaa55c911941c32c974654430ac1cc6bba8180efdf8d38d2805fd71c619b9aecb0364e7fcb27aea21dea4c7a6f7d5e65abe378177e06a5a4e1e8a9b0af73bcace610610cfa7f02f3c36684b20c8aefe737f29507e8fa43da250d46febcbc92b1da12d7beff047f7f46d853c898974c44358600e2da48ec1429a46803f356a997dbc83908d8b5dfbd32208d4b2f50775e6a19e5035ebb8192a45eb2ee7aad2145bf583187fbe5d3d2c62370e6936e5e6e236008b2a4893f67a0d0e5c6f82b2ebf82a2018546f38c7cb0bb2b2f2287cbef5ccd3192f126059a1ffb02cb831486e60960bd2f8ccbb7c39381f4f6b458f0e519a192cd19693ca3eac088bd6935f865031103754bb1ffa5f6844742b18fd8ee050d2e9c87baa53848b4c73134446ef7acb9a5a0db3babe2ff0495afa87c131d4de587721a90798cbf56c7d62a30e2c67a4157656b54891e8c48432ebd8caa93de697404711450cdd541e88617659ebdcdb54617754ec75b33b9fa03256a1673a9265b1d83f5b166198cd143f17acbb3a104b666b93ef557e5d658c563ab904525e3881da4110a5b2545379a35ac0303a528ae90a17938264aa69444bb206188fde5574a5d69a9b443c1d7b33594a4995fb49162372c99a28c87624f4b8dc38beaa4a27450825218bf271b913b2f4d84a6d4310663ffa45d8cae1a96c7267a5be666c5032f7f49e76d29a9339d7c9694dd6c4309403f4aa11c3ed77b915fb4950a077a886f19095cb9ed8086b5899023e59389bd63c388b272d637d4369210ad2e15f5816408e1013facf37287ad13a267b67cb310f3f129a2b561c36b677ef8926446ca6e0e0c8c293e0ecb4c4632cbab114db19dbf1fd3ff2a648036e31ed0709bcbd0eaa46cfe9974c03ab87df33c94b36a3d7ffbe6ee02babcf2b17a2e6e53ad9f0327ff809b7f396770dd92b7e711877f70ce802f789c77263721ff76c5e229e076ef8b54ced6459cf376b5be00bca59d8352039c42496ee08438acc390eb8e49fbed872c79a48de4d2a793d12ad0e8c184d67a7d0eb4d9a3d9e0737026dbfb3e23df30c46dfbc224a57ad6b677d1b3ee2dcf8b5c3c926bda3bf720cda6bd725d2be993bdb7fcb9782aec8db6bca8fd0f7d291417647e337f479e9e9e66a4ed9d7b7c9a9152304c8dada9b13535b6a6c6d6d4d89a1a5b53636b6a6c4d8dada9b13535b6a6c6d6d4d89a1a5b53636b6a6c4d8dada9b1f50f686cfd010000ffff010000ffff0d1f6f41ce250000`)))