Better traversal; added persistent list expansion state

This commit is contained in:
diamondburned 2020-09-02 00:11:48 -07:00
parent 8838b8e0d8
commit 35c186580b
13 changed files with 307 additions and 116 deletions

View File

@ -90,7 +90,25 @@ func Save() error {
}
// Restore the global config. IsNotExist is not an error and will not be
// returned.
func Restore() error {
return UnmarshalFromFile(ConfigFile, &sections)
// logged.
func Restore() {
if err := UnmarshalFromFile(ConfigFile, &sections); err != nil {
log.Error(errors.Wrap(err, "Failed to unmarshal main config.json"))
}
log.Printlnf("To restore: %#v", toRestore)
for path, v := range toRestore {
if err := UnmarshalFromFile(path, v); err != nil {
log.Error(errors.Wrapf(err, "Failed to unmarshal %s", path))
}
}
}
var toRestore = map[string]interface{}{}
// RegisterConfig adds the config filename into the registry of value pointers
// to unmarshal configs to.
func RegisterConfig(filename string, jsonValue interface{}) {
toRestore[filename] = jsonValue
}

View File

@ -2,6 +2,7 @@ package config
import (
"encoding/json"
"io"
"log"
"os"
"path/filepath"
@ -33,6 +34,13 @@ func __init() {
}
}
// PrettyMarshal pretty marshals v into dst as formatted JSON.
func PrettyMarshal(dst io.Writer, v interface{}) error {
enc := json.NewEncoder(dst)
enc.SetIndent("", "\t")
return enc.Encode(v)
}
// DirPath returns the config directory.
func DirPath() string {
// Ensure that files and folders are initialized.
@ -41,6 +49,24 @@ func DirPath() string {
return dirPath
}
// SaveToFile saves the given bytes into the given filename. The filename will
// be prepended with the config directory.
func SaveToFile(file string, v []byte) error {
file = filepath.Join(DirPath(), file)
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")
}
defer f.Close()
if _, err := f.Write(v); err != nil {
return errors.Wrap(err, "Failed to write")
}
return nil
}
// MarshalToFile marshals the given interface into the given filename. The
// filename will be prepended with the config directory.
func MarshalToFile(file string, from interface{}) error {
@ -52,10 +78,7 @@ func MarshalToFile(file string, from interface{}) error {
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", "\t")
if err := enc.Encode(from); err != nil {
if err := PrettyMarshal(f, from); err != nil {
return errors.Wrap(err, "Failed to marshal given struct")
}

View File

@ -148,7 +148,7 @@ func (h *Header) SetBreadcrumber(b traverse.Breadcrumber) {
return
}
h.breadcrumbs = b.Breadcrumb()
h.breadcrumbs = traverse.TryBreadcrumb(b)
if len(h.breadcrumbs) < 2 {
return
}

View File

@ -1,3 +1,5 @@
// Package config contains UI widgets and renderers for cchat's Configurator
// interface.
package config
import (

View File

@ -112,7 +112,7 @@ func (h *Header) SetBreadcrumber(b traverse.Breadcrumber) {
return
}
if crumb := b.Breadcrumb(); len(crumb) > 0 {
if crumb := traverse.TryBreadcrumb(b); len(crumb) > 0 {
h.SvcName.SetText(crumb[0])
} else {
h.SvcName.SetText("")

View File

@ -0,0 +1,109 @@
package savepath
import (
"bytes"
"time"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/config"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/pkg/errors"
)
// map of services to a list of list of IDs.
var paths = make(pathMap)
type pathMap map[string]pathMap
const configName = "savepaths.json"
func init() {
config.RegisterConfig(configName, &paths)
}
// ActiveSetter is an interface for all widgets that allow setting the active
// state.
type ActiveSetter interface {
SetActive(bool)
}
// Restore restores the expand state by calling SetActive. This is meant to be
// used on a ToggledButton.
func Restore(b traverse.Breadcrumber, asetter ActiveSetter) {
if IsExpanded(b) {
asetter.SetActive(true)
}
}
// IsExpanded returns true if the current breadcrumb node is expanded.
func IsExpanded(b traverse.Breadcrumber) bool {
var path = traverse.TryID(b)
var node = paths
// Descend and traverse.
var nest = 0
for ; nest < len(path); nest++ {
ch, ok := node[path[nest]]
if !ok {
return false
}
node = ch
}
// Return true if there is available a path that at least matches with the
// breadcrumb path.
return nest == len(path)
}
// SaveDelay is the delay to wait before saving.
const SaveDelay = 5 * time.Second
var lastSaved int64
// Save saves the list of paths. This function is not thread-safe. It is also
// non-blocking.
func Save() {
var now = time.Now().UnixNano()
if (lastSaved + int64(SaveDelay)) > now {
return
}
lastSaved = now
gts.AfterFunc(SaveDelay, func() {
var buf bytes.Buffer
// Marshal in the same thread to avoid race conditions.
if err := config.PrettyMarshal(&buf, paths); err != nil {
log.Error(errors.Wrap(err, "Failed to marshal paths"))
return
}
go func() {
if err := config.SaveToFile(configName, buf.Bytes()); err != nil {
log.Error(errors.Wrap(err, "Failed to save paths"))
}
}()
})
}
func Update(b traverse.Breadcrumber, expanded bool) {
var path = traverse.TryID(b)
var node = paths
// Descend and initialize.
for i := 0; i < len(path); i++ {
ch, ok := node[path[i]]
if !ok {
ch = make(pathMap)
node[path[i]] = ch
}
node = ch
}
Save()
}

View File

@ -208,8 +208,12 @@ func (s *Service) MoveSession(id, movingID string) {
s.SaveAllSessions()
}
func (s *Service) Breadcrumb() traverse.Breadcrumb {
return traverse.TryBreadcrumb(nil, s.service.Name().Content)
func (s *Service) Breadcrumb() string {
return s.service.Name().Content
}
func (s *Service) ParentBreadcrumb() traverse.Breadcrumber {
return nil
}
func (s *Service) SaveAllSessions() {

View File

@ -5,6 +5,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/service/loading"
"github.com/diamondburned/cchat-gtk/internal/ui/service/savepath"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/gotk3/gotk3/gtk"
)
@ -180,6 +181,9 @@ func (c *Children) LoadAll() {
row.Show()
c.Box.Add(row)
}
// Restore expansion if possible.
savepath.Restore(row, row.Button)
}
// Check if we have icons.
@ -227,6 +231,6 @@ func (c *Children) saveSelectedRow() (restore func()) {
}
}
func (c *Children) Breadcrumb() traverse.Breadcrumb {
return traverse.TryBreadcrumb(c.Parent)
func (c *Children) ParentBreadcrumb() traverse.Breadcrumber {
return c.Parent
}

View File

@ -7,6 +7,7 @@ import (
"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"
"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/traverse"
"github.com/diamondburned/cchat/text"
@ -24,7 +25,19 @@ func AssertUnhollow(hollower interface{ IsHollow() bool }) {
}
type ServerRow struct {
*Row
*gtk.Box
Avatar *roundimage.Avatar
Button *button.ToggleButtonImage
parentcrumb traverse.Breadcrumber
// non-nil if server list and the function returns error
childrenErr error
childrev *gtk.Revealer
children *Children
serverList cchat.ServerList
ctrl Controller
Server cchat.Server
@ -49,7 +62,7 @@ var serverCSS = primitives.PrepareClassCSS("server", `
// hollow children containers and rows for the given server.
func NewHollowServer(p traverse.Breadcrumber, sv cchat.Server, ctrl Controller) *ServerRow {
var serverRow = &ServerRow{
Row: NewHollowRow(p),
parentcrumb: p,
ctrl: ctrl,
Server: sv,
cancelUnread: func() {},
@ -84,12 +97,30 @@ func (r *ServerRow) Init() {
}
// Initialize the row, which would fill up the button and others as well.
r.Row.Init(r.Server.Name())
r.Row.SetIconer(r.Server)
serverCSS(r.Row)
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.Show()
r.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
r.Box.PackStart(r.Button, false, false, 0)
serverCSS(r.Box)
// Ensure errors are displayed.
r.childrenSetErr(r.childrenErr)
// Try to set an icon.
r.SetIconer(r.Server)
// Connect the destroyer, if any.
r.Row.Connect("destroy", r.cancelUnread)
r.Connect("destroy", r.cancelUnread)
// Restore the read state.
r.Button.SetUnreadUnsafe(r.unread, r.mentioned) // update with state
@ -151,60 +182,13 @@ func (r *ServerRow) SetUnreadUnsafe(unread, mentioned bool) {
traverse.TrySetUnread(r.parentcrumb, r.Server.ID(), r.unread, r.mentioned)
}
type Row struct {
*gtk.Box
Avatar *roundimage.Avatar
Button *button.ToggleButtonImage
parentcrumb traverse.Breadcrumber
// non-nil if server list and the function returns error
childrenErr error
childrev *gtk.Revealer
children *Children
serverList cchat.ServerList
}
func NewHollowRow(parent traverse.Breadcrumber) *Row {
return &Row{
parentcrumb: parent,
}
}
func (r *Row) IsHollow() bool {
func (r *ServerRow) IsHollow() bool {
return r.Box == nil
}
// Init initializes the row from its initial hollow state. It does nothing after
// the first call.
func (r *Row) Init(name text.Rich) {
if !r.IsHollow() {
return
}
r.Avatar = roundimage.NewAvatar(IconSize)
r.Avatar.SetText(name.Content)
r.Avatar.Show()
btn := rich.NewCustomToggleButtonImage(r.Avatar, name)
btn.Show()
r.Button = button.WrapToggleButtonImage(btn)
r.Button.Box.SetHAlign(gtk.ALIGN_START)
r.Button.SetRelief(gtk.RELIEF_NONE)
r.Button.Show()
r.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
r.Box.PackStart(r.Button, false, false, 0)
// Ensure errors are displayed.
r.childrenSetErr(r.childrenErr)
}
// SetHollowServerList sets the row to a hollow server list (children) and
// recursively create
func (r *Row) SetHollowServerList(list cchat.ServerList, ctrl Controller) {
func (r *ServerRow) SetHollowServerList(list cchat.ServerList, ctrl Controller) {
r.serverList = list
r.children = NewHollowChildren(r, ctrl)
@ -230,7 +214,7 @@ func (r *Row) SetHollowServerList(list cchat.ServerList, ctrl Controller) {
}
// Reset clears off all children servers. It's a no-op if there are none.
func (r *Row) Reset() {
func (r *ServerRow) Reset() {
if r.children != nil {
// Remove everything from the children container.
r.children.Reset()
@ -244,7 +228,7 @@ func (r *Row) Reset() {
r.children = nil
}
func (r *Row) childrenSetErr(err error) {
func (r *ServerRow) childrenSetErr(err error) {
// Update the state and only use this state field.
r.childrenErr = err
@ -273,21 +257,29 @@ func (r *ServerRow) HasIcon() bool {
return !r.IsHollow() && r.Button.Image.GetRevealChild()
}
func (r *Row) Breadcrumb() traverse.Breadcrumb {
if r.IsHollow() {
return nil
}
return traverse.TryBreadcrumb(r.parentcrumb, r.Button.GetText())
func (r *ServerRow) ParentBreadcrumb() traverse.Breadcrumber {
return r.parentcrumb
}
func (r *Row) SetLabelUnsafe(name text.Rich) {
func (r *ServerRow) Breadcrumb() string {
if r.IsHollow() {
return ""
}
return r.Button.GetText()
}
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)
}
func (r *Row) SetIconer(v interface{}) {
func (r *ServerRow) SetIconer(v interface{}) {
AssertUnhollow(r)
if iconer, ok := v.(cchat.Icon); ok {
@ -297,7 +289,7 @@ func (r *Row) SetIconer(v interface{}) {
}
// SetLoading is called by the parent struct.
func (r *Row) SetLoading() {
func (r *ServerRow) SetLoading() {
AssertUnhollow(r)
r.SetSensitive(false)
@ -307,7 +299,7 @@ func (r *Row) SetLoading() {
// SetFailed is shared between the parent struct and the children list. This is
// because both of those errors share the same appearance, just different
// callbacks.
func (r *Row) SetFailed(err error, retry func()) {
func (r *ServerRow) SetFailed(err error, retry func()) {
AssertUnhollow(r)
r.SetSensitive(true)
@ -318,7 +310,7 @@ func (r *Row) SetFailed(err error, retry func()) {
// SetDone is shared between the parent struct and the children list. This is
// because both will use the same SetFailed.
func (r *Row) SetDone() {
func (r *ServerRow) SetDone() {
AssertUnhollow(r)
r.Button.SetNormal()
@ -326,7 +318,7 @@ func (r *Row) SetDone() {
r.SetTooltipText("")
}
func (r *Row) SetNormalExtraMenu(items []menu.Item) {
func (r *ServerRow) SetNormalExtraMenu(items []menu.Item) {
AssertUnhollow(r)
r.Button.SetNormalExtraMenu(items)
@ -335,13 +327,13 @@ func (r *Row) SetNormalExtraMenu(items []menu.Item) {
}
// SetSelected is used for highlighting the current message server.
func (r *Row) SetSelected(selected bool) {
func (r *ServerRow) SetSelected(selected bool) {
AssertUnhollow(r)
r.Button.SetSelected(selected)
}
func (r *Row) GetActive() bool {
func (r *ServerRow) GetActive() bool {
if !r.IsHollow() {
return r.Button.GetActive()
}
@ -351,7 +343,7 @@ func (r *Row) GetActive() bool {
// SetRevealChild reveals the list of servers. It does nothing if there are no
// servers, meaning if Row does not represent a ServerList.
func (r *Row) SetRevealChild(reveal bool) {
func (r *ServerRow) SetRevealChild(reveal bool) {
AssertUnhollow(r)
// Do the above noop check.
@ -371,11 +363,14 @@ func (r *Row) SetRevealChild(reveal bool) {
// to call Servers on this. Now, we already know that there are hollow
// servers in the children container.
r.children.LoadAll()
// Save the path.
savepath.Update(r, reveal)
}
// GetRevealChild returns whether or not the server list is expanded, or always
// false if there is no server list.
func (r *Row) GetRevealChild() bool {
func (r *ServerRow) GetRevealChild() bool {
AssertUnhollow(r)
if r.childrev != nil {

View File

@ -7,31 +7,76 @@
package traverse
import (
"strings"
"github.com/diamondburned/cchat"
)
type Breadcrumb []string
func (b Breadcrumb) String() string {
return strings.Join([]string(b), "/")
}
// Breadcrumber is the base interface that other interfaces extend on. A child
// must at minimum implement this interface to use any other.
type Breadcrumber interface {
// Breadcrumb returns the parent's path before the children's breadcrumb.
// This method recursively joins the parent's crumb with the children's,
// then eventually make its way up to the root node.
Breadcrumb() Breadcrumb
ParentBreadcrumb() Breadcrumber
}
type BreadcrumbNamer interface {
// Breadcrumb returns the breadcrumb name.
Breadcrumb() string
}
// Traverse traverses the given breadcrumber recursively. If traverser returns
// true, then the function halts. Traversal is done from parent down to
// children.
func Traverse(bc Breadcrumber, traverser func(b Breadcrumber) bool) {
if bc == nil {
return
}
var stack []Breadcrumber
for current := bc; current != nil; current = current.ParentBreadcrumb() {
stack = append(stack, current)
}
for _, bc := range stack {
if traverser(bc) {
return
}
}
}
// TryBreadcrumb accepts a nilable breadcrumber and handles it appropriately.
func TryBreadcrumb(i Breadcrumber, appended ...string) []string {
if i == nil {
return appended
func TryBreadcrumb(i Breadcrumber) (breadcrumbs []string) {
Traverse(i, func(b Breadcrumber) bool {
if namer, ok := b.(BreadcrumbNamer); ok {
breadcrumbs = append(breadcrumbs, namer.Breadcrumb())
}
return false
})
for l, r := 0, len(breadcrumbs)-1; l < r; l, r = l+1, r-1 {
breadcrumbs[l], breadcrumbs[r] = breadcrumbs[r], breadcrumbs[l]
}
return append(i.Breadcrumb(), appended...)
return
}
func TryID(i Breadcrumber, appended ...cchat.ID) (ids []cchat.ID) {
Traverse(i, func(b Breadcrumber) bool {
switch b := b.(type) {
case cchat.Identifier:
ids = append(ids, b.ID())
case BreadcrumbNamer:
ids = append(ids, b.Breadcrumb())
}
return false
})
for l, r := 0, len(ids)-1; l < r; l, r = l+1, r-1 {
ids[l], ids[r] = ids[r], ids[l]
}
return
}
// Unreadabler extends Breadcrumber to add unread states to the parent node.

View File

@ -217,8 +217,12 @@ func (r *Row) Reset() {
r.cmder = nil
}
func (r *Row) Breadcrumb() traverse.Breadcrumb {
return traverse.TryBreadcrumb(r.parentcrumb, r.Session.Name().Content)
func (r *Row) ParentBreadcrumb() traverse.Breadcrumber {
return r.parentcrumb
}
func (r *Row) Breadcrumb() string {
return r.Session.Name().Content
}
// Activate executes whatever needs to be done. If the row has failed, then this

View File

@ -6,7 +6,6 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui"
"github.com/diamondburned/cchat-gtk/internal/ui/config"
"github.com/diamondburned/cchat/services"
"github.com/pkg/errors"
_ "github.com/diamondburned/cchat-discord"
_ "github.com/diamondburned/cchat-mock"
@ -33,9 +32,7 @@ func main() {
}
// Restore the configs.
if err := config.Restore(); err != nil {
log.Error(errors.Wrap(err, "Failed to restore config"))
}
config.Restore()
return app
})

View File

@ -1,16 +1,6 @@
{ pkgs ? import <nixpkgs> {} }:
# let hunspell = pkgs.hunspellWithDicts(with pkgs.hunspellDicts; [
# en-us
# en-us-large
# ]);
let hunspellWrapper = pkgs.hunspellWithDicts(with pkgs.hunspellDicts; [
en-us
en-us-large
]);
libhandy = pkgs.libhandy.overrideAttrs(old: {
let libhandy = pkgs.libhandy.overrideAttrs(old: {
name = "libhandy-0.90.0";
src = builtins.fetchGit {
url = "https://gitlab.gnome.org/GNOME/libhandy.git";
@ -29,8 +19,8 @@ in pkgs.stdenv.mkDerivation rec {
version = "0.0.2";
buildInputs =
[ libhandy hunspellWrapper ]
++ (with pkgs; [ enchant2 gnome3.gspell gnome3.glib gnome3.gtk ]);
[ libhandy ]
++ (with pkgs; [ gnome3.gspell gnome3.glib gnome3.gtk ]);
nativeBuildInputs = with pkgs; [
pkgconfig go