Move app UI package under internal/appui

This commit is contained in:
Joe Julian
2026-04-09 06:35:59 -07:00
parent 07a071503a
commit 7751b5472a
24 changed files with 25 additions and 25 deletions
+184
View File
@@ -0,0 +1,184 @@
//go:build android
package appui
/*
#cgo CFLAGS: -Werror
#cgo LDFLAGS: -landroid
#include <jni.h>
#include <stdlib.h>
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
return (*env)->GetObjectClass(env, obj);
}
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetMethodID(env, clazz, name, sig);
}
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetStaticMethodID(env, clazz, name, sig);
}
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
return (*env)->CallObjectMethodA(env, obj, method, args);
}
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) {
(*env)->CallStaticVoidMethodA(env, cls, methodID, args);
}
static jvalue jni_ValueObject(jobject obj) {
jvalue value;
value.l = obj;
return value;
}
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
return (*env)->ExceptionOccurred(env);
}
static void jni_ExceptionClear(JNIEnv *env) {
(*env)->ExceptionClear(env);
}
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
return (*env)->NewString(env, unicodeChars, len);
}
static int jni_IsNull(jobject obj) {
return obj == NULL;
}
*/
import "C"
import (
"fmt"
"strings"
"unicode/utf16"
"unsafe"
"gioui.org/app"
_ "unsafe"
)
type androidVaultSharer struct{}
//go:linkname gioJavaVM gioui.org/app.javaVM
func gioJavaVM() *C.JavaVM
//go:linkname gioRunInJVM gioui.org/app.runInJVM
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
func newPlatformVaultSharer(goos string) vaultSharer {
return androidVaultSharer{}
}
func (androidVaultSharer) ShareVault(path, title string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("vault path is required")
}
ctx := C.jobject(unsafe.Pointer(app.AppContext()))
if C.jni_IsNull(ctx) != 0 {
return fmt.Errorf("android app context is not available")
}
var callErr error
gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) {
sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare")
if err != nil {
callErr = err
return
}
methodName := cString("shareVault")
defer C.free(unsafe.Pointer(methodName))
methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V")
defer C.free(unsafe.Pointer(methodSig))
method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig)
if method == nil {
callErr = androidJNIError(env, "resolve shareVault method")
if callErr == nil {
callErr = fmt.Errorf("resolve shareVault method")
}
return
}
jPath := androidJavaString(env, path)
jTitle := androidJavaString(env, title)
args := [3]C.jvalue{}
args[0] = C.jni_ValueObject(ctx)
args[1] = C.jni_ValueObject(C.jobject(jPath))
args[2] = C.jni_ValueObject(C.jobject(jTitle))
C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0])
callErr = androidJNIError(env, "share vault")
})
return callErr
}
func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) {
var zeroClass C.jclass
contextClass := C.jni_GetObjectClass(env, ctx)
getClassLoaderName := cString("getClassLoader")
defer C.free(unsafe.Pointer(getClassLoaderName))
getClassLoaderSig := cString("()Ljava/lang/ClassLoader;")
defer C.free(unsafe.Pointer(getClassLoaderSig))
getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig)
if getClassLoader == nil {
if err := androidJNIError(env, "resolve getClassLoader"); err != nil {
return zeroClass, err
}
return zeroClass, fmt.Errorf("resolve getClassLoader")
}
classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil)
if err := androidJNIError(env, "load class loader"); err != nil {
return zeroClass, err
}
if C.jni_IsNull(classLoader) != 0 {
return zeroClass, fmt.Errorf("android class loader is nil")
}
classLoaderClass := C.jni_GetObjectClass(env, classLoader)
loadClassName := cString("loadClass")
defer C.free(unsafe.Pointer(loadClassName))
loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;")
defer C.free(unsafe.Pointer(loadClassSig))
loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig)
if loadClass == nil {
if err := androidJNIError(env, "resolve loadClass"); err != nil {
return zeroClass, err
}
return zeroClass, fmt.Errorf("resolve loadClass")
}
jClassName := androidJavaString(env, name)
args := [1]C.jvalue{}
args[0] = C.jni_ValueObject(C.jobject(jClassName))
loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0])
if err := androidJNIError(env, "load AndroidShare class"); err != nil {
return zeroClass, err
}
if C.jni_IsNull(loaded) != 0 {
return zeroClass, fmt.Errorf("load AndroidShare class returned nil")
}
return C.jclass(loaded), nil
}
func androidJNIError(env *C.JNIEnv, action string) error {
if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 {
C.jni_ExceptionClear(env)
return fmt.Errorf("%s: Java exception", action)
}
return nil
}
func androidJavaString(env *C.JNIEnv, s string) C.jstring {
chars := utf16.Encode([]rune(s))
if len(chars) == 0 {
return C.jni_NewString(env, nil, 0)
}
return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars)))
}
func cString(value string) *C.char {
return C.CString(value)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !android
package appui
func newPlatformVaultSharer(goos string) vaultSharer {
return nil
}
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
package appui
import (
"io"
"strings"
"sync"
gioclipboard "gioui.org/io/clipboard"
"gioui.org/layout"
appclipboard "git.julianfamily.org/keepassgo/clipboard"
)
type clipboardCommandWriter struct {
mu sync.Mutex
pending []string
invalidate func()
}
func newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
if strings.EqualFold(goos, "android") {
return &clipboardCommandWriter{invalidate: invalidate}
}
return nil
}
func processClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
commandWriter, ok := writer.(*clipboardCommandWriter)
if !ok {
return
}
commandWriter.Process(gtx)
}
func (w *clipboardCommandWriter) WriteText(text string) error {
w.mu.Lock()
w.pending = append(w.pending, text)
w.mu.Unlock()
if w.invalidate != nil {
w.invalidate()
}
return nil
}
func (w *clipboardCommandWriter) Process(gtx layout.Context) {
for _, text := range w.drain() {
gtx.Execute(gioclipboard.WriteCmd{
Type: "application/text",
Data: io.NopCloser(strings.NewReader(text)),
})
}
}
func (w *clipboardCommandWriter) drain() []string {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.pending) == 0 {
return nil
}
pending := append([]string(nil), w.pending...)
w.pending = nil
return pending
}
+42
View File
@@ -0,0 +1,42 @@
package appui
import (
"slices"
"testing"
)
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
t.Parallel()
writer := newPlatformClipboardWriter("android", nil)
if _, ok := writer.(*clipboardCommandWriter); !ok {
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
}
}
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
t.Parallel()
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
}
}
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
t.Parallel()
writer := &clipboardCommandWriter{}
if err := writer.WriteText("username"); err != nil {
t.Fatalf("WriteText(username) error = %v", err)
}
if err := writer.WriteText("password"); err != nil {
t.Fatalf("WriteText(password) error = %v", err)
}
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
t.Fatalf("drain() = %v, want [username password]", got)
}
if got := writer.drain(); got != nil {
t.Fatalf("drain() after flush = %v, want nil", got)
}
}
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
package appui
import (
"fmt"
"image"
"image/color"
"strings"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
)
type focusAppearance struct {
BorderColor color.NRGBA
OutlineColor color.NRGBA
OutlineWidth int
MinHeight int
}
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)),
}
if prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
if focused {
appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
}
return appearance
}
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
}
}
return background, text
}
func (u *ui) accessibilityLabel(id focusID) string {
switch {
case id == focusSearch:
return "Search vault"
case strings.HasPrefix(string(id), "breadcrumb:"):
index := focusIndex(id)
crumbs := u.breadcrumbLabels()
if index >= 0 && index < len(crumbs) {
return fmt.Sprintf("Navigate to %s", crumbs[index])
}
case strings.HasPrefix(string(id), "list:"):
index := focusIndex(id)
if index >= 0 && index < len(u.visible) {
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
}
case strings.HasPrefix(string(id), "detail:"):
name := strings.TrimPrefix(string(id), "detail:")
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
}
return ""
}
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
return layout.Dimensions{Size: size}
}
width := appearance.OutlineWidth
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
return layout.Dimensions{Size: size}
}
func (u *ui) isFocused(id focusID) bool {
return u.keyboardFocus == id
}
func detailFieldLabel(field detailField) string {
switch field {
case detailFieldID:
return "ID"
case detailFieldTitle:
return "Title"
case detailFieldUsername:
return "Username"
case detailFieldPassword:
return "Password"
case detailFieldURL:
return "URL"
case detailFieldPath:
return "Path"
case detailFieldTags:
return "Tags"
case detailFieldPasswordProfile:
return "Password Profile"
case detailFieldNotes:
return "Notes"
case detailFieldFields:
return "Custom Fields"
case detailFieldHistoryIndex:
return "History Index"
default:
return strings.ReplaceAll(string(field), "-", " ")
}
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
package appui
import (
"image"
"gioui.org/layout"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
)
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() {
return layout.Dimensions{}
}
return layout.Dimensions{}
}
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
if u.usesCompactViewport() {
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
}
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
}
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
width := gtx.Dp(unit.Dp(widthDP))
height := gtx.Dp(unit.Dp(heightDP))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X
}
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
height = gtx.Constraints.Max.Y
}
img := widget.Image{
Src: src,
Fit: widget.Contain,
Position: layout.W,
Scale: 1.0 / gtx.Metric.PxPerDp,
}
gtx.Constraints.Min = image.Point{}
gtx.Constraints.Max = image.Pt(width, height)
return img.Layout(gtx)
}
+519
View File
@@ -0,0 +1,519 @@
package appui
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"gioui.org/widget"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/vault"
)
func (u *ui) attachmentInput() (string, []byte, error) {
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return "", nil, fmt.Errorf("attachment name is required")
}
path := strings.TrimSpace(u.attachmentPath.Text())
if path == "" {
return "", nil, fmt.Errorf("attachment path is required")
}
info, err := os.Stat(path)
if err != nil {
return "", nil, fmt.Errorf("stat attachment: %w", err)
}
if info.Size() > maxAttachmentBytes {
return "", nil, fmt.Errorf("attachment too large: %d bytes exceeds %d byte limit", info.Size(), maxAttachmentBytes)
}
content, err := os.ReadFile(path)
if err != nil {
return "", nil, fmt.Errorf("read attachment: %w", err)
}
return name, content, nil
}
func (u *ui) loadSelectedEntryIntoEditor() {
u.resetPasswordPeek()
u.clearGeneratedPasswordDraft()
u.selectedHistoryIndex = -1
u.historyIndex.SetText("")
item, ok := u.selectedEntry()
if !ok {
u.entryID.SetText("")
u.entryTitle.SetText("")
u.entryUsername.SetText("")
u.entryPassword.SetText("")
u.entryURL.SetText("")
u.entryNotes.SetText("")
u.entryTags.SetText("")
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
u.entryFields.SetText("")
u.setCustomFieldRows(nil)
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
return
}
u.entryID.SetText(item.ID)
u.entryTitle.SetText(item.Title)
u.entryUsername.SetText(item.Username)
u.entryPassword.SetText(item.Password)
u.entryURL.SetText(item.URL)
u.entryNotes.SetText(item.Notes)
u.entryTags.SetText(strings.Join(item.Tags, ", "))
u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / "))
u.entryFields.SetText(marshalFields(item.Fields))
u.setCustomFieldRows(item.Fields)
u.attachmentName.SetText("")
u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("")
}
func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil
u.customFieldValues = nil
u.removeCustomFields = nil
if len(fields) == 0 {
u.appendCustomFieldRow("", "")
return
}
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
u.appendCustomFieldRow(key, fields[key])
}
}
func (u *ui) appendCustomFieldRow(key, value string) {
keyEditor := widget.Editor{SingleLine: true, Submit: false}
keyEditor.SetText(key)
valueEditor := widget.Editor{SingleLine: true, Submit: false}
valueEditor.SetText(value)
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
}
func (u *ui) removeCustomFieldRow(index int) {
if index < 0 || index >= len(u.customFieldKeys) {
return
}
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "")
}
}
func (u *ui) currentCustomFields() (map[string]string, error) {
fields := map[string]string{}
for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text())
value := strings.TrimSpace(u.customFieldValues[i].Text())
if key == "" && value == "" {
continue
}
if key == "" {
return nil, fmt.Errorf("custom field name is required")
}
fields[key] = value
}
if len(fields) == 0 && strings.TrimSpace(u.entryFields.Text()) != "" {
return parseFields(u.entryFields.Text())
}
if len(fields) == 0 {
return nil, nil
}
return fields, nil
}
func (u *ui) visibleHistory() []vault.Entry {
item, ok := u.selectedEntry()
if !ok || len(item.History) == 0 {
return nil
}
return append([]vault.Entry(nil), item.History...)
}
func (u *ui) selectedHistoryEntry() (vault.Entry, bool) {
history := u.visibleHistory()
if u.selectedHistoryIndex < 0 || u.selectedHistoryIndex >= len(history) {
return vault.Entry{}, false
}
return history[u.selectedHistoryIndex], true
}
func (u *ui) selectHistoryVersion(index int) error {
history := u.visibleHistory()
if index < 0 || index >= len(history) {
return fmt.Errorf("history index %d out of range", index)
}
u.selectedHistoryIndex = index
u.historyIndex.SetText(strconv.Itoa(index))
return nil
}
func (u *ui) saveEntryAction() error {
entry, err := u.editorEntry()
if err != nil {
return err
}
if err := u.state.UpsertEntry(entry); err != nil {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
func (u *ui) duplicateSelectedEntryAction() error {
baseID := strings.TrimSpace(u.state.SelectedEntryID)
if baseID == "" {
return fmt.Errorf("no entry selected")
}
duplicateID := baseID + "-copy"
if _, err := u.state.DuplicateSelectedEntry(duplicateID); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) deleteSelectedEntryAction() error {
if err := u.state.DeleteSelectedEntry(); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) restoreSelectedRecycleEntryAction() error {
id := strings.TrimSpace(u.state.SelectedEntryID)
if id == "" {
return fmt.Errorf("no recycle-bin entry selected")
}
if err := u.state.RestoreEntry(id); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) saveTemplateAction() error {
entry, err := u.editorEntry()
if err != nil {
return err
}
if len(entry.Path) == 0 {
entry.Path = []string{"Templates"}
}
if err := u.state.UpsertTemplate(entry); err != nil {
return err
}
u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter()
return nil
}
func (u *ui) deleteSelectedTemplateAction() error {
id := strings.TrimSpace(u.state.SelectedEntryID)
if id == "" {
return fmt.Errorf("no template selected")
}
if err := u.state.DeleteTemplate(id); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) createGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) moveCurrentGroupAction() error {
u.clearDeleteGroupConfirmation()
if len(u.displayPath()) == 0 {
return fmt.Errorf("no current group selected")
}
return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text()))
}
func (u *ui) renameGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) deleteCurrentGroupAction() error {
if !u.deleteGroupPendingConfirmation() {
return fmt.Errorf("confirm deleting the empty group first")
}
if err := u.state.DeleteCurrentGroup(); err != nil {
return err
}
u.clearDeleteGroupConfirmation()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
u.filter()
return nil
}
func (u *ui) instantiateSelectedTemplateAction() error {
templateID := strings.TrimSpace(u.state.SelectedEntryID)
if templateID == "" {
return fmt.Errorf("no template selected")
}
entry, err := u.editorEntry()
if err != nil {
return err
}
if _, err := u.state.InstantiateTemplate(templateID, entry); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) addAttachmentAction() error {
name, content, err := u.attachmentInput()
if err != nil {
return err
}
if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.attachmentName.SetText(name)
u.filter()
return nil
}
func (u *ui) replaceAttachmentAction() error {
name, content, err := u.attachmentInput()
if err != nil {
return err
}
if err := u.state.ReplaceAttachmentOnSelectedEntry(name, content); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.attachmentName.SetText(name)
u.filter()
return nil
}
func (u *ui) exportAttachmentAction() error {
item, ok := u.selectedEntry()
if !ok {
return fmt.Errorf("no entry selected")
}
name := strings.TrimSpace(u.attachmentName.Text())
content, ok := item.Attachments[name]
if !ok {
return fmt.Errorf("attachment not found")
}
exportPath := strings.TrimSpace(u.exportAttachmentPath.Text())
if exportPath == "" {
return fmt.Errorf("export attachment path is required")
}
if err := os.WriteFile(exportPath, content, 0o600); err != nil {
return fmt.Errorf("write attachment export: %w", err)
}
return nil
}
func (u *ui) removeAttachmentAction() error {
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return fmt.Errorf("attachment name is required")
}
if err := u.state.DeleteAttachmentFromSelectedEntry(name); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) restoreSelectedHistoryAction() error {
index, err := u.selectedHistoryVersionIndex()
if err != nil {
return err
}
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
return err
}
u.loadSelectedEntryIntoEditor()
u.filter()
return nil
}
func (u *ui) selectedHistoryVersionIndex() (int, error) {
text := strings.TrimSpace(u.historyIndex.Text())
if text != "" {
index, err := strconv.Atoi(text)
if err != nil {
return 0, fmt.Errorf("invalid history index: %w", err)
}
return index, nil
}
if u.selectedHistoryIndex >= 0 {
return u.selectedHistoryIndex, nil
}
return 0, fmt.Errorf("no history version selected")
}
func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{Writer: u.clipboardWriter}
return service.Copy(model, u.state.SelectedEntryID, target)
}
func (u *ui) generatePasswordAction() error {
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
if err != nil {
return err
}
password, err := passwords.Generate(profile)
if err != nil {
return err
}
u.entryPassword.SetText(password)
u.generatedPasswordDraft = true
return nil
}
func (u *ui) editorEntry() (vault.Entry, error) {
path := parsePath(u.entryPath.Text())
if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) {
path = append([]string{root}, path...)
}
fields, err := u.currentCustomFields()
if err != nil {
return vault.Entry{}, err
}
return vault.Entry{
ID: strings.TrimSpace(u.entryID.Text()),
Title: strings.TrimSpace(u.entryTitle.Text()),
Username: strings.TrimSpace(u.entryUsername.Text()),
Password: u.entryPassword.Text(),
URL: strings.TrimSpace(u.entryURL.Text()),
Notes: strings.TrimSpace(u.entryNotes.Text()),
Tags: parseTags(u.entryTags.Text()),
Path: path,
Fields: fields,
}, nil
}
func parsePath(text string) []string {
var out []string
for _, part := range strings.Split(text, "/") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func parseTags(text string) []string {
var out []string
for _, part := range strings.Split(text, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func parseFields(text string) (map[string]string, error) {
if strings.TrimSpace(text) == "" {
return nil, nil
}
fields := map[string]string{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("invalid field line %q", line)
}
fields[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return fields, nil
}
func marshalFields(fields map[string]string) string {
if len(fields) == 0 {
return ""
}
lines := make([]string, 0, len(fields))
for key, value := range fields {
lines = append(lines, key+"="+value)
}
return strings.Join(lines, "\n")
}
func (u *ui) clearGeneratedPasswordDraft() {
u.generatedPasswordDraft = false
}
func (u *ui) attachmentActionSummary() string {
items := u.selectedAttachmentItems()
if len(items) == 0 {
return "No attachments yet. Add one below to store a file with this entry."
}
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return "Select an attachment above, then replace it, export it, or remove it below."
}
for _, item := range items {
if item.Name == name {
return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name)
}
}
return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name)
}
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
package appui
import (
"image"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
)
type dropdownAnchor struct {
TriggerRightX int
TriggerBottomY int
}
func (a dropdownAnchor) point() image.Point {
return image.Pt(a.TriggerRightX, a.TriggerBottomY)
}
type dropdownSurface struct {
ContainerWidth int
LeftInset int
TopInset int
}
func (s dropdownSurface) menuConstraints(gtx layout.Context) layout.Context {
menuGTX := gtx
menuGTX.Constraints.Min = image.Point{}
menuGTX.Constraints.Max.X = max(0, s.ContainerWidth)
return menuGTX
}
func (s dropdownSurface) origin(anchor dropdownAnchor, menuWidth int) image.Point {
x := s.LeftInset + anchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth)
y := s.TopInset + anchor.TriggerBottomY
return image.Pt(x, y)
}
func (s dropdownSurface) draw(gtx layout.Context, anchor dropdownAnchor, menu layout.Widget) layout.Dimensions {
menuGTX := s.menuConstraints(gtx)
menuOps := op.Record(gtx.Ops)
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
menuCall := menuOps.Stop()
menuOrigin := s.origin(anchor, menuDims.Size.X)
stack := op.Offset(menuOrigin).Push(gtx.Ops)
menuCall.Add(gtx.Ops)
stack.Pop()
return layout.Dimensions{Size: gtx.Constraints.Max}
}
type headerActionMetrics struct {
RowOriginX int
Spacing int
RowDims layout.Dimensions
SyncDims layout.Dimensions
LockDims layout.Dimensions
MainDims layout.Dimensions
}
func (m headerActionMetrics) syncAnchor() dropdownAnchor {
return dropdownAnchor{
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
TriggerBottomY: m.RowDims.Size.Y,
}
}
func (m headerActionMetrics) mainAnchor() dropdownAnchor {
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
return dropdownAnchor{
TriggerRightX: m.RowOriginX + triggerRightX,
TriggerBottomY: m.RowDims.Size.Y,
}
}
+378
View File
@@ -0,0 +1,378 @@
package appui
import (
"fmt"
"strconv"
"strings"
"gioui.org/io/key"
"git.julianfamily.org/keepassgo/appstate"
)
type focusID string
type detailField string
const (
focusSearch focusID = "search"
detailFieldID detailField = "id"
detailFieldTitle detailField = "title"
detailFieldUsername detailField = "username"
detailFieldPassword detailField = "password"
detailFieldURL detailField = "url"
detailFieldPath detailField = "path"
detailFieldTags detailField = "tags"
detailFieldPasswordProfile detailField = "password-profile"
detailFieldNotes detailField = "notes"
detailFieldFields detailField = "fields"
detailFieldHistoryIndex detailField = "history-index"
)
func breadcrumbFocusID(index int) focusID {
return focusID(fmt.Sprintf("breadcrumb:%d", index))
}
func listFocusID(index int) focusID {
return focusID(fmt.Sprintf("list:%d", index))
}
func detailFocusID(field detailField) focusID {
return focusID("detail:" + string(field))
}
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
if u.handleShortcutKey(name, modifiers) {
return true
}
if u.isVaultLocked() && name == key.NameReturn {
u.startUnlockAction()
return true
}
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
if u.lifecycleMode == "remote" {
u.startOpenRemoteAction()
} else {
u.startOpenVaultAction()
}
return true
}
switch name {
case key.NameTab:
delta := 1
if modifiers.Contain(key.ModShift) {
delta = -1
}
u.moveKeyboardFocus(delta)
return true
case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn:
return u.handleFocusedKey(name)
default:
return false
}
}
func (u *ui) moveKeyboardFocus(delta int) {
order := u.focusOrder()
if len(order) == 0 {
return
}
current := canonicalFocusID(u.keyboardFocus)
index := 0
for i, item := range order {
if canonicalFocusID(item) == current {
index = i
break
}
}
index += delta
if index < 0 {
index = len(order) - 1
}
if index >= len(order) {
index = 0
}
u.setKeyboardFocus(order[index])
}
func (u *ui) focusOrder() []focusID {
if u.isVaultLocked() {
return []focusID{detailFocusID(detailFieldPassword)}
}
order := []focusID{focusSearch}
if u.state.Section != appstate.SectionRecycleBin {
order = append(order, breadcrumbFocusID(0))
}
if len(u.visible) > 0 {
order = append(order, listFocusID(u.focusedListIndexOrZero()))
}
order = append(order, detailFocusID(u.focusedDetailFieldOrDefault()))
return order
}
func (u *ui) setKeyboardFocus(id focusID) {
u.keyboardFocus = id
if strings.HasPrefix(string(id), "list:") {
u.focusListIndex(focusIndex(id))
}
}
func (u *ui) handleFocusedKey(name key.Name) bool {
switch {
case u.keyboardFocus == focusSearch:
if name == key.NameDownArrow && len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
return true
}
case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"):
return u.handleBreadcrumbKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "list:"):
return u.handleListKey(name)
case strings.HasPrefix(string(u.keyboardFocus), "detail:"):
return u.handleDetailKey(name)
}
return false
}
func (u *ui) handleBreadcrumbKey(name key.Name) bool {
crumbs := u.breadcrumbLabels()
if len(crumbs) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameLeftArrow:
if index > 0 {
u.keyboardFocus = breadcrumbFocusID(index - 1)
}
return true
case key.NameRightArrow:
if index < len(crumbs)-1 {
u.keyboardFocus = breadcrumbFocusID(index + 1)
}
return true
case key.NameDownArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
case key.NameReturn:
u.activateBreadcrumb(index)
return true
default:
return false
}
}
func (u *ui) handleListKey(name key.Name) bool {
if len(u.visible) == 0 {
return false
}
index := focusIndex(u.keyboardFocus)
switch name {
case key.NameUpArrow:
if index > 0 {
u.setKeyboardFocus(listFocusID(index - 1))
}
return true
case key.NameDownArrow:
if index < len(u.visible)-1 {
u.setKeyboardFocus(listFocusID(index + 1))
}
return true
case key.NameLeftArrow:
u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1)
return true
case key.NameRightArrow, key.NameReturn:
u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault())
return true
default:
return false
}
}
func (u *ui) handleDetailKey(name key.Name) bool {
fields := detailFocusOrder()
index := u.focusedDetailIndex()
switch name {
case key.NameUpArrow:
if index > 0 {
u.keyboardFocus = detailFocusID(fields[index-1])
}
return true
case key.NameDownArrow:
if index < len(fields)-1 {
u.keyboardFocus = detailFocusID(fields[index+1])
}
return true
case key.NameLeftArrow:
if len(u.visible) > 0 {
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
}
return true
default:
return false
}
}
func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool {
if !modifiers.Contain(key.ModShortcut) {
return false
}
switch name {
case "F":
_ = u.performShortcut(shortcutSearch)
case "S":
_ = u.performShortcut(shortcutSave)
case "L":
_ = u.performShortcut(shortcutLock)
case "N":
_ = u.performShortcut(shortcutNewEntry)
case "U":
_ = u.performShortcut(shortcutCopyUser)
case "P":
_ = u.performShortcut(shortcutCopyPassword)
case "O":
_ = u.performShortcut(shortcutCopyURL)
default:
return false
}
return true
}
func (u *ui) activateBreadcrumb(index int) {
var path []string
if index <= 0 {
path = nil
} else {
crumbs := u.breadcrumbLabels()
path = append([]string{}, crumbs[1:index+1]...)
}
u.state.NavigateToPath(path)
u.filter()
if index >= len(u.breadcrumbLabels()) {
index = len(u.breadcrumbLabels()) - 1
}
if index < 0 {
index = 0
}
u.keyboardFocus = breadcrumbFocusID(index)
}
func (u *ui) breadcrumbLabels() []string {
if u.state.Section == appstate.SectionRecycleBin {
return nil
}
labels := append([]string{"Vault"}, u.state.CurrentPath...)
if u.state.Section == appstate.SectionTemplates {
labels = append([]string{"Templates"}, u.state.CurrentPath...)
}
return labels
}
func (u *ui) focusListIndex(index int) {
if len(u.visible) == 0 {
return
}
if index < 0 {
index = 0
}
if index >= len(u.visible) {
index = len(u.visible) - 1
}
u.keyboardFocus = listFocusID(index)
u.state.SelectedEntryID = u.visible[index].ID
u.loadSelectedEntryIntoEditor()
}
func (u *ui) focusedListIndexOrZero() int {
if strings.HasPrefix(string(u.keyboardFocus), "list:") {
index := focusIndex(u.keyboardFocus)
if index >= 0 && index < len(u.visible) {
return index
}
}
for i, item := range u.visible {
if item.ID == u.state.SelectedEntryID {
return i
}
}
return 0
}
func (u *ui) focusedDetailFieldOrDefault() detailField {
if strings.HasPrefix(string(u.keyboardFocus), "detail:") {
name := strings.TrimPrefix(string(u.keyboardFocus), "detail:")
for _, field := range detailFocusOrder() {
if string(field) == name {
return field
}
}
}
return detailFieldTitle
}
func (u *ui) focusedDetailIndex() int {
current := u.focusedDetailFieldOrDefault()
for i, field := range detailFocusOrder() {
if field == current {
return i
}
}
return 0
}
func detailFocusOrder() []detailField {
return []detailField{
detailFieldID,
detailFieldTitle,
detailFieldUsername,
detailFieldPassword,
detailFieldURL,
detailFieldPath,
detailFieldTags,
detailFieldPasswordProfile,
detailFieldNotes,
detailFieldFields,
detailFieldHistoryIndex,
}
}
func canonicalFocusID(id focusID) focusID {
switch {
case strings.HasPrefix(string(id), "breadcrumb:"):
return breadcrumbFocusID(0)
case strings.HasPrefix(string(id), "list:"):
return listFocusID(0)
case strings.HasPrefix(string(id), "detail:"):
return detailFocusID(detailFieldTitle)
default:
return id
}
}
func focusIndex(id focusID) int {
_, value, ok := strings.Cut(string(id), ":")
if !ok {
return 0
}
index, err := strconv.Atoi(value)
if err != nil {
return 0
}
return index
}
+551
View File
@@ -0,0 +1,551 @@
package appui
import (
"image"
"image/color"
"strings"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
func (u *ui) header(gtx layout.Context) layout.Dimensions {
if u.usesCompactViewport() {
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
return layout.Dimensions{}
}
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.headerActions(gtx)
}
if u.shouldShowDesktopWorkingHeader() {
return layout.Dimensions{}
}
return card(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return u.brandMark(gtx, 196, 56)
}),
layout.Rigid(u.headerActions),
)
})
}
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() {
return layout.Dimensions{}
}
if u.isVaultLocked() {
return layout.Dimensions{}
}
if u.shouldShowDesktopWorkingHeader() {
return layout.Dimensions{}
}
spacing := gtx.Dp(unit.Dp(8))
metrics := headerActionMetrics{Spacing: spacing}
row := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
metrics.SyncDims = u.syncButtonGroup(gtx)
return metrics.SyncDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
metrics.LockDims = btn.Layout(gtx)
return metrics.LockDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
metrics.MainDims = u.mainMenuButtonGroup(gtx)
return metrics.MainDims
}),
)
}
rowOps := op.Record(gtx.Ops)
metrics.RowDims = row(gtx)
rowCall := rowOps.Stop()
if u.usesCompactViewport() {
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
}
surface := dropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops)
rowCall.Add(gtx.Ops)
rowStack.Pop()
if u.usesCompactViewport() {
if u.syncMenuOpen {
u.phoneSyncMenuVisible = true
u.phoneSyncMenuAnchor = metrics.syncAnchor().point()
}
if u.mainMenuOpen {
u.phoneMainMenuVisible = true
u.phoneMainMenuAnchor = metrics.mainAnchor().point()
}
width := gtx.Constraints.Max.X
return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)}
}
if u.syncMenuOpen {
surface.draw(gtx, metrics.syncAnchor(), u.syncMenu)
}
if u.mainMenuOpen {
surface.draw(gtx, metrics.mainAnchor(), u.mainMenu)
}
width := metrics.RowDims.Size.X
return layout.Dimensions{Size: image.Pt(width, metrics.RowDims.Size.Y)}
}
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
},
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
},
}
rowWidth := menuActionWidth(gtx, rows)
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[0])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[1])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[2])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[3])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[4])
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, rowWidth, rows[5])
}),
)
})
}
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
label := "Sync"
spacing := unit.Dp(4)
if u.usesCompactViewport() {
spacing = unit.Dp(3)
}
row := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.usesCompactViewport())
}),
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.syncMenuToggle(gtx)
}),
)
}
return row(gtx)
}
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
if u.syncMenuOpen {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
if u.usesCompactViewport() {
btn.Size = unit.Dp(16)
btn.Inset = layout.UniformInset(unit.Dp(7))
}
return btn.Layout(gtx)
}
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
model := u.buildSyncMenuModel()
profiles := u.availableRemoteProfiles()
credentials := u.availableRemoteCredentialEntries()
if len(u.vaultRemoteProfileClicks) < len(profiles) {
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
}
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
}
actionRows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
},
}
if model.showShare {
actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}
if model.showRemoteSyncSetupShortcut() {
actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSetupShortcutLabel())
})
}
if model.showDirectRemoteSyncShortcut() {
actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.directRemoteSyncShortcutLabel())
})
}
if model.showRemoteSyncSettingsShortcut() {
actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSettingsShortcutLabel())
})
}
if model.showRemoveRemoteSyncShortcut() {
actionRows = append(actionRows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.removeRemoteSyncShortcutLabel())
})
}
actionWidth := menuActionWidth(gtx, actionRows)
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
rows := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !model.showShare {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
})
}),
}
if model.showRemoteSyncSetupShortcut() {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSetupShortcutLabel())
})
}),
)
}
if model.showDirectRemoteSyncShortcut() {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.directRemoteSyncShortcutLabel())
})
}),
)
}
if model.showRemoteSyncSettingsShortcut() {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.remoteSyncSettingsShortcutLabel())
})
}),
)
}
if model.showRemoveRemoteSyncShortcut() {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.removeRemoteSyncShortcutLabel())
})
}),
)
}
if u.hasOpenVault() && len(profiles) > 0 && len(credentials) > 0 {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.savedBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
if !model.showSelectors {
rows = append(rows,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
summary := model.savedBindingSummary
if !summary.ok {
return layout.Dimensions{}
}
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), summary.profileLabel)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.credentialLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), summary.syncLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}),
)
} else {
for i, profile := range profiles {
i := i
profile := profile
rows = append(rows,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := strings.TrimSpace(u.selectedVaultRemoteProfileID) == profile.ID
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}),
)
}
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
for i, entry := range credentials {
i := i
entry := entry
rows = append(rows,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := strings.TrimSpace(u.selectedVaultRemoteCredentialEntryID) == entry.ID
label := entry.Title
if strings.TrimSpace(entry.Username) != "" {
label += " · " + strings.TrimSpace(entry.Username)
}
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}),
)
}
}
if _, ok := u.selectedVaultRemoteProfile(); ok {
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
}),
)
}
}
}
if model.showSaveCurrentBinding {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.saveCurrentRemoteBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.saveCurrentRemoteBindingButtonLabel())
}),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...)
})
}
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
macro := op.Record(gtx.Ops)
contentDims := w(measureGTX)
_ = macro.Stop()
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
maxWidth := gtx.Constraints.Max.X
if maxWidth > 0 && width > maxWidth {
width = maxWidth
}
if width > 0 {
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
}
return compactCard(gtx, w)
}
func (u *ui) topRightActionOrder() []string {
if u.isVaultLocked() {
return nil
}
return []string{"Sync", "Lock", "Menu"}
}
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
button := func(gtx layout.Context) layout.Dimensions {
icon := u.menuIcon
if icon == nil {
icon = u.settingsIcon
}
btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu")
if u.mainMenuOpen {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = selectedColor
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}
return button(gtx)
}
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() {
return layout.Dimensions{}
}
if !u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone() {
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
contentInsetPx := gtx.Dp(unit.Dp(16))
surface := dropdownSurface{
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
LeftInset: contentInsetPx,
TopInset: contentInsetPx,
}
if u.syncMenuVisibleOnPhone() {
surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
}
if u.mainMenuVisibleOnPhone() {
surface.draw(gtx, dropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
func (u *ui) syncMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
}
func (u *ui) mainMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
}
func (u *ui) syncMenuDropsBelowTrigger() bool {
return true
}
func (u *ui) syncMenuRightAlignsToTrigger() bool {
return true
}
func (u *ui) headerMenusUseOverlayModel() bool {
return true
}
func (u *ui) mainMenuDropsBelowTrigger() bool {
return true
}
func (u *ui) mainMenuRightAlignsToTrigger() bool {
return true
}
func anchoredMenuX(triggerWidth, menuWidth int) int {
return triggerWidth - menuWidth
}
func anchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
x := rowOriginX + triggerRightX - menuWidth
if x < 0 {
return 0
}
if x+menuWidth > containerWidth {
return max(0, containerWidth-menuWidth)
}
return x
}
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
width := 0
for _, row := range rows {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
macro := op.Record(gtx.Ops)
dims := row(measureGTX)
_ = macro.Stop()
if dims.Size.X > width {
width = dims.Size.X
}
}
return width
}
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
if width <= 0 {
return child(gtx)
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return layout.E.Layout(gtx, child)
}
+24
View File
@@ -0,0 +1,24 @@
package appui
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget/material"
)
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
panel := card
if u.usesCompactViewport() {
panel = compactCard
}
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
u.lifecycleBranding,
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.lifecycleControls,
}
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
})
}
+300
View File
@@ -0,0 +1,300 @@
package appui
import (
"encoding/json"
"image/color"
"os"
"path/filepath"
"strings"
"time"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/vault"
)
const (
displayDensityDense = "dense"
displayDensityComfortable = "comfortable"
contrastStandard = "standard"
contrastHigh = "high"
keyboardFocusStandard = "standard"
keyboardFocusProminent = "prominent"
)
type accessibilityPreferences struct {
DisplayDensity string
Contrast string
ReducedMotion bool
KeyboardFocus string
}
type settingsFile struct {
Sync syncSettings `json:"sync,omitempty"`
}
type syncSettings struct {
SourceDefault string `json:"sourceDefault,omitempty"`
DirectionDefault string `json:"directionDefault,omitempty"`
}
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
}
type settingsDraft struct {
Accessibility accessibilityPreferences
Sync syncSettingsDraft
}
type legacySyncPreferences struct {
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
}
type choiceSpec struct {
Click *widget.Clickable
Label string
Active bool
}
func defaultAccessibilityPreferences() accessibilityPreferences {
return accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(true),
Contrast: contrastStandard,
KeyboardFocus: keyboardFocusStandard,
}
}
func displayDensityForDenseLayout(dense bool) string {
if dense {
return displayDensityDense
}
return displayDensityComfortable
}
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
normalized := defaultAccessibilityPreferences()
switch prefs.DisplayDensity {
case displayDensityDense, displayDensityComfortable:
normalized.DisplayDensity = prefs.DisplayDensity
}
switch prefs.Contrast {
case contrastStandard, contrastHigh:
normalized.Contrast = prefs.Contrast
}
switch prefs.KeyboardFocus {
case keyboardFocusStandard, keyboardFocusProminent:
normalized.KeyboardFocus = prefs.KeyboardFocus
}
normalized.ReducedMotion = prefs.ReducedMotion
return normalized
}
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
normalized := normalizeAccessibilityPreferences(prefs)
u.denseLayout = normalized.DisplayDensity == displayDensityDense
u.accessibilityPrefs = normalized
}
func (u *ui) loadSettingsDraft() {
u.settingsDraft = settingsDraft{
Accessibility: accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
Contrast: u.accessibilityPrefs.Contrast,
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
},
Sync: syncSettingsDraft{
SourceDefault: u.syncDefaultSourceMode,
DirectionDefault: u.syncDefaultDirection,
},
}
}
func (u *ui) saveSecuritySettingsAction() error {
if err := u.applySecuritySettingsLive(); err != nil {
return err
}
u.securityDialogOpen = false
return nil
}
func (u *ui) applySecuritySettingsLive() error {
settings := vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}
if err := u.state.ConfigureSecurity(settings); err != nil {
return err
}
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
}
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
u.applySettingsFormToPreferences()
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
u.saveSettings()
u.saveUIPreferences()
return nil
}
func (u *ui) loadSettings() {
u.syncDefaultSourceMode = syncSourceLocal
u.syncDefaultDirection = syncDirectionPull
if strings.TrimSpace(u.settingsPath) != "" {
content, err := os.ReadFile(u.settingsPath)
if err == nil {
var settings settingsFile
if json.Unmarshal(content, &settings) == nil {
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
return
}
}
}
u.loadLegacySyncDefaultsFromUIPreferences()
}
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
if strings.TrimSpace(u.uiPreferencesPath) == "" {
return
}
content, err := os.ReadFile(u.uiPreferencesPath)
if err != nil {
return
}
var prefs legacySyncPreferences
if err := json.Unmarshal(content, &prefs); err != nil {
return
}
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault))
}
func (u *ui) saveSettings() {
if strings.TrimSpace(u.settingsPath) == "" {
return
}
if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil {
return
}
content, err := json.MarshalIndent(settingsFile{
Sync: syncSettings{
SourceDefault: string(u.syncDefaultSourceMode),
DirectionDefault: string(u.syncDefaultDirection),
},
}, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.settingsPath, content, 0o600)
}
func (u *ui) showStatusMessage(message string) {
u.state.StatusMessage = message
if u.accessibilityPrefs.ReducedMotion {
u.statusExpiresAt = time.Time{}
return
}
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
}
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
return sectionCard(gtx, u.theme, title, detail, body)
}
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), body)
lbl.Color = th.Palette.Fg
return lbl.Layout(gtx)
}),
)
})
}
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
children := make([]layout.FlexChild, 0, len(choices)*2)
for i, choice := range choices {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
}
current := choice
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
}
type listRowColors struct {
Title color.NRGBA
Meta color.NRGBA
Secondary color.NRGBA
Divider color.NRGBA
Fill color.NRGBA
Edge color.NRGBA
}
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
colors := listRowColors{
Title: accentColor,
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
Secondary: mutedColor,
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
}
if selected {
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
}
if recycleBin {
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
}
if focused && !selected {
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
}
if u.accessibilityPrefs.Contrast == contrastHigh {
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
if focused || selected {
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
}
}
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
}
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
}
return colors
}
+83
View File
@@ -0,0 +1,83 @@
package appui
import (
"strings"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"git.julianfamily.org/keepassgo/clipboard"
)
const (
shortcutSearch = "search"
shortcutSave = "save"
shortcutLock = "lock"
shortcutNewEntry = "new-entry"
shortcutCopyUser = "copy-user"
shortcutCopyPassword = "copy-password"
shortcutCopyURL = "copy-url"
)
func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Name: "F", Required: key.ModShortcut},
key.Filter{Name: "S", Required: key.ModShortcut},
key.Filter{Name: "L", Required: key.ModShortcut},
key.Filter{Name: "N", Required: key.ModShortcut},
key.Filter{Name: "U", Required: key.ModShortcut},
key.Filter{Name: "P", Required: key.ModShortcut},
key.Filter{Name: "O", Required: key.ModShortcut},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameLeftArrow},
key.Filter{Name: key.NameRightArrow},
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
)
if !ok {
break
}
ke, ok := ev.(key.Event)
if !ok || ke.State != key.Press {
continue
}
u.handleKeyPress(ke.Name, ke.Modifiers)
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
_ = u.handlePhoneBack()
}
}
}
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
u.keyboardFocus = focusSearch
return nil
case shortcutSave:
return u.saveAction()
case shortcutLock:
return u.lockAction()
case shortcutNewEntry:
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.keyboardFocus = detailFocusID(detailFieldTitle)
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
case shortcutCopyPassword:
return u.copySelectedFieldAction(clipboard.TargetPassword)
case shortcutCopyURL:
return u.copySelectedFieldAction(clipboard.TargetURL)
default:
return nil
}
}
+223
View File
@@ -0,0 +1,223 @@
package appui
import (
"image/color"
"runtime"
"strings"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
return layout.Dimensions{Size: gtx.Constraints.Max}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(620))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
}
if width < 1 {
width = gtx.Constraints.Max.X
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return card(gtx, u.syncDialogContent)
})
}),
)
}
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
}
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncDirectionChoices() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncDirectionChoices() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncSourceChoices() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncSourceChoices() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.syncSourceMode == syncSourceRemote {
children := []layout.FlexChild{
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.syncPasswordField(gtx)
}),
}
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
check.Color = accentColor
return check.Layout(gtx)
}),
)
}
if len(matchingCredentials) > 0 {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
for i, entry := range matchingCredentials {
i := i
entry := entry
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := entry.Title
if strings.TrimSpace(entry.Username) != "" {
label += " · " + strings.TrimSpace(entry.Username)
}
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
}),
)
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
if supportsDesktopFilePicker(runtime.GOOS) {
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
}
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
}),
)
}),
)
})
}
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
field := func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return u.outlinedFieldState(gtx, false, field)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}
+142
View File
@@ -0,0 +1,142 @@
package appui
import (
"runtime"
"strings"
"git.julianfamily.org/keepassgo/appstate"
)
type syncMenuModel struct {
hasOpenVault bool
hasSelectedBinding bool
showSelectors bool
showShare bool
showSaveCurrentBinding bool
savedBindingSummary syncMenuBindingSummary
remoteBaseURL string
remotePath string
remoteUsername string
remotePassword string
selectedVaultSyncMode appstate.SyncMode
}
type syncMenuBindingSummary struct {
profileLabel string
credentialLabel string
syncLabel string
ok bool
}
func (u *ui) buildSyncMenuModel() syncMenuModel {
model := syncMenuModel{
hasOpenVault: u.hasOpenVault(),
showSelectors: u.shouldShowSavedRemoteBindingSelectors(),
showShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
remoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
remotePath: strings.TrimSpace(u.remotePath.Text()),
remoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
remotePassword: u.remotePassword.Text(),
selectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
}
_, model.hasSelectedBinding = u.selectedVaultRemoteBinding()
model.savedBindingSummary = u.computeSavedRemoteBindingSummary()
model.showSaveCurrentBinding = model.hasOpenVault && model.remoteBaseURL != "" && model.remotePath != "" && model.remoteUsername != "" && model.remotePassword != ""
return model
}
func (u *ui) computeSavedRemoteBindingSummary() syncMenuBindingSummary {
profile, ok := u.selectedVaultRemoteProfile()
if !ok {
return syncMenuBindingSummary{}
}
entry, ok := u.selectedVaultRemoteCredentialEntry()
if !ok {
return syncMenuBindingSummary{}
}
credentialLabel := entry.Title
if strings.TrimSpace(entry.Username) != "" {
credentialLabel += " · " + strings.TrimSpace(entry.Username)
}
syncLabel := "Sync manually when you choose Use Remote Sync."
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
syncLabel = "Syncs automatically on open and save."
}
return syncMenuBindingSummary{
profileLabel: profile.Name,
credentialLabel: credentialLabel,
syncLabel: syncLabel,
ok: true,
}
}
func (m syncMenuModel) savedBindingHeading() string {
if !m.showSelectors {
return "Use this vault's saved remote sync target"
}
return "Use a saved remote profile from this vault"
}
func (m syncMenuModel) openSelectedButtonLabel() string {
if !m.showSelectors {
return "Use Remote Sync"
}
return "Open Saved Remote"
}
func (m syncMenuModel) showDirectRemoteSyncShortcut() bool {
return m.hasOpenVault && m.hasSelectedBinding
}
func (m syncMenuModel) directRemoteSyncShortcutLabel() string {
return "Use Remote Sync"
}
func (m syncMenuModel) showRemoteSyncSettingsShortcut() bool {
return m.hasOpenVault && m.hasSelectedBinding
}
func (m syncMenuModel) remoteSyncSettingsShortcutLabel() string {
return "Remote Sync Settings"
}
func (m syncMenuModel) showRemoveRemoteSyncShortcut() bool {
return m.showRemoteSyncSettingsShortcut()
}
func (m syncMenuModel) removeRemoteSyncShortcutLabel() string {
return "Stop Using Remote Sync"
}
func (m syncMenuModel) showRemoteSyncSetupShortcut() bool {
return m.hasOpenVault && !m.hasSelectedBinding
}
func (m syncMenuModel) remoteSyncSetupShortcutLabel() string {
return "Set Up Remote Sync"
}
func (m syncMenuModel) actionLabels() []string {
labels := []string{"Open Advanced Sync"}
if m.showRemoteSyncSetupShortcut() {
labels = append(labels, m.remoteSyncSetupShortcutLabel())
}
if m.showDirectRemoteSyncShortcut() {
labels = append(labels, m.directRemoteSyncShortcutLabel())
}
if m.showRemoteSyncSettingsShortcut() {
labels = append(labels, m.remoteSyncSettingsShortcutLabel())
}
if m.showRemoveRemoteSyncShortcut() {
labels = append(labels, m.removeRemoteSyncShortcutLabel())
}
return labels
}
func (m syncMenuModel) saveCurrentRemoteBindingHeading() string {
return "Bind this local vault to the current remote target"
}
func (m syncMenuModel) saveCurrentRemoteBindingButtonLabel() string {
return "Save Remote In Vault"
}