From e01d0bca7af6a58dfa0753305ada3d9907fbb270 Mon Sep 17 00:00:00 2001 From: codemaestro64 Date: Sun, 28 Feb 2021 23:54:16 +0100 Subject: [PATCH] add proposal details page --- go.mod | 3 +- go.sum | 9 + ui/decredmaterial/grid.go | 213 ++++++++++++++ ui/decredmaterial/tooltip.go | 4 +- ui/decredmaterial/votebar.go | 6 +- ui/page.go | 9 +- ui/proposal_details_page.go | 288 ++++++++++++++++++ ui/proposals_page.go | 97 ++++++- ui/utils/markdown.go | 547 +++++++++++++++++++++++++++++++++++ ui/window.go | 6 + wallet/commands.go | 4 + 11 files changed, 1165 insertions(+), 21 deletions(-) create mode 100644 ui/decredmaterial/grid.go create mode 100644 ui/proposal_details_page.go create mode 100644 ui/utils/markdown.go diff --git a/go.mod b/go.mod index 93a7a72fb..0cdb2c21f 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.13 require ( gioui.org v0.0.0-20210124160655-f88a8216e9d7 + github.com/ararog/timeago v0.0.0-20160328174124-e9969cf18b8d github.com/decred/dcrd/chaincfg v1.5.2 github.com/decred/dcrd/chaincfg/chainhash v1.0.2 github.com/decred/dcrd/dcrutil v1.4.0 @@ -11,6 +12,7 @@ require ( github.com/decred/dcrd/dcrutil/v3 v3.0.0 github.com/decred/slog v1.1.0 github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28 + github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8 github.com/jessevdk/go-flags v1.4.1-0.20200711081900-c17162fe8fd7 github.com/jrick/logrotate v1.0.0 github.com/onsi/ginkgo v1.14.0 @@ -19,7 +21,6 @@ require ( github.com/skip2/go-qrcode v0.0.0-20191027152451-9434209cb086 golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 golang.org/x/image v0.0.0-20200618115811-c13761719519 - github.com/ararog/timeago v0.0.0-20160328174124-e9969cf18b8d ) // TODO: Remove and use an actual release of dcrlibwallet diff --git a/go.sum b/go.sum index d326e0c00..92a596f28 100644 --- a/go.sum +++ b/go.sum @@ -323,6 +323,7 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28 h1:M2Zt3G2w6Q57GZndOYk42p7RvMeO8izO8yKTfIxGqxA= github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28/go.mod h1:ElSskYZe3oM8kThaHGJ+kiN2yyUMVXMZ7WxF9QqLDS8= @@ -359,6 +360,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8 h1:nWU6p08f1VgIalT6iZyqXi4o5cZsz4X6qa87nusfcsc= +github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -442,16 +445,19 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA= github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/otiai10/copy v1.0.1/go.mod h1:8bMCJrAqOtN/d9oyh5HR7HhLQMvcGMpGdwRDYsfOCHc= @@ -525,6 +531,7 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= golang.org/x/crypto v0.0.0-20180718160520-a2144134853f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180808211826-de0752318171/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -672,11 +679,13 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/ui/decredmaterial/grid.go b/ui/decredmaterial/grid.go new file mode 100644 index 000000000..5ff8b8b1c --- /dev/null +++ b/ui/decredmaterial/grid.go @@ -0,0 +1,213 @@ +package decredmaterial + +import ( + "image" + + "gioui.org/layout" + "gioui.org/op" +) + +// inf is an infinite axis constraint. +const inf = 1e6 + +// GridElement lays out the ith element of a Grid. +type GridElement func(gtx layout.Context, i int) layout.Dimensions + +// Grid lays out at most Num elements along the main axis. +// The number of cross axis elements depend on the total number of elements. +type Grid struct { + Num int + Axis layout.Axis + Alignment layout.Alignment + list layout.List +} + +// GridWrap lays out as many elements as possible along the main axis +// before wrapping to the cross axis. +type GridWrap struct { + Axis layout.Axis + Alignment layout.Alignment +} + +type wrapData struct { + dims layout.Dimensions + call op.CallOp +} + +func (g GridWrap) Layout(gtx layout.Context, num int, el GridElement) layout.Dimensions { + defer op.Save(gtx.Ops).Load() + csMax := gtx.Constraints.Max + var mainSize, crossSize, mainPos, crossPos, base int + gtx.Constraints.Min = image.Point{} + mainCs := axisMain(g.Axis, csMax) + crossCs := axisCross(g.Axis, gtx.Constraints.Max) + + var els []wrapData + for i := 0; i < num; i++ { + macro := op.Record(gtx.Ops) + dims, okMain, okCross := g.place(gtx, i, el) + call := macro.Stop() + if !okMain && !okCross { + break + } + main := axisMain(g.Axis, dims.Size) + cross := axisCross(g.Axis, dims.Size) + if okMain { + els = append(els, wrapData{dims, call}) + + mainCs := axisMain(g.Axis, gtx.Constraints.Max) + gtx.Constraints.Max = axisPoint(g.Axis, mainCs-main, crossCs) + + mainPos += main + crossPos = max(crossPos, cross) + base = max(base, dims.Baseline) + continue + } + // okCross + mainSize = max(mainSize, mainPos) + crossSize += crossPos + g.placeAll(gtx.Ops, els, crossPos, base) + els = append(els[:0], wrapData{dims, call}) + + gtx.Constraints.Max = axisPoint(g.Axis, mainCs-main, crossCs-crossPos) + mainPos = main + crossPos = cross + base = dims.Baseline + } + mainSize = max(mainSize, mainPos) + crossSize += crossPos + g.placeAll(gtx.Ops, els, crossPos, base) + sz := axisPoint(g.Axis, mainSize, crossSize) + return layout.Dimensions{Size: sz} +} + +func (g GridWrap) place(gtx layout.Context, i int, el GridElement) (dims layout.Dimensions, okMain, okCross bool) { + cs := gtx.Constraints + if g.Axis == layout.Horizontal { + gtx.Constraints.Max.X = inf + } else { + gtx.Constraints.Max.Y = inf + } + dims = el(gtx, i) + okMain = dims.Size.X <= cs.Max.X + okCross = dims.Size.Y <= cs.Max.Y + if g.Axis == layout.Vertical { + okMain, okCross = okCross, okMain + } + return +} + +func (g GridWrap) placeAll(ops *op.Ops, els []wrapData, crossMax, baseMax int) { + var mainPos int + var pt image.Point + for i, el := range els { + cross := axisCross(g.Axis, el.dims.Size) + switch g.Alignment { + case layout.Start: + cross = 0 + case layout.End: + cross = crossMax - cross + case layout.Middle: + cross = (crossMax - cross) / 2 + case layout.Baseline: + if g.Axis == layout.Horizontal { + cross = baseMax - el.dims.Baseline + } else { + cross = 0 + } + } + if cross == 0 { + el.call.Add(ops) + } else { + pt = axisPoint(g.Axis, 0, cross) + op.Offset(layout.FPt(pt)).Add(ops) + el.call.Add(ops) + op.Offset(layout.FPt(pt.Mul(-1))).Add(ops) + } + if i == len(els)-1 { + pt = axisPoint(g.Axis, -mainPos, crossMax) + } else { + main := axisMain(g.Axis, el.dims.Size) + pt = axisPoint(g.Axis, main, 0) + mainPos += main + } + op.Offset(layout.FPt(pt)).Add(ops) + } +} + +func (g *Grid) Layout(gtx layout.Context, num int, el GridElement) layout.Dimensions { + if g.Num == 0 { + return layout.Dimensions{Size: gtx.Constraints.Min} + } + if g.Axis == g.list.Axis { + if g.Axis == layout.Horizontal { + g.list.Axis = layout.Vertical + } else { + g.list.Axis = layout.Horizontal + } + g.list.Alignment = g.Alignment + } + csMax := gtx.Constraints.Max + return g.list.Layout(gtx, (num+g.Num-1)/g.Num, func(gtx layout.Context, idx int) layout.Dimensions { + defer op.Save(gtx.Ops).Load() + if g.Axis == layout.Horizontal { + gtx.Constraints.Max.Y = inf + } else { + gtx.Constraints.Max.X = inf + } + gtx.Constraints.Min = image.Point{} + var mainMax, crossMax int + left := axisMain(g.Axis, csMax) + i := idx * g.Num + n := min(num, i+g.Num) + for ; i < n; i++ { + dims := el(gtx, i) + main := axisMain(g.Axis, dims.Size) + crossMax = max(crossMax, axisCross(g.Axis, dims.Size)) + left -= main + if left <= 0 { + mainMax = axisMain(g.Axis, csMax) + break + } + pt := axisPoint(g.Axis, main, 0) + op.Offset(layout.FPt(pt)).Add(gtx.Ops) + mainMax += main + } + return layout.Dimensions{Size: axisPoint(g.Axis, mainMax, crossMax)} + }) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func axisPoint(a layout.Axis, main, cross int) image.Point { + if a == layout.Horizontal { + return image.Point{main, cross} + } + return image.Point{cross, main} +} + +func axisMain(a layout.Axis, sz image.Point) int { + if a == layout.Horizontal { + return sz.X + } + return sz.Y +} + +func axisCross(a layout.Axis, sz image.Point) int { + if a == layout.Horizontal { + return sz.Y + } + return sz.X +} diff --git a/ui/decredmaterial/tooltip.go b/ui/decredmaterial/tooltip.go index 8f9d546f4..14b35492d 100644 --- a/ui/decredmaterial/tooltip.go +++ b/ui/decredmaterial/tooltip.go @@ -4,11 +4,11 @@ import ( "image" "image/color" - "gioui.org/layout" - "gioui.org/widget" "gioui.org/f32" + "gioui.org/layout" "gioui.org/op" "gioui.org/unit" + "gioui.org/widget" "github.com/planetdecred/godcr/ui/values" ) diff --git a/ui/decredmaterial/votebar.go b/ui/decredmaterial/votebar.go index d58eeed23..11a7fc9cd 100644 --- a/ui/decredmaterial/votebar.go +++ b/ui/decredmaterial/votebar.go @@ -20,6 +20,7 @@ type VoteBar struct { passPercentage float32 yesColor color.NRGBA noColor color.NRGBA + bgColor color.NRGBA yesLabel Label noLabel Label @@ -50,6 +51,7 @@ func (t *Theme) VoteBar(infoIcon, legendIcon *widget.Icon) VoteBar { quorumTooltip: t.Tooltip("", Left), infoIcon: infoIcon, legendIcon: legendIcon, + bgColor: t.Color.Gray, } } @@ -128,14 +130,14 @@ func (v *VoteBar) LayoutWithLegend(gtx C) D { leftPos := (v.passPercentage / 100) * float32(gtx.Constraints.Max.X) return layout.Inset{ Left: unit.Dp(leftPos), - Top: values.MarginPadding20, + Top: values.MarginPadding20, }.Layout(gtx, func(gtx C) D { gtx.Constraints.Min.X = 3 gtx.Constraints.Min.Y = 28 v.passTooltip.SetText(fmt.Sprintf("%d %% Yes votes required for approval", int(v.passPercentage))) return v.passTooltip.Layout(gtx, func(gtx C) D { - return fill(gtx, v.yesColor) + return fill(gtx, v.bgColor) }) }) }), diff --git a/ui/page.go b/ui/page.go index c0e24cc38..3ccc162d6 100644 --- a/ui/page.go +++ b/ui/page.go @@ -78,7 +78,8 @@ type pageCommon struct { subPageBackButton decredmaterial.IconButton subPageInfoButton decredmaterial.IconButton - changePage func(string) + changePage func(string) + refreshWindow func() } type ( @@ -245,6 +246,7 @@ func (win *Window) addPages(decredIcons map[string]image.Image) { subPageBackButton: win.theme.PlainIconButton(new(widget.Clickable), ic.navigationArrowBack), subPageInfoButton: win.theme.PlainIconButton(new(widget.Clickable), ic.actionInfo), changePage: win.changePage, + refreshWindow: win.refreshWindow, } common.testButton = win.theme.Button(new(widget.Clickable), "test button") @@ -276,6 +278,7 @@ func (win *Window) addPages(decredIcons map[string]image.Image) { win.pages[PageWalletSettings] = win.WalletSettingsPage(common) win.pages[PageSecurityTools] = win.SecurityToolsPage(common) win.pages[PageProposals] = win.ProposalsPage(common) + win.pages[PageProposalDetails] = win.ProposalDetailsPage(common) win.pages[PageDebug] = win.DebugPage(common) win.pages[PageAbout] = win.AboutPage(common) win.pages[PageHelp] = win.HelpPage(common) @@ -289,6 +292,10 @@ func (page pageCommon) ChangePage(pg string) { page.changePage(pg) } +func (page pageCommon) refresh() { + page.refreshWindow() +} + func (page pageCommon) Notify(text string, success bool) { go func() { page.toast <- &toast{ diff --git a/ui/proposal_details_page.go b/ui/proposal_details_page.go new file mode 100644 index 000000000..62a2e175d --- /dev/null +++ b/ui/proposal_details_page.go @@ -0,0 +1,288 @@ +package ui + +import ( + "encoding/base64" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + + "github.com/planetdecred/dcrlibwallet" + "github.com/planetdecred/godcr/ui/decredmaterial" + "github.com/planetdecred/godcr/ui/utils" + "github.com/planetdecred/godcr/ui/values" +) + +const ( + PageProposalDetails = "ProposalDetails" +) + +type proposalItemWidgets struct { + widgets []layout.Widget + clickables map[string]*widget.Clickable +} + +type proposalDetails struct { + theme *decredmaterial.Theme + line *decredmaterial.Line + backButton decredmaterial.IconButton + descriptionCard decredmaterial.Card + proposalItems map[string]proposalItemWidgets + clickables map[string]*widget.Clickable + descriptionList *layout.List + selectedProposal **dcrlibwallet.Proposal + redirectIcon *widget.Image + redirectButton *widget.Clickable + redirectLabel decredmaterial.Label + voteBar decredmaterial.VoteBar + rejectedIcon *widget.Icon + successIcon *widget.Icon +} + +func (win *Window) ProposalDetailsPage(common pageCommon) layout.Widget { + pg := &proposalDetails{ + theme: common.theme, + line: common.theme.Line(), + backButton: common.theme.PlainIconButton(new(widget.Clickable), common.icons.navigationArrowBack), + descriptionCard: common.theme.Card(), + descriptionList: &layout.List{Axis: layout.Vertical}, + selectedProposal: &win.selectedProposal, + redirectButton: new(widget.Clickable), + redirectIcon: common.icons.redirectIcon, + redirectLabel: common.theme.Body2("View on Politeia"), + voteBar: common.theme.VoteBar(common.icons.actionInfo, common.icons.imageBrightness1), + proposalItems: make(map[string]proposalItemWidgets), + rejectedIcon: common.icons.navigationCancel, + successIcon: common.icons.navigationCheck, + } + pg.backButton.Color = common.theme.Color.Hint + pg.backButton.Inset = layout.UniformInset(values.MarginPadding0) + pg.backButton.Size = values.MarginPadding30 + pg.line.Color = pg.theme.Color.Background + + return func(gtx C) D { + pg.handle(common) + return pg.Layout(gtx, common) + } +} + +func (pg *proposalDetails) handle(common pageCommon) { + for pg.backButton.Button.Clicked() { + common.ChangePage(PageProposals) + } +} + +func (pg *proposalDetails) layoutLinkButtons(gtx C) D { + proposal := *pg.selectedProposal + + return layout.Flex{}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return pg.backButton.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Left: values.MarginPadding5}.Layout(gtx, func(gtx C) D { + return pg.theme.H6(truncateString(proposal.Name, 40)).Layout(gtx) + }) + }), + layout.Flexed(1, func(gtx C) D { + return layout.E.Layout(gtx, func(gtx C) D { + return material.Clickable(gtx, pg.redirectButton, func(gtx C) D { + return layout.Flex{}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: values.MarginPadding5}.Layout(gtx, func(gtx C) D { + return pg.redirectLabel.Layout(gtx) + }) + }), + layout.Rigid(func(gtx C) D { + return pg.redirectIcon.Layout(gtx) + }), + ) + }) + }) + }), + ) +} + +func (pg *proposalDetails) getProposalText() []byte { + proposal := *pg.selectedProposal + desc, _ := base64.StdEncoding.DecodeString(proposal.IndexFile) + + return desc +} + +func (pg *proposalDetails) layoutProposalVoteBar(gtx C) D { + proposal := *pg.selectedProposal + + yes := float32(proposal.YesVotes) + no := float32(proposal.NoVotes) + quorumPercent := float32(proposal.QuorumPercentage) + passPercentage := float32(proposal.PassPercentage) + eligibleTickets := float32(proposal.EligibleTickets) + + return pg.voteBar.SetParams(yes, no, eligibleTickets, quorumPercent, passPercentage).LayoutWithLegend(gtx) +} + +func (pg *proposalDetails) layoutInDiscussionTitle(gtx C, proposal *dcrlibwallet.Proposal) D { + return D{} +} + +func (pg *proposalDetails) layoutVotingTitle(gtx C, proposal *dcrlibwallet.Proposal) D { + return D{} +} + +func (pg *proposalDetails) layoutNormalTitle(gtx C, proposal *dcrlibwallet.Proposal) D { + var label decredmaterial.Label + var icon *widget.Icon + switch proposal.Category { + case dcrlibwallet.ProposalCategoryApproved: + label = pg.theme.Body2("Approved") + icon = pg.successIcon + icon.Color = pg.theme.Color.Success + case dcrlibwallet.ProposalCategoryRejected: + label = pg.theme.Body2("Rejected") + icon = pg.rejectedIcon + icon.Color = pg.theme.Color.Danger + case dcrlibwallet.ProposalCategoryAbandoned: + label = pg.theme.Body2("Abandoned") + } + timeagoLabel := pg.theme.Body2(timeAgo(proposal.Timestamp)) + + return layout.Flex{}.Layout(gtx, + layout.Rigid(func(gtx C) D { + if icon == nil { + return D{} + } + return icon.Layout(gtx, unit.Dp(20)) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Left: values.MarginPadding5}.Layout(gtx, label.Layout) + }), + layout.Flexed(1, func(gtx C) D { + return layout.E.Layout(gtx, timeagoLabel.Layout) + }), + ) +} + +func (pg *proposalDetails) layoutTitle(gtx C) D { + proposal := *pg.selectedProposal + + return pg.descriptionCard.Layout(gtx, func(gtx C) D { + return layout.UniformInset(values.MarginPadding15).Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + if proposal.Category == dcrlibwallet.ProposalCategoryPre { + return pg.layoutInDiscussionTitle(gtx, proposal) + } else if proposal.Category == dcrlibwallet.ProposalCategoryActive { + return pg.layoutVotingTitle(gtx, proposal) + } + return pg.layoutNormalTitle(gtx, proposal) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: values.MarginPadding10, Bottom: values.MarginPadding10}.Layout(gtx, func(gtx C) D { + return pg.line.Layout(gtx) + }) + }), + layout.Rigid(func(gtx C) D { + if proposal.Category == dcrlibwallet.ProposalCategoryActive || + proposal.Category == dcrlibwallet.ProposalCategoryApproved || + proposal.Category == dcrlibwallet.ProposalCategoryRejected { + return pg.layoutProposalVoteBar(gtx) + } + return D{} + }), + ) + }) + }) +} + +func (pg *proposalDetails) layoutDescription(gtx C) D { + proposal := *pg.selectedProposal + dotLabel := pg.theme.H4(" . ") + userLabel := pg.theme.Body2(proposal.Username) + versionLabel := pg.theme.Body2("Version " + proposal.Version) + publishedLabel := pg.theme.Body2("Published " + timeAgo(proposal.PublishedAt)) + updatedLabel := pg.theme.Body2("Updated " + timeAgo(proposal.Timestamp)) + + w := []layout.Widget{ + func(gtx C) D { + return layout.Inset{ + Top: values.MarginPadding5, + Bottom: values.MarginPadding10, + }.Layout(gtx, func(gtx C) D { + return pg.theme.H5(proposal.Name).Layout(gtx) + }) + }, + func(gtx C) D { + pg.line.Width = gtx.Constraints.Max.X + return pg.line.Layout(gtx) + }, + func(gtx C) D { + return layout.Inset{ + Top: values.MarginPadding15, + Bottom: values.MarginPadding15, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{}.Layout(gtx, + layout.Rigid(userLabel.Layout), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: unit.Dp(-23)}.Layout(gtx, dotLabel.Layout) + }), + layout.Rigid(publishedLabel.Layout), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: unit.Dp(-23)}.Layout(gtx, dotLabel.Layout) + }), + layout.Rigid(versionLabel.Layout), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: unit.Dp(-23)}.Layout(gtx, dotLabel.Layout) + }), + layout.Rigid(updatedLabel.Layout), + ) + }) + }, + func(gtx C) D { + pg.line.Width = gtx.Constraints.Max.X + return pg.line.Layout(gtx) + }, + } + + if item, ok := pg.proposalItems[proposal.Token]; ok { + w = append(w, item.widgets...) + } else { + r := utils.RenderMarkdown(gtx, pg.theme, pg.getProposalText()) + proposalWidgets, proposalClickables := r.Layout() + pg.proposalItems[proposal.Token] = proposalItemWidgets{ + widgets: proposalWidgets, + clickables: proposalClickables, + } + w = append(w, proposalWidgets...) + } + + return pg.descriptionCard.Layout(gtx, func(gtx C) D { + return layout.UniformInset(values.MarginPadding15).Layout(gtx, func(gtx C) D { + return pg.descriptionList.Layout(gtx, len(w), func(gtx C, i int) D { + return layout.UniformInset(unit.Dp(0)).Layout(gtx, w[i]) + }) + }) + }) +} + +func (pg *proposalDetails) Layout(gtx C, common pageCommon) D { + return common.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return pg.layoutLinkButtons(gtx) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{ + Top: unit.Dp(-30), + Bottom: values.MarginPadding10, + }.Layout(gtx, func(gtx C) D { + return pg.layoutTitle(gtx) + }) + }), + layout.Rigid(func(gtx C) D { + return pg.layoutDescription(gtx) + }), + ) + }) +} diff --git a/ui/proposals_page.go b/ui/proposals_page.go index 60ec949ac..0415ceba9 100644 --- a/ui/proposals_page.go +++ b/ui/proposals_page.go @@ -5,7 +5,6 @@ import ( "image/color" "strings" "time" - //"fmt" "gioui.org/layout" "gioui.org/op/clip" @@ -28,6 +27,26 @@ const ( categoryStateError ) +type proposalNotificationListeners struct { + page *proposalsPage +} + +func (p proposalNotificationListeners) OnNewProposal(proposal *dcrlibwallet.Proposal) { + p.page.addDiscoveredProposal(*proposal) +} + +func (p proposalNotificationListeners) OnProposalVoteStarted(proposal *dcrlibwallet.Proposal) { + p.page.updateProposal(*proposal) +} + +func (p proposalNotificationListeners) OnProposalVoteFinished(proposal *dcrlibwallet.Proposal) { + p.page.updateProposal(*proposal) +} + +func (p proposalNotificationListeners) OnProposalsSynced() { + p.page.refreshWindow() +} + type proposalItem struct { btn *widget.Clickable proposal dcrlibwallet.Proposal @@ -59,6 +78,9 @@ type proposalsPage struct { hasFetchedInitialProposal bool legendIcon *widget.Icon infoIcon *widget.Icon + selectedProposal **dcrlibwallet.Proposal + hasRegisteredListeners bool + refreshWindow func() } var ( @@ -74,15 +96,17 @@ var ( func (win *Window) ProposalsPage(common pageCommon) layout.Widget { pg := &proposalsPage{ - theme: common.theme, - wallet: win.wallet, - proposalsList: &layout.List{Axis: layout.Vertical}, - scrollContainer: common.theme.ScrollContainer(), - tabCard: common.theme.Card(), - itemCard: common.theme.Card(), - notify: common.Notify, - legendIcon: common.icons.imageBrightness1, - infoIcon: common.icons.actionInfo, + theme: common.theme, + wallet: win.wallet, + proposalsList: &layout.List{Axis: layout.Vertical}, + scrollContainer: common.theme.ScrollContainer(), + tabCard: common.theme.Card(), + itemCard: common.theme.Card(), + notify: common.Notify, + legendIcon: common.icons.imageBrightness1, + infoIcon: common.icons.actionInfo, + selectedProposal: &win.selectedProposal, + refreshWindow: common.refreshWindow, } pg.tabCard.Radius = decredmaterial.CornerRadius{NE: 0, NW: 0, SE: 0, SW: 0} @@ -98,12 +122,12 @@ func (win *Window) ProposalsPage(common pageCommon) layout.Widget { } return func(gtx C) D { - pg.handle(common) + pg.Handle(common) return pg.Layout(gtx, common) } } -func (pg *proposalsPage) handle(common pageCommon) { +func (pg *proposalsPage) Handle(common pageCommon) { for i := range pg.tabs.tabs { if pg.tabs.tabs[i].btn.Clicked() { pg.tabs.selected = i @@ -111,13 +135,41 @@ func (pg *proposalsPage) handle(common pageCommon) { } for k := range pg.tabs.tabs[i].proposals { - if pg.tabs.tabs[i].proposals[k].btn.Clicked() { - // TODO goto proposal details page + for pg.tabs.tabs[i].proposals[k].btn.Clicked() { + *pg.selectedProposal = &pg.tabs.tabs[i].proposals[k].proposal + common.ChangePage(PageProposalDetails) } } } } +func (pg *proposalsPage) addDiscoveredProposal(proposal dcrlibwallet.Proposal) { + for i := range pg.tabs.tabs { + if pg.tabs.tabs[i].category == proposal.Category { + item := proposalItem{ + btn: new(widget.Clickable), + proposal: proposal, + voteBar: pg.theme.VoteBar(pg.infoIcon, pg.legendIcon), + } + pg.tabs.tabs[i].proposals = append([]proposalItem{item}, pg.tabs.tabs[i].proposals...) + break + } + } +} + +func (pg *proposalsPage) updateProposal(proposal dcrlibwallet.Proposal) { +out: + for i := range pg.tabs.tabs { + for k := range pg.tabs.tabs[i].proposals { + if pg.tabs.tabs[i].proposals[k].proposal.Token == proposal.Token { + pg.tabs.tabs[i].proposals = append(pg.tabs.tabs[i].proposals[:k], pg.tabs.tabs[i].proposals[k+1:]...) + break out + } + } + } + pg.addDiscoveredProposal(proposal) +} + func (pg *proposalsPage) onfetchSuccess(proposals []dcrlibwallet.Proposal) { pg.tabs.tabs[pg.tabs.selected].proposals = make([]proposalItem, len(proposals)) for i := range proposals { @@ -209,7 +261,6 @@ func (pg *proposalsPage) layoutAuthorAndDate(gtx C, proposal dcrlibwallet.Propos nameLabel := pg.theme.Body2(proposal.Username) dotLabel := pg.theme.H4(" . ") versionLabel := pg.theme.Body2("Version " + proposal.Version) - timeAgoLabel := pg.theme.Body2(timeAgo(proposal.Timestamp)) var categoryLabel decredmaterial.Label @@ -332,6 +383,11 @@ func (pg *proposalsPage) Layout(gtx C, common pageCommon) D { pg.fetchProposalsForCategory() } + if !pg.hasRegisteredListeners { + pg.wallet.AddProposalNotificationListener(proposalNotificationListeners{pg}) + pg.hasRegisteredListeners = true + } + return common.LayoutWithoutPadding(gtx, func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(pg.layoutTabs), @@ -344,3 +400,14 @@ func timeAgo(timestamp int64) string { timeAgo, _ := timeago.TimeAgoWithTime(time.Now(), time.Unix(timestamp, 0)) return timeAgo } + +func truncateString(str string, num int) string { + bnoden := str + if len(str) > num { + if num > 3 { + num -= 3 + } + bnoden = str[0:num] + "..." + } + return bnoden +} diff --git a/ui/utils/markdown.go b/ui/utils/markdown.go new file mode 100644 index 000000000..343694ca2 --- /dev/null +++ b/ui/utils/markdown.go @@ -0,0 +1,547 @@ +package utils + +import ( + "fmt" + "io" + "strings" + "unicode" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + + md "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/ast" + "github.com/gomarkdown/markdown/parser" + "github.com/planetdecred/godcr/ui/decredmaterial" +) + +type ( + C = layout.Context + D = layout.Dimensions +) + +type labelFunc func(string) decredmaterial.Label + +type Renderer struct { + gtx layout.Context + theme *decredmaterial.Theme + maxWidth int + isList bool + + prefix string + + // constant left padding to apply + leftPad int + // all the custom left paddings, without the fixed space from leftPad + padAccumulator []string + + links map[string]*widget.Clickable + //accumulatedLabels []labelWidget + stringBuilder strings.Builder + containers []layout.Widget + + table *tableRenderer +} + +const ( + bulletUnicode = "\u2022" + linkTag = "[[link" + linkSpacer = "@@@@" +) + +func RenderMarkdown(gtx layout.Context, theme *decredmaterial.Theme, source []byte) *Renderer { + extensions := parser.NoIntraEmphasis // Ignore emphasis markers inside words + extensions |= parser.Tables // Parse tables + extensions |= parser.FencedCode // Parse fenced code blocks + extensions |= parser.Autolink // Detect embedded URLs that are not explicitly marked + extensions |= parser.Strikethrough // Strikethrough text using ~~test~~ + extensions |= parser.SpaceHeadings // Be strict about prefix heading rules + extensions |= parser.HeadingIDs // specify heading IDs with {#id} + extensions |= parser.BackslashLineBreak // Translate trailing backslashes into line breaks + extensions |= parser.DefinitionLists // Parse definition lists + extensions |= parser.LaxHTMLBlocks // more in HTMLBlock, less in HTMLSpan + extensions |= parser.NoEmptyLineBeforeBlock // no need for new line before a list + + p := parser.NewWithExtensions(extensions) + + source = prepareDocForTable(source) + nodes := md.Parse(source, p) + renderer := newRenderer(gtx, theme) + md.Render(nodes, renderer) + + return renderer +} + +func newRenderer(gtx layout.Context, theme *decredmaterial.Theme) *Renderer { + return &Renderer{ + gtx: gtx, + theme: theme, + maxWidth: gtx.Constraints.Max.X - 100, + } +} + +func prepareDocForTable(doc []byte) []byte { + d := strings.Replace(string(doc), ":|", "------:|", -1) + d = strings.Replace(d, "-|", "------|", -1) + d = strings.Replace(d, "|-", "|------", -1) + d = strings.Replace(d, "|:-", "|:------", -1) + + return []byte(d) +} + +func (r *Renderer) pad() string { + return strings.Repeat(" ", r.leftPad) + strings.Join(r.padAccumulator, "") +} + +func (r *Renderer) addPad(pad string) { + r.padAccumulator = append(r.padAccumulator, pad) +} + +func (r *Renderer) popPad() { + r.padAccumulator = r.padAccumulator[:len(r.padAccumulator)-1] +} + +func (r *Renderer) RenderNode(w io.Writer, node ast.Node, entering bool) ast.WalkStatus { + switch node := node.(type) { + case *ast.Document: + // Nothing to do + case *ast.BlockQuote: + + case *ast.List: + // extra new line at the end of a list *if* next is not a list + if next := ast.GetNextNode(node); !entering && next != nil { + _, parentIsListItem := node.GetParent().(*ast.ListItem) + _, nextIsList := next.(*ast.List) + if !nextIsList && !parentIsListItem { + r.renderEmptyLine() + } + } + + case *ast.ListItem: + r.renderList(node, entering) + case *ast.Paragraph: + if !entering { + r.renderParagraph() + } + case *ast.Heading: + if !entering { + r.renderHeading(node.Level, true) + } + case *ast.Strong: + if !entering { + r.renderHeading(6, false) + } + case *ast.Link: + if !entering { + r.renderLink(node) + return ast.SkipChildren + } + case *ast.Text: + r.renderText(node) + case *ast.Table: + r.renderTable(entering) + case *ast.TableCell: + if !entering { + r.renderTableCell(node) + } + case *ast.TableRow: + r.renderTableRow(node, entering) + } + + return ast.GoToNext +} + +func (r *Renderer) renderParagraph() { + r.renderWords(r.theme.Body2) + // add dummy widget for new line + r.renderEmptyLine() +} + +func (r *Renderer) renderHeading(level int, block bool) { + lblFunc := r.theme.H6 + + switch level { + case 1: + lblFunc = r.theme.H4 + case 2: + lblFunc = r.theme.H5 + case 3: + lblFunc = r.theme.H6 + } + + r.renderWords(lblFunc) + if block { + // add dummy widget for new line + r.renderEmptyLine() + } +} + +func (r *Renderer) renderLink(node *ast.Link) { + dest := string(node.Destination) + text := string(ast.GetFirstChild(node).AsLeaf().Literal) + + if r.links == nil { + r.links = map[string]*widget.Clickable{} + } + + if _, ok := r.links[dest]; !ok { + r.links[dest] = new(widget.Clickable) + } + + // fix a bug that causes the link to be written to the builder before this is called + content := r.stringBuilder.String() + r.stringBuilder.Reset() + parts := strings.Split(content, " ") + parts = parts[:len(parts)-1] + for i := range parts { + r.stringBuilder.WriteString(parts[i] + " ") + } + + word := linkTag + linkSpacer + dest + linkSpacer + text + r.stringBuilder.WriteString(word) +} + +func (r *Renderer) renderText(node *ast.Text) { + if string(node.Literal) == "\n" { + return + } + + content := string(node.Literal) + if shouldCleanText(node) { + content = removeLineBreak(content) + } + r.stringBuilder.WriteString(content) +} + +func (r *Renderer) renderWords(lblFunc labelFunc) { + content := r.stringBuilder.String() + r.stringBuilder.Reset() + + words := strings.Fields(content) + words = append([]string{r.prefix}, words...) + r.prefix = "" + + wdgt := func(gtx C) D { + return decredmaterial.GridWrap{ + Axis: layout.Horizontal, + Alignment: layout.Start, + }.Layout(gtx, len(words), func(gtx C, i int) D { + if strings.HasPrefix(words[i], linkTag) { + return r.getLinkWidget(gtx, words[i]) + } + + return lblFunc(words[i] + " ").Layout(gtx) + }) + } + r.containers = append(r.containers, wdgt) +} + +func (r *Renderer) renderEmptyLine() { + var padding = -5 + + if r.isList { + padding = -10 + r.isList = false + } + + r.containers = append(r.containers, func(gtx C) D { + dims := r.theme.Body2("").Layout(gtx) + dims.Size.Y = dims.Size.Y + padding + return dims + }) +} + +func (r *Renderer) renderList(node *ast.ListItem, entering bool) { + if entering { + r.isList = true + switch { + // numbered list + case node.ListFlags&ast.ListTypeOrdered != 0: + itemNumber := 1 + siblings := node.GetParent().GetChildren() + for _, sibling := range siblings { + if sibling == node { + break + } + itemNumber++ + } + r.prefix += fmt.Sprintf("%d. ", itemNumber) + + // content of a definition + case node.ListFlags&ast.ListTypeDefinition != 0: + r.prefix += " " + + // no flags means it's the normal bullet point list + default: + r.prefix += " " + bulletUnicode + " " + } + } +} + +func (r *Renderer) renderTable(entering bool) { + if entering { + r.table = newTableRenderer(r.theme) + } else { + r.containers = append(r.containers, r.table.Render()) + r.table = nil + } +} + +func (r *Renderer) renderTableCell(node *ast.TableCell) { + content := r.stringBuilder.String() + r.stringBuilder.Reset() + + align := CellAlignLeft + switch node.Align { + case ast.TableAlignmentRight: + align = CellAlignRight + case ast.TableAlignmentCenter: + align = CellAlignCenter + } + + if node.IsHeader { + r.table.AddHeaderCell(content, align) + } else { + r.table.AddBodyCell(content, CellAlignCopyHeader) + } +} + +func (r *Renderer) renderTableRow(node *ast.TableRow, entering bool) { + if _, ok := node.Parent.(*ast.TableBody); ok && entering { + r.table.NextBodyRow() + } + if _, ok := node.Parent.(*ast.TableFooter); ok && entering { + r.table.NextBodyRow() + } +} + +func (*Renderer) RenderHeader(w io.Writer, node ast.Node) {} + +func (*Renderer) RenderFooter(w io.Writer, node ast.Node) {} + +func (r *Renderer) Layout() ([]layout.Widget, map[string]*widget.Clickable) { + return r.containers, r.links +} + +func shouldCleanText(node ast.Node) bool { + for node != nil { + switch node.(type) { + case *ast.BlockQuote: + return false + + case *ast.Heading, *ast.Image, *ast.Link, + *ast.TableCell, *ast.Document, *ast.ListItem: + return true + } + node = node.GetParent() + } + + return false +} + +func removeLineBreak(text string) string { + lines := strings.Split(text, "\n") + + if len(lines) <= 1 { + return text + } + + for i, l := range lines { + switch i { + case 0: + lines[i] = strings.TrimRightFunc(l, unicode.IsSpace) + case len(lines) - 1: + lines[i] = strings.TrimLeftFunc(l, unicode.IsSpace) + default: + lines[i] = strings.TrimFunc(l, unicode.IsSpace) + } + } + + return strings.Join(lines, " ") +} + +func (r *Renderer) getLinkWidget(gtx layout.Context, linkWord string) D { + parts := strings.Split(linkWord, linkSpacer) + + gtx.Constraints.Max.X = gtx.Constraints.Max.X - 200 + return material.Clickable(gtx, r.links[parts[1]], func(gtx C) D { + lbl := r.theme.Body2(parts[2] + " ") + lbl.Color = r.theme.Color.Primary + return lbl.Layout(gtx) + }) +} + +type CellAlign int + +const ( + CellAlignLeft CellAlign = iota + CellAlignRight + CellAlignCenter + CellAlignCopyHeader +) + +type tableCell struct { + content string + alignment CellAlign + contentLength float64 +} + +type tableRenderer struct { + header []tableCell + body [][]tableCell + + widths []float64 + theme *decredmaterial.Theme +} + +func newTableRenderer(theme *decredmaterial.Theme) *tableRenderer { + return &tableRenderer{ + theme: theme, + } +} + +func (tr *tableRenderer) AddHeaderCell(content string, alignment CellAlign) { + tr.header = append(tr.header, tableCell{ + content: content, + contentLength: float64(len(content)), + alignment: alignment, + }) + tr.widths = append(tr.widths, 0) +} + +func (tr *tableRenderer) NextBodyRow() { + tr.body = append(tr.body, nil) +} + +func (tr *tableRenderer) AddBodyCell(content string, alignement CellAlign) { + row := tr.body[len(tr.body)-1] + row = append(row, tableCell{ + content: content, + contentLength: float64(len(content)), + alignment: alignement, + }) + tr.body[len(tr.body)-1] = row +} + +// normalize ensure that the table has the same number of cells +// in each rows, header or not. +func (tr *tableRenderer) normalize() { + width := len(tr.header) + /**for _, row := range tr.body { + //width = max(width, len(row)) + }**/ + + // grow the header if needed + for len(tr.header) < width { + tr.header = append(tr.header, tableCell{}) + } + + // grow lines if needed + for i := range tr.body { + for len(tr.body[i]) < width { + tr.body[i] = append(tr.body[i], tableCell{}) + } + } +} + +func (tr *tableRenderer) copyAlign() { + for i, row := range tr.body { + for j, cell := range row { + if cell.alignment == CellAlignCopyHeader { + tr.body[i][j].alignment = tr.header[j].alignment + } + } + } +} + +func (tr *tableRenderer) calculateLengths() { + textLenghts := make([]float64, len(tr.header)) + + for i := range tr.header { + index := i + textLenghts[index] = tr.header[index].contentLength + } + + for i := range tr.body { + index := i + for k := range tr.body[index] { + kIndex := k + if textLenghts[kIndex] < tr.body[index][kIndex].contentLength { + textLenghts[kIndex] = tr.body[index][kIndex].contentLength + } + } + } + + total := float64(0) + for i := range textLenghts { + index := i + total += textLenghts[index] + } + + totalWidthRecouped := float64(0) + cutWidths := []int{} + for i := range textLenghts { + index := i + tr.widths[index] = (textLenghts[index] / total) * float64(100) + if tr.widths[index] > 40 { + totalWidthRecouped += tr.widths[index] - 40 + tr.widths[index] = 40 + cutWidths = append(cutWidths, index) + } + } + + averageWidthToAdd := totalWidthRecouped / float64(len(tr.widths)-len(cutWidths)) + for i := range tr.widths { + index := i + for k := range cutWidths { + kIndex := k + if index == kIndex { + continue + } + tr.widths[index] += averageWidthToAdd + } + } +} + +func (tr *tableRenderer) Render() layout.Widget { + var tableChildren []layout.FlexChild + tr.normalize() + tr.copyAlign() + + tr.calculateLengths() + + if tr.header != nil { + header := tr.getTableRow(tr.header) + tableChildren = append(tableChildren, layout.Rigid(header)) + } + + for i := range tr.body { + index := i + row := tr.getTableRow(tr.body[index]) + tableChildren = append(tableChildren, layout.Rigid(row)) + } + + return func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, tableChildren...) + } +} + +func (tr *tableRenderer) getTableRow(row []tableCell) func(gtx C) D { + children := make([]layout.FlexChild, len(row)) + for i := range row { + index := i + children[index] = layout.Rigid(func(gtx C) D { + gtx.Constraints.Max.X = int((tr.widths[index] / 100) * float64(gtx.Constraints.Max.X)) + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return tr.theme.Body2(row[index].content).Layout(gtx) + }) + } + + return func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + dims := layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx, children...) + dims.Size.Y += 5 + return dims + } +} diff --git a/ui/window.go b/ui/window.go index f3fd359d7..43095f0e5 100644 --- a/ui/window.go +++ b/ui/window.go @@ -58,6 +58,8 @@ type Window struct { toast chan *toast modal chan *modalLoad sysDestroyWithSync bool + + selectedProposal *dcrlibwallet.Proposal } type WriteClipboard struct { @@ -112,6 +114,10 @@ func CreateWindow(wal *wallet.Wallet, decredIcons map[string]image.Image, collec func (win *Window) changePage(page string) { win.current = page + win.refreshWindow() +} + +func (win *Window) refreshWindow() { win.window.Invalidate() } diff --git a/wallet/commands.go b/wallet/commands.go index b216c5953..22e81d225 100644 --- a/wallet/commands.go +++ b/wallet/commands.go @@ -693,6 +693,10 @@ func (wal *Wallet) SyncProposals() { go wal.multi.Politeia.Sync() } +func (wal *Wallet) AddProposalNotificationListener(listener dcrlibwallet.ProposalNotificationListener) error { + return wal.multi.Politeia.AddNotificationListener(listener, "godcr") +} + func (wal *Wallet) GetWalletSeedPhrase(walletID int, password []byte) (string, error) { return wal.multi.WalletWithID(walletID).DecryptSeed(password) }