Move app UI package under internal/appui
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user