diff --git a/internal/ui/config/config.go b/internal/ui/config/config.go index 2be7141..0f4f6bf 100644 --- a/internal/ui/config/config.go +++ b/internal/ui/config/config.go @@ -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, §ions) +// logged. +func Restore() { + if err := UnmarshalFromFile(ConfigFile, §ions); 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 } diff --git a/internal/ui/config/file.go b/internal/ui/config/file.go index af89a83..c0977ad 100644 --- a/internal/ui/config/file.go +++ b/internal/ui/config/file.go @@ -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") } diff --git a/internal/ui/messages/header.go b/internal/ui/messages/header.go index 594388a..6fb3c97 100644 --- a/internal/ui/messages/header.go +++ b/internal/ui/messages/header.go @@ -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 } diff --git a/internal/ui/service/config/config.go b/internal/ui/service/config/config.go index ea13b63..ea7c5a4 100644 --- a/internal/ui/service/config/config.go +++ b/internal/ui/service/config/config.go @@ -1,3 +1,5 @@ +// Package config contains UI widgets and renderers for cchat's Configurator +// interface. package config import ( diff --git a/internal/ui/service/header.go b/internal/ui/service/header.go index 521b8dc..80317c8 100644 --- a/internal/ui/service/header.go +++ b/internal/ui/service/header.go @@ -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("") diff --git a/internal/ui/service/savepath/savepath.go b/internal/ui/service/savepath/savepath.go new file mode 100644 index 0000000..7e72a54 --- /dev/null +++ b/internal/ui/service/savepath/savepath.go @@ -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() +} diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index f7bb7a7..635970c 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -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() { diff --git a/internal/ui/service/session/server/children.go b/internal/ui/service/session/server/children.go index f8d12f9..59f433b 100644 --- a/internal/ui/service/session/server/children.go +++ b/internal/ui/service/session/server/children.go @@ -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 } diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go index beebb91..41dd0c4 100644 --- a/internal/ui/service/session/server/server.go +++ b/internal/ui/service/session/server/server.go @@ -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 { diff --git a/internal/ui/service/session/server/traverse/traverse.go b/internal/ui/service/session/server/traverse/traverse.go index 80101f2..d55adc4 100644 --- a/internal/ui/service/session/server/traverse/traverse.go +++ b/internal/ui/service/session/server/traverse/traverse.go @@ -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. diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index b33186b..6ec7dc1 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -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 diff --git a/main.go b/main.go index cbfab7c..f5edb4c 100644 --- a/main.go +++ b/main.go @@ -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 }) diff --git a/shell.nix b/shell.nix index 6665649..983a96d 100644 --- a/shell.nix +++ b/shell.nix @@ -1,16 +1,6 @@ { pkgs ? import {} }: -# 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