Added preferences

This commit is contained in:
diamondburned (Forefront) 2020-06-19 21:40:34 -07:00
parent 1a9cc2626e
commit 9df0aca1fa
13 changed files with 493 additions and 76 deletions

View File

@ -21,24 +21,39 @@ var App struct {
Header *gtk.HeaderBar
}
// NewModalDialog returns a new modal dialog that's transient for the main
// window.
func NewModalDialog() (*gtk.Dialog, error) {
d, err := gtk.DialogNew()
if err != nil {
return nil, err
}
d.SetModal(true)
d.SetTransientFor(App.Window)
return d, nil
}
func AddAppAction(name string, call func()) {
action := glib.SimpleActionNew(name, nil)
action.Connect("activate", call)
App.AddAction(action)
}
func AddWindowAction(name string, call func()) {
action := glib.SimpleActionNew(name, nil)
action.Connect("activate", call)
App.Window.AddAction(action)
}
func init() {
gtk.Init(&Args)
App.Application, _ = gtk.ApplicationNew(AppID, 0)
}
type Windower interface {
Window() gtk.IWidget
}
type Headerer interface {
Header() gtk.IWidget
}
// Above interfaces should be kept for modularity, but since this is an internal
// abstraction, we already know our application will implement both.
type WindowHeaderer interface {
Windower
Headerer
Window() gtk.IWidget
Header() gtk.IWidget
Destroy()
}
func Main(wfn func() WindowHeaderer) {
@ -62,8 +77,12 @@ func Main(wfn func() WindowHeaderer) {
// Execute the function later, because we need it to run after
// initialization.
w := wfn()
App.Window.Connect("destroy", w.Destroy)
App.Window.Add(w.Window())
App.Header.Add(w.Header())
// Connect extra events.
AddAppAction("quit", App.Window.Destroy)
})
// Use a special function to run the application. Exit with the appropriate

View File

@ -0,0 +1,43 @@
// Package config provides the repository for configuration and preferences.
package config
import "sort"
// List of config sections.
type Section uint8
const (
Appearance Section = iota
sectionLen
)
func (s Section) String() string {
switch s {
case Appearance:
return "Appearance"
default:
return "???"
}
}
var Sections = [sectionLen][]Entry{}
func sortSection(section Section) {
// TODO: remove the sorting and allow for declarative ordering
sort.Slice(Sections[section], func(i, j int) bool {
return Sections[section][i].Name < Sections[section][j].Name
})
}
type Entry struct {
Name string
Value EntryValue
}
func AppearanceAdd(name string, value EntryValue) {
Sections[Appearance] = append(Sections[Appearance], Entry{
Name: name,
Value: value,
})
sortSection(Appearance)
}

View File

@ -0,0 +1,83 @@
package preferences
import (
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/config"
"github.com/gotk3/gotk3/gtk"
)
type Dialog struct {
*gtk.Dialog
switcher *gtk.StackSwitcher
stack *gtk.Stack
}
func NewDialog() *Dialog {
stack, _ := gtk.StackNew()
stack.Show()
switcher, _ := gtk.StackSwitcherNew()
switcher.SetStack(stack)
switcher.Show()
h, _ := gtk.HeaderBarNew()
h.SetShowCloseButton(true)
h.SetCustomTitle(switcher)
h.Show()
d, _ := gts.NewModalDialog()
d.SetDefaultSize(400, 300)
d.SetTitle("Preferences")
d.SetTitlebar(h)
b, _ := d.GetContentArea()
b.SetMarginTop(8)
b.SetMarginBottom(8)
b.SetMarginStart(16)
b.SetMarginEnd(16)
b.PackStart(stack, true, true, 0)
b.Show()
return &Dialog{
Dialog: d,
stack: stack,
switcher: switcher,
}
}
func Section(entries []config.Entry) *gtk.Grid {
var grid, _ = gtk.GridNew()
for i, entry := range entries {
l, _ := gtk.LabelNew(entry.Name)
l.SetHExpand(true)
l.SetXAlign(0)
l.Show()
grid.Attach(l, 0, i, 1, 1)
grid.Attach(entry.Value.Construct(), 1, i, 1, 1)
}
grid.SetRowSpacing(4)
grid.SetColumnSpacing(4)
grid.Show()
return grid
}
func NewPreferenceDialog() *Dialog {
var dialog = NewDialog()
for i, section := range config.Sections {
grid := Section(section)
name := config.Section(i).String()
dialog.stack.AddTitled(grid, name, name)
}
return dialog
}
func SpawnPreferenceDialog() {
NewPreferenceDialog().Show()
}

View File

@ -0,0 +1,100 @@
package config
import (
"encoding/json"
"github.com/gotk3/gotk3/gtk"
)
// EntryValue with JSON serde capabilities.
type EntryValue interface {
json.Marshaler
json.Unmarshaler
Construct() gtk.IWidget
}
type _combo struct {
selected *int
options []string
change func(int)
}
func Combo(selected *int, options []string, change func(int)) EntryValue {
return &_combo{selected, options, change}
}
func (c *_combo) Construct() gtk.IWidget {
var combo, _ = gtk.ComboBoxTextNew()
for _, opt := range c.options {
combo.Append(opt, opt)
}
combo.Connect("changed", func() {
active := combo.GetActive()
*c.selected = active
if c.change != nil {
c.change(active)
}
})
combo.SetActive(*c.selected)
combo.SetHAlign(gtk.ALIGN_END)
combo.Show()
return combo
}
func (c *_combo) MarshalJSON() ([]byte, error) {
return json.Marshal(*c.selected)
}
func (c *_combo) UnmarshalJSON(b []byte) error {
var value int
if err := json.Unmarshal(b, &value); err != nil {
return err
}
*c.selected = value
return nil
}
type _switch struct {
value *bool
change func(bool)
}
func Switch(value *bool, change func(bool)) EntryValue {
return &_switch{value, change}
}
func (s *_switch) Construct() gtk.IWidget {
sw, _ := gtk.SwitchNew()
sw.SetActive(*s.value)
sw.Connect("notify::active", func() {
v := sw.GetActive()
*s.value = v
if s.change != nil {
s.change(v)
}
})
sw.SetHAlign(gtk.ALIGN_END)
sw.Show()
return sw
}
func (s *_switch) MarshalJSON() ([]byte, error) {
return json.Marshal(*s.value)
}
func (s *_switch) UnmarshalJSON(b []byte) error {
var value bool
if err := json.Unmarshal(b, &value); err != nil {
return err
}
*s.value = value
return nil
}

View File

@ -4,19 +4,19 @@ import (
"html"
"strings"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
"github.com/gotk3/gotk3/gtk"
)
type header struct {
*gtk.Box
left *gtk.Box // TODO
left *headerLeft // TODO
right *headerRight
}
func newHeader() *header {
left, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
left.SetSizeRequest(leftMinWidth, -1)
left := newHeaderLeft()
left.Show()
right := newHeaderRight()
@ -51,6 +51,27 @@ func (h *header) SetBreadcrumb(b breadcrumb.Breadcrumb) {
)
}
type headerLeft struct {
*gtk.Box
openmenu *gtk.MenuButton
}
func newHeaderLeft() *headerLeft {
openmenu := primitives.NewMenuActionButton([][2]string{
{"Preferences", "app.preferences"},
{"Quit", "app.quit"},
})
openmenu.Show()
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
box.PackStart(openmenu, false, false, 5)
return &headerLeft{
Box: box,
openmenu: openmenu,
}
}
type headerRight struct {
*gtk.Box
breadcrumb *gtk.Label

View File

@ -3,6 +3,7 @@ package input
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/config"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/imgutil"
@ -11,6 +12,17 @@ import (
const AvatarSize = 24
var showUser = true
var currentRevealer = func(bool) {} // noop by default
func init() {
// Bind this revealer in settings.
config.AppearanceAdd("Show Username in Input", config.Switch(
&showUser,
func(b bool) { currentRevealer(b) },
))
}
type usernameContainer struct {
*gtk.Revealer
main *gtk.Box
@ -48,6 +60,11 @@ func newUsernameContainer() *usernameContainer {
rev.SetTransitionDuration(50)
rev.Add(box)
// Bind the current global revealer to this revealer for settings. This
// operation should be thread-safe, as everything is being done in the main
// thread.
currentRevealer = rev.SetRevealChild
return &usernameContainer{
Revealer: rev,
main: box,
@ -56,6 +73,16 @@ func newUsernameContainer() *usernameContainer {
}
}
func (u *usernameContainer) SetRevealChild(reveal bool) {
// Only reveal if showUser is true.
u.Revealer.SetRevealChild(reveal && showUser)
}
// shouldReveal returns whether or not the container should reveal.
func (u *usernameContainer) shouldReveal() bool {
return !u.label.GetLabel().Empty() && showUser
}
func (u *usernameContainer) Reset() {
u.SetRevealChild(false)
u.avatar.Reset()
@ -67,7 +94,7 @@ func (u *usernameContainer) Update(session cchat.Session, sender cchat.ServerMes
// Set the fallback username.
u.label.SetLabelUnsafe(session.Name())
// Reveal the name if it's not empty.
u.SetRevealChild(!u.label.GetLabel().Empty())
u.SetRevealChild(u.shouldReveal())
// Does sender (aka Server) implement ServerNickname? If yes, use it.
if nicknamer, ok := sender.(cchat.ServerNickname); ok {
@ -91,7 +118,7 @@ func (u *usernameContainer) SetLabel(content text.Rich) {
u.label.SetLabelUnsafe(content)
// Reveal if the name is not empty.
u.SetRevealChild(!u.label.GetLabel().Empty())
u.SetRevealChild(u.shouldReveal())
})
}

View File

@ -7,7 +7,9 @@ import (
"github.com/diamondburned/cchat-gtk/icons"
"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/messages/container"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container/compact"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container/cozy"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/sadface"
@ -16,55 +18,19 @@ import (
"github.com/pkg/errors"
)
// ServerMessage combines Server and ServerMessage from cchat.
type ServerMessage interface {
cchat.Server
cchat.ServerMessage
}
const (
cozyMessage int = iota
compactMessage
)
type state struct {
session cchat.Session
server cchat.Server
var msgIndex = cozyMessage
actioner cchat.ServerMessageActioner
actions []string
current func() // stop callback
author string
}
func (s *state) Reset() {
// If we still have the last server to leave, then leave it.
if s.current != nil {
s.current()
}
// Lazy way to reset the state.
*s = state{}
}
func (s *state) hasActions() bool {
return s.actioner != nil && len(s.actions) > 0
}
// SessionID returns the session ID, or an empty string if there's no session.
func (s *state) SessionID() string {
if s.session != nil {
return s.session.ID()
}
return ""
}
func (s *state) bind(session cchat.Session, server ServerMessage) {
s.session = session
s.server = server
if s.actioner, _ = server.(cchat.ServerMessageActioner); s.actioner != nil {
s.actions = s.actioner.MessageActions()
}
}
func (s *state) setcurrent(fn func()) {
s.current = fn
func init() {
config.AppearanceAdd("Message Display", config.Combo(
&msgIndex, // 0 or 1
[]string{"Cozy", "Compact"},
nil,
))
}
type View struct {
@ -73,6 +39,7 @@ type View struct {
InputView *input.InputView
Container container.Container
contType int // msgIndex
// Inherit some useful methods.
state
@ -80,17 +47,16 @@ type View struct {
func NewView() *View {
view := &View{}
// TODO: change
view.InputView = input.NewView(view)
// view.Container = compact.NewContainer(view)
view.Container = cozy.NewContainer(view)
view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
view.Box.PackStart(view.Container, true, true, 0)
view.Box.PackStart(view.InputView, false, false, 0)
view.Box.PackEnd(view.InputView, false, false, 0)
view.Box.Show()
// Create the message container, which will use PackEnd to add the widget on
// TOP of the input view.
view.createMessageContainer()
// placeholder logo
logo, _ := gtk.ImageNewFromPixbuf(icons.Logo256())
logo.Show()
@ -99,11 +65,34 @@ func NewView() *View {
return view
}
func (v *View) createMessageContainer() {
// Remove the old message container.
if v.Container != nil {
v.Box.Remove(v.Container)
}
// Update the container type.
switch v.contType = msgIndex; msgIndex {
case cozyMessage:
v.Container = cozy.NewContainer(v)
case compactMessage:
v.Container = compact.NewContainer(v)
}
// Add the new message container.
v.Box.PackEnd(v.Container, true, true, 0)
}
func (v *View) Reset() {
v.state.Reset() // Reset the state variables.
v.FaceView.Reset() // Switch back to the main screen.
v.Container.Reset() // Clean all messages.
v.InputView.Reset() // Reset the input.
v.Container.Reset() // Clean all messages.
// Recreate the message container if the type is different.
if v.contType != msgIndex {
v.createMessageContainer()
}
}
// JoinServer is not thread-safe, but it calls backend functions asynchronously.
@ -217,3 +206,54 @@ func (v *View) makeActionItem(action, msgID string) menu.Item {
}()
})
}
// ServerMessage combines Server and ServerMessage from cchat.
type ServerMessage interface {
cchat.Server
cchat.ServerMessage
}
type state struct {
session cchat.Session
server cchat.Server
actioner cchat.ServerMessageActioner
actions []string
current func() // stop callback
author string
}
func (s *state) Reset() {
// If we still have the last server to leave, then leave it.
if s.current != nil {
s.current()
}
// Lazy way to reset the state.
*s = state{}
}
func (s *state) hasActions() bool {
return s.actioner != nil && len(s.actions) > 0
}
// SessionID returns the session ID, or an empty string if there's no session.
func (s *state) SessionID() string {
if s.session != nil {
return s.session.ID()
}
return ""
}
func (s *state) bind(session cchat.Session, server ServerMessage) {
s.session = session
s.server = server
if s.actioner, _ = server.(cchat.ServerMessageActioner); s.actioner != nil {
s.actions = s.actioner.MessageActions()
}
}
func (s *state) setcurrent(fn func()) {
s.current = fn
}

View File

@ -168,4 +168,60 @@ func NewTargetEntry(target string) gtk.TargetEntry {
return *e
}
// func 
// NewMenuActionButton is the same as NewActionButton, but it uses the
// open-menu-symbolic icon.
func NewMenuActionButton(actions [][2]string) *gtk.MenuButton {
return NewActionButton("open-menu-symbolic", actions)
}
// NewActionButton creates a new menu button that spawns a popover with the
// listed actions.
func NewActionButton(iconName string, actions [][2]string) *gtk.MenuButton {
p, _ := gtk.PopoverNew(nil)
p.SetSizeRequest(200, -1) // wide enough width
ActionPopover(p, actions)
i, _ := gtk.ImageNew()
i.SetProperty("icon-name", iconName)
i.SetProperty("icon-size", gtk.ICON_SIZE_SMALL_TOOLBAR)
i.Show()
b, _ := gtk.MenuButtonNew()
b.SetHAlign(gtk.ALIGN_CENTER)
b.SetPopover(p)
b.Add(i)
return b
}
// LabelTweaker is used for ActionPopover and other functions that may need to
// change the alignment of children widgets.
type LabelTweaker interface {
SetUseMarkup(bool)
SetHAlign(gtk.Align)
SetXAlign(float64)
}
var _ LabelTweaker = (*gtk.Label)(nil)
func ActionPopover(p *gtk.Popover, actions [][2]string) {
var box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
for _, action := range actions {
b, _ := gtk.ModelButtonNew()
b.SetLabel(action[0])
b.SetActionName(action[1])
b.Show()
// Set the label's alignment in a hacky way.
c, _ := b.GetChild()
l := c.(LabelTweaker)
l.SetUseMarkup(true)
l.SetHAlign(gtk.ALIGN_START)
box.PackStart(b, false, true, 0)
}
box.Show()
p.Add(box)
}

View File

@ -127,6 +127,10 @@ func NewContainer(svc cchat.Service, ctrl Controller) *Container {
return container
}
func (c *Container) Sessions() []*session.Row {
return c.children.Sessions()
}
func (c *Container) AddSession(ses cchat.Session) *session.Row {
srow := session.New(c, ses, c)
c.children.AddSessionRow(ses.ID(), srow)

View File

@ -96,8 +96,14 @@ func (r *Row) ReconnectSession() {
r.ctrl.RestoreSession(r, r.sessionID)
}
// DisconnectSession disconnects the current session.
// DisconnectSession disconnects the current session. It does nothing if the row
// does not have a session active.
func (r *Row) DisconnectSession() {
// No-op if no session.
if r.Session == nil {
return
}
// Call the disconnect function from the controller first.
r.ctrl.OnSessionDisconnect(r)

View File

@ -7,3 +7,7 @@ headerbar { padding: 0; }
box-shadow: none;
border: none;
}
popover > box {
margin: 6px;
}

View File

@ -3,6 +3,7 @@ package ui
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/config/preferences"
"github.com/diamondburned/cchat-gtk/internal/ui/messages"
"github.com/diamondburned/cchat-gtk/internal/ui/service"
"github.com/diamondburned/cchat-gtk/internal/ui/service/auth"
@ -44,8 +45,7 @@ type App struct {
}
var (
_ gts.Windower = (*App)(nil)
_ gts.Headerer = (*App)(nil)
_ gts.WindowHeaderer = (*App)(nil)
_ service.Controller = (*App)(nil)
)
@ -63,6 +63,10 @@ func NewApplication() *App {
app.header.left.SetSizeRequest(width, -1)
})
// Bind the preferences action for our GAction button in the header popover.
// The action name for this is "app.preferences".
gts.AddAppAction("preferences", preferences.SpawnPreferenceDialog)
return app
}
@ -114,6 +118,16 @@ func (app *App) AuthenticateSession(container *service.Container, svc cchat.Serv
})
}
// Destroy is called when the main window is destroyed or closed.
func (app *App) Destroy() {
// Disconnect everything.
for _, service := range app.window.Services.Services {
for _, session := range service.Sessions() {
session.DisconnectSession()
}
}
}
func (app *App) Header() gtk.IWidget {
return app.header
}

File diff suppressed because one or more lines are too long