diff --git a/cmd/gtk/app/run.go b/cmd/gtk/app/run.go index 029a87c24..0b18f1fc8 100644 --- a/cmd/gtk/app/run.go +++ b/cmd/gtk/app/run.go @@ -5,6 +5,8 @@ package app import ( "context" + adw "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/gogpu/systray" "github.com/pactus-project/pactus/cmd" @@ -99,6 +101,56 @@ func Run(ctx context.Context, conn grpc.ClientConnInterface, mwView := gtkutil.IdleAddSyncT(func() *view.MainWindowView { mwView := view.NewMainWindowView() + // Register the main window so dialogs open centered over it. + gtkutil.SetMainWindow(&mwView.Window.Window) + + // Custom title bar: small Pactus logo next to the app name on the left, + // and a round light/dark toggle on the right, beside the window buttons. + header := gtk.NewHeaderBar() + + brand := gtk.NewBox(gtk.OrientationHorizontal, 8) + brand.SetVAlign(gtk.AlignCenter) + logo := gtk.NewImageFromPaintable(assets.ImagePactusLogoTexture) + logo.SetPixelSize(20) + logo.AddCSSClass("app-logo") + title := gtk.NewLabel("Pactus GUI") + title.AddCSSClass("app-title") + brand.Append(logo) + brand.Append(title) + header.PackStart(brand) + // Suppress the centered window title so the brand stays left-aligned. + header.SetTitleWidget(gtk.NewLabel("")) + + themeToggle := gtk.NewToggleButton() + themeToggle.AddCSSClass("theme-toggle") + themeToggle.AddCSSClass("circular") + themeToggle.SetVAlign(gtk.AlignCenter) + themeToggle.SetTooltipText("Toggle light / dark mode") + applyThemeIcon := func(dark bool) { + if dark { + themeToggle.SetLabel("๐ŸŒ™") + } else { + themeToggle.SetLabel("โ˜€") + } + } + // Restore the persisted light/dark choice, falling back to the system + // theme when the user has not chosen yet. + startDark := adw.StyleManagerGetDefault().Dark() + if saved, ok := gtkutil.LoadDarkMode(); ok { + startDark = saved + nav.SetDarkMode(saved) + } + themeToggle.SetActive(startDark) + applyThemeIcon(startDark) + themeToggle.ConnectToggled(func() { + active := themeToggle.Active() + nav.SetDarkMode(active) + applyThemeIcon(active) + }) + header.PackEnd(themeToggle) + + mwView.Window.SetTitlebar(header) + walletCtrl.SetupMenu(mwView.Window) menu := nav.CreateMenu(isLocal) @@ -118,6 +170,14 @@ func Run(ctx context.Context, conn grpc.ClientConnInterface, gtkApp.AddWindow(&mwView.Window.Window) mwView.Window.Present() + // Center the main window on first show; GTK4 cannot position it, so do + // it natively once mapped (no-op on Linux/macOS). + glib.TimeoutAdd(80, func() bool { + gtkutil.CenterActiveWindow() + + return false + }) + return mwView }) diff --git a/cmd/gtk/assets/css/style.css b/cmd/gtk/assets/css/style.css index f5b02f43c..c867b016a 100644 --- a/cmd/gtk/assets/css/style.css +++ b/cmd/gtk/assets/css/style.css @@ -1,98 +1,575 @@ +/* + * Pactus GUI theme foundation. + * + * Colors are derived from GTK's adaptive named colors (@theme_bg_color, + * @theme_fg_color, @theme_base_color ...) so the interface follows the + * active light or dark theme automatically. Only the Pactus brand accent + * is fixed; it is a mid-tone teal chosen to stay legible on both light + * and dark backgrounds. + * + * Note: GTK CSS variables (@define-color) hold colors only, not lengths, + * so radii and spacing are written as literal values kept consistent here. + */ + +/* ---- Brand + token layer ------------------------------------------------ */ + +/* Pactus brand palette, taken from the logo and the site's action buttons. */ +@define-color pactus_navy #002235; +@define-color pactus_accent #00a085; /* green - primary actions/accent */ +@define-color pactus_accent_hi #12b89a; /* green hover */ +@define-color pactus_blue #217aff; /* blue - navigation */ + +/* Route GTK's own accent/selection through the brand green for cohesion. */ +@define-color accent_color @pactus_accent; +@define-color accent_bg_color @pactus_accent; +@define-color theme_selected_bg_color @pactus_accent; +@define-color theme_selected_fg_color #ffffff; + +/* Surfaces + lines derived from the active theme (adapts to light/dark). */ +@define-color surface @theme_bg_color; +@define-color card_bg mix(@theme_base_color, @theme_fg_color, 0.03); +@define-color card_border alpha(@theme_fg_color, 0.10); +@define-color hairline alpha(@theme_fg_color, 0.12); +@define-color muted_fg alpha(@theme_fg_color, 0.62); +@define-color hover_bg alpha(@theme_fg_color, 0.06); +@define-color danger_fg #e0483a; + +/* ---- Base --------------------------------------------------------------- */ + +window { + background-color: @surface; +} + +label { + /* Gentle default; specific labels override below. */ +} + +separator { + margin: 8px 4px; + background-color: @hairline; + min-width: 1px; + min-height: 1px; +} + +/* ---- Header bar (title bar with logo) ----------------------------------- */ + +headerbar { + min-height: 32px; + padding: 1px 6px; + background-color: @surface; + border-bottom: 1px solid @hairline; + box-shadow: none; +} + +.app-logo { + margin: 0 6px 0 2px; +} + +.app-title { + font-weight: 700; + font-size: 13px; + color: @theme_fg_color; +} + +.theme-toggle { + margin-right: 4px; + min-width: 24px; + min-height: 24px; + padding: 0; + font-size: 13px; +} + +headerbar windowcontrols > button { + min-height: 24px; + min-width: 24px; + padding: 0; +} + +/* ---- Notebook tabs (main navigation) ------------------------------------ */ + +notebook > header { + background-color: @surface; + border-bottom: 1px solid @hairline; + padding: 2px 4px 0 4px; +} + +notebook > header tab { + margin: 0 2px; + padding: 8px 16px; + border-radius: 8px 8px 0 0; + border: none; + color: @muted_fg; + font-weight: 500; + transition: background-color 150ms ease, color 150ms ease; +} + +notebook > header tab:hover { + background-color: @hover_bg; + color: @theme_fg_color; +} + +notebook > header tab:checked { + color: @pactus_accent; + box-shadow: inset 0 -2px 0 0 @pactus_accent; +} + +notebook > header tab:checked label { + font-weight: 700; +} + +/* ---- Sidebar navigation ------------------------------------------------- */ + +.sidebar { + background-color: mix(@theme_bg_color, @theme_fg_color, 0.03); + border-right: 1px solid @hairline; + min-width: 210px; + padding: 10px 8px; +} + +.sidebar-list { + background: transparent; +} + +.sidebar-list > row { + border-radius: 8px; + margin: 2px; + min-height: 0; +} + +.sidebar-list > row:hover { + background-color: @hover_bg; +} + +.sidebar-list > row:selected { + background-color: alpha(@pactus_blue, 0.16); +} + +.sidebar-list > row:selected .sidebar-label { + color: @pactus_blue; + font-weight: 700; +} + +.sidebar-row { + padding: 10px 12px; +} + +.sidebar-icon { + font-size: 15px; +} + +.sidebar-label { + font-size: 14px; +} + +/* ---- Buttons ------------------------------------------------------------ */ + +button { + border-radius: 8px; + padding: 6px 14px; + transition: background-color 150ms ease, box-shadow 150ms ease; +} + +button:hover { + background-color: @hover_bg; +} + +button.suggested-action, +button.default { + background-image: none; + background-color: @pactus_accent; + color: #ffffff; + border: none; +} + +button.suggested-action:hover, +button.default:hover { + background-color: @pactus_accent_hi; +} + +button.destructive-action { + background-image: none; + background-color: @danger_fg; + color: #ffffff; + border: none; +} + .inline_button { - padding: 2px; - margin-right: 3px; + padding: 4px; + margin-right: 4px; + border-radius: 6px; +} + +/* ---- Entries ------------------------------------------------------------ */ + +/* Keep all input controls the same height. min-height is only a floor, so the + entry padding is kept small and the floor equalizes entries, dropdowns, + combo boxes and spin buttons. */ +entry { + border-radius: 8px; + padding: 3px 10px; + min-height: 32px; + border: 1px solid @hairline; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +spinbutton, +dropdown > button, +combobox button { + min-height: 32px; + border-radius: 8px; +} + +dropdown, +combobox { + min-height: 32px; +} + +entry:focus-within { + border-color: @pactus_accent; + box-shadow: 0 0 0 2px alpha(@pactus_accent, 0.30); } .copyable_entry { padding-right: 36px; } -.warning { - color: red; +/* ---- Lists + rows ------------------------------------------------------- */ + +list { + padding: 4px; + background-color: transparent; } +list > row { + border-radius: 8px; + padding: 6px 8px; + margin: 1px 2px; + transition: background-color 150ms ease; +} -separator { - margin: 8px; +list > row:hover { + background-color: @hover_bg; +} + +/* ---- Cards / framed sections ------------------------------------------- */ + +frame { + border-radius: 12px; + border: 1px solid @card_border; + background-color: @card_bg; + padding: 4px; +} + +frame > label { + font-weight: 600; + margin: 2px 6px; } .widget-grid { - margin-top: 12px; - margin-bottom: 8px; - margin-right: 4px; - margin-left: 4px; + margin: 12px 6px 8px 6px; } .dialog-box { - margin-top: 12px; - margin-bottom: 8px; - margin-right: 4px; - margin-left: 4px; + margin: 18px 20px 14px 20px; } .dialog-action-bar { + padding-top: 14px; +} +.dialog-action-bar button { + min-width: 90px; + margin-left: 8px; } .toolbar { background-image: none; background-color: @theme_bg_color; box-shadow: none; + border-bottom: 1px solid @hairline; } -/*** Styles for Gtk.ListBox ***/ -list { - padding: 2px; - margin: 2px; -} +/* ---- TextView ----------------------------------------------------------- */ -/*** Styles for Gtk.TextView ***/ textview.view { - padding: 2px; - margin: 2px; - border: 1px solid #000000; + padding: 6px; + border-radius: 8px; + border: 1px solid @hairline; +} + +/* ---- Node status cards -------------------------------------------------- */ + +.node-content { + margin: 16px; +} + +.page-header { + margin-bottom: 4px; +} + +.page-title { + font-size: 18px; + font-weight: 800; +} + +.page-subtitle { + font-size: 12px; + color: @muted_fg; +} + +.card { + padding: 16px 18px; + min-width: 190px; + border-radius: 14px; + background-color: @card_bg; + border: 1px solid @card_border; + box-shadow: 0 1px 3px alpha(@theme_fg_color, 0.06); +} + +.section-title { + font-weight: 800; + font-size: 11px; + color: @muted_fg; + margin-bottom: 2px; +} + +.metric-label { + font-size: 11px; + color: @muted_fg; +} + +.metric-value { + font-size: 15px; + font-weight: 700; +} + +.metric-mono { + font-size: 12px; + font-weight: 600; + font-family: monospace; + background-color: alpha(@theme_fg_color, 0.05); + border-radius: 8px; + padding: 8px 10px; + margin-top: 2px; +} + +.sync-card { + padding: 16px 18px; } -/* Styles for the assistant page */ +.sync-status { + font-size: 13px; + font-weight: 600; + color: @muted_fg; + margin-top: 4px; +} + +/* Local Node Info: aligned definition list. */ +.info-key { + font-size: 13px; + color: @muted_fg; +} + +.info-val { + font-size: 13px; + font-weight: 600; +} + +.info-mono { + font-family: monospace; + font-size: 12px; + font-weight: 600; +} + +/* Prominent statistic value used on card headers. */ +.stat-value { + font-size: 20px; + font-weight: 800; +} + +/* ---- Data table (GtkColumnView) ----------------------------------------- */ + +.table-card { + padding: 16px 18px 12px 18px; +} + +/* Wallet Addresses/Transactions notebook styled as a padded card. */ +notebook.wallet-tabs { + background-color: @card_bg; + border: 1px solid @card_border; + border-radius: 14px; + box-shadow: 0 1px 3px alpha(@theme_fg_color, 0.06); +} + +notebook.wallet-tabs > header { + background: transparent; + border-bottom: 1px solid @hairline; + padding: 2px 12px 0 12px; +} + +notebook.wallet-tabs > stack { + padding: 16px 20px 18px 20px; +} + +columnview.data-table { + background: transparent; +} + +columnview.data-table header button { + background: none; + border: none; + box-shadow: none; + min-height: 0; + padding: 6px 12px; + margin: 0; +} + +columnview.data-table header button label { + font-size: 11px; + font-weight: 700; + color: @muted_fg; +} + +columnview.data-table header { + border-bottom: 1px solid @hairline; +} + +columnview.data-table row cell { + padding: 9px 12px; + border-bottom: 1px solid alpha(@theme_fg_color, 0.06); +} + +columnview.data-table row:hover { + background-color: @hover_bg; +} + +columnview.data-table row:selected { + background-color: alpha(@pactus_blue, 0.12); +} + +.cell-mono { + font-family: monospace; + font-size: 12px; +} + +.cell-num { + font-size: 13px; +} + +.cell-dim { + color: @muted_fg; + font-size: 13px; +} + +/* ---- Circular sync gauge ------------------------------------------------ */ + +.circular-progress-label { + font-size: 15px; + font-weight: 700; + color: @theme_fg_color; +} + +/* ---- Progress + controls ------------------------------------------------ */ + +progressbar > trough { + border-radius: 6px; + min-height: 8px; + background-color: @hover_bg; +} + +progressbar > trough > progress { + background-image: none; + background-color: @pactus_accent; + border-radius: 6px; +} + +progressbar text { + color: @muted_fg; + font-size: 11px; +} + +/* Brand the interactive controls. */ +spinner { + color: @pactus_accent; +} + +check:checked, +radio:checked { + background-image: none; + background-color: @pactus_accent; + border-color: @pactus_accent; +} + +switch:checked { + background-color: @pactus_accent; +} + +scale > trough > highlight { + background-color: @pactus_accent; +} + +/* ---- Status colors ------------------------------------------------------ */ + +.warning { + color: @danger_fg; + font-weight: 600; +} + +/* ---- Startup assistant -------------------------------------------------- */ .assistant-frame { font-size: 14px; - margin: 4px; - padding: 4px; + margin: 8px; + padding: 14px; + border-radius: 12px; + border: 1px solid @card_border; + background-color: @card_bg; + transition: border-color 150ms ease, background-color 150ms ease; +} + +.assistant-frame:hover { + border-color: alpha(@pactus_accent, 0.55); } .assistant-frame-label { + font-weight: 700; margin-bottom: 12px; } .assistant-frame-desc { - font-size: 14px; + font-size: 13px; font-weight: normal; + color: @muted_fg; margin-bottom: 12px; - margin-left: 8px; + margin-left: 4px; } +/* ---- Splash screen ------------------------------------------------------ */ + .splash { - padding: 24px; + padding: 28px 32px; + background-color: @surface; + border-radius: 16px; } .splash-logo { - margin-bottom: 8px; - padding: 0px; + margin-bottom: 10px; + padding: 0; } .splash-spinner { margin-bottom: 6px; - padding: 0px; + padding: 0; } .splash-status { font-size: 14px; - font-weight: 500; - margin-bottom: 6px; + font-weight: 600; + margin-bottom: 4px; } .splash-version { font-size: 12px; - opacity: 0.7; -} \ No newline at end of file + color: @muted_fg; + opacity: 0.8; +} diff --git a/cmd/gtk/assets/icons.go b/cmd/gtk/assets/icons.go index 373fde4e9..465618a5f 100644 --- a/cmd/gtk/assets/icons.go +++ b/cmd/gtk/assets/icons.go @@ -56,6 +56,26 @@ var ( //go:embed icons/save.svg iconSaveData []byte IconSaveTexture *gdk.Texture + + //go:embed icons/nav_overview.svg + iconNavOverviewData []byte + IconNavOverviewTexture *gdk.Texture + + //go:embed icons/nav_committee.svg + iconNavCommitteeData []byte + IconNavCommitteeTexture *gdk.Texture + + //go:embed icons/nav_network.svg + iconNavNetworkData []byte + IconNavNetworkTexture *gdk.Texture + + //go:embed icons/nav_validators.svg + iconNavValidatorsData []byte + IconNavValidatorsTexture *gdk.Texture + + //go:embed icons/nav_wallet.svg + iconNavWalletData []byte + IconNavWalletTexture *gdk.Texture ) func initIcons() { @@ -71,4 +91,9 @@ func initIcons() { IconPrevTexture = TextureFromBytes(iconPrevData) IconNextTexture = TextureFromBytes(iconNextData) IconSaveTexture = TextureFromBytes(iconSaveData) + IconNavOverviewTexture = TextureFromBytes(iconNavOverviewData) + IconNavCommitteeTexture = TextureFromBytes(iconNavCommitteeData) + IconNavNetworkTexture = TextureFromBytes(iconNavNetworkData) + IconNavValidatorsTexture = TextureFromBytes(iconNavValidatorsData) + IconNavWalletTexture = TextureFromBytes(iconNavWalletData) } diff --git a/cmd/gtk/assets/icons/nav_committee.svg b/cmd/gtk/assets/icons/nav_committee.svg new file mode 100644 index 000000000..1a6852318 --- /dev/null +++ b/cmd/gtk/assets/icons/nav_committee.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/cmd/gtk/assets/icons/nav_network.svg b/cmd/gtk/assets/icons/nav_network.svg new file mode 100644 index 000000000..64f8e5a6f --- /dev/null +++ b/cmd/gtk/assets/icons/nav_network.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cmd/gtk/assets/icons/nav_overview.svg b/cmd/gtk/assets/icons/nav_overview.svg new file mode 100644 index 000000000..cd085b135 --- /dev/null +++ b/cmd/gtk/assets/icons/nav_overview.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/cmd/gtk/assets/icons/nav_validators.svg b/cmd/gtk/assets/icons/nav_validators.svg new file mode 100644 index 000000000..01c0008ad --- /dev/null +++ b/cmd/gtk/assets/icons/nav_validators.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cmd/gtk/assets/icons/nav_wallet.svg b/cmd/gtk/assets/icons/nav_wallet.svg new file mode 100644 index 000000000..b9ae33cab --- /dev/null +++ b/cmd/gtk/assets/icons/nav_wallet.svg @@ -0,0 +1,3 @@ + + + diff --git a/cmd/gtk/assets/ui/main_window.ui b/cmd/gtk/assets/ui/main_window.ui index d2adaff4d..45a0fe7ce 100644 --- a/cmd/gtk/assets/ui/main_window.ui +++ b/cmd/gtk/assets/ui/main_window.ui @@ -1,68 +1,209 @@ - - 640 - 920 + 1040 Pactus GUI - vertical + horizontal - - True + + sidebar + vertical - - True - - - - - right - Node + + sidebar-list navigation-sidebar + single + True + + + node + + + sidebar-row + 12 + + + sidebar-icon + 18 + + + + + Node Overview + sidebar-label + start + + + + + + + + + committee + + + sidebar-row + 12 + + + sidebar-icon + 18 + + + + + Committee + sidebar-label + start + + + + + + + + + network + + + sidebar-row + 12 + + + sidebar-icon + 18 + + + + + Network + sidebar-label + start + + + + + + + + + validators + + + sidebar-row + 12 + + + sidebar-icon + 18 + + + + + My Validators + sidebar-label + start + + + + + + + + + wallet + + + sidebar-row + 12 + + + sidebar-icon + 18 + + + + + Wallet + sidebar-label + start + + + + + + + + + + + True + True + False + False + crossfade - - True - - - - - Committee + + node + + + never + True + True + + + vertical + + + + - - True - - - - - Network + + committee + + + vertical + + - - True - - - - - My Validators + + network + + + vertical + + - - True + + validators + + + vertical + + - - - Wallet + + + wallet + + + vertical + + diff --git a/cmd/gtk/assets/ui/widget_committee.ui b/cmd/gtk/assets/ui/widget_committee.ui index 9cc96961c..8c2cfe699 100644 --- a/cmd/gtk/assets/ui/widget_committee.ui +++ b/cmd/gtk/assets/ui/widget_committee.ui @@ -1,136 +1,147 @@ - - True + node-content vertical + 12 + - - left + + Committee + page-title + start + 0 + + + + + + + True + 12 - + + card vertical + 4 + + + Committee Size + section-title + start + + - - 8 - widget-grid - 8 - - - start - Committee Size: - - 0 - 0 - - - - - - start - True - - 1 - 0 - - - - - - start - Committee Power: - - 0 - 1 - - - - - - start - True - - 1 - 1 - - - - - - start - Total Power: - - 0 - 2 - - - - - - start - True - - 1 - 2 - - - - - - start - Protocol Versions: - - 0 - 3 - - - - - - start - True - start - True - - 1 - 3 - - - + + stat-value + start + 0 + True - - - _Info - True + + + card + vertical + 4 + + + Committee Power + section-title + start + + + + + stat-value + start + 0 + True + end + + - + + card vertical + 4 - - True - True - - - True - True - True - - + + Total Power + section-title + start + + + + + stat-value + start + 0 + True + end + + + + + + + card + vertical + 4 + + + Protocol Versions + section-title + start + + + + + metric-value + start + 0 + start + True + True - + + + + + + + card table-card + vertical + True + 10 + - _Members - True + Members + section-title + start + + + + + True + True + never + + + True + True + True + + diff --git a/cmd/gtk/assets/ui/widget_network.ui b/cmd/gtk/assets/ui/widget_network.ui index 3a4a83d98..18a5c4f2b 100644 --- a/cmd/gtk/assets/ui/widget_network.ui +++ b/cmd/gtk/assets/ui/widget_network.ui @@ -1,95 +1,101 @@ - - + - True + node-content vertical + 12 + - - left + + Network + page-title + start + 0 + + + + + + True + 12 + + + card + vertical + 4 + + + Network Name + section-title + start + + + + + stat-value + start + 0 + True + end + + + + - + + card vertical + 4 + + + Connected Peers + section-title + start + + - - widget-grid - 8 - 8 - - - start - Network Name: - - 0 - 0 - - - - - - start - True - - 1 - 0 - - - - - - start - Connected Peers: - - 0 - 1 - - - - - - start - True - - 1 - 1 - - - + + stat-value + start + 0 + True - + + + + + + card table-card + vertical + True + 10 + - _Info - True + Peers + section-title + start - - vertical + + True + True + never - + True True - - - True - - + True - - - _Peers - True - - - \ No newline at end of file + diff --git a/cmd/gtk/assets/ui/widget_node.ui b/cmd/gtk/assets/ui/widget_node.ui index c88c7da86..5333767f9 100644 --- a/cmd/gtk/assets/ui/widget_node.ui +++ b/cmd/gtk/assets/ui/widget_node.ui @@ -1,383 +1,498 @@ - - top + node-content vertical + 12 + + - - widget-grid - 8 - True - 8 - True - - - start - - start - - 0 - 0 - - - - - - start - True - start - - 1 - 0 - - - - - - start - ๐ŸŒ Network: - start - - 0 - 1 - - - - - - start - True - start - - 1 - 1 - - - - - - start - ๐Ÿงพ Network ID: - start - - 0 - 2 - - - - - - start - True - start - - 1 - 2 - - - - - - start - ๐Ÿค– Node Agent: - start - - 0 - 3 - - - - - - start - True - start - - 1 - 3 - - - - - - start - โฐ Clock Offset: - start - - 0 - 4 - - - - - - start - start - - 1 - 4 - - - - - - start - ๐Ÿ“ก Connections: - start - - 0 - 5 - - - - - - start - True - start - - 1 - 5 - - - - - - start - ๐Ÿ”ฐ Moniker: - start - - 0 - 6 - - - - - - start - True - start - - 1 - 6 - - - - - - start - ๐Ÿ” Reachability: - start - - 0 - 7 - - - - - - start - True - start - - 1 - 7 - - - - - - start - โœ‚๏ธ Pruned: - - 0 - 8 - - - - - - start - start - - 1 - 8 - - - - - - start - โœ… Active Validators: - start - - 0 - 9 - - - - - - start - True - start - - 1 - 9 - - - - - - start - โœจ Total Power: - start - - 0 - 10 - - - - - - start - True - start - - 1 - 10 - - - - - - start - ๐Ÿ“ˆ Average Score: - start - - 0 - 11 - - - - - - start - True - start - - 1 - 11 - - - + + page-header + 8 + Node Overview + page-title start - ๐Ÿ’Ž In Committee Now: - start - - 0 - 12 - + True + 0 - - start - True - start - - 1 - 12 - + + Transfer + app.transfer + suggested-action - - start - โ›“๏ธ Last Block Height: - start - - 0 - 13 - - - - - - start - start - - 1 - 13 - - - - - - start - ๐Ÿ•” Last Block Time: - start - - 0 - 14 - + + Bond + app.bond - - start - start - - 1 - 14 - + + Unbond + app.unbond - - start - ๐Ÿ“ฆ Remaining Blocks: - start - - 0 - 15 - + + Withdraw + app.withdraw - - - start - start - - 1 - 15 - + + + + + + + True + True + start + 12 + + + + + card + vertical + 14 + + + Network Health + section-title + start + + + + + metric + vertical + + + Synced Height + metric-label + start + + + + + metric-value + start + 0 + + + + + + + metric + vertical + + + Last Block + metric-label + start + + + + + metric-value + start + 0 + end + + + + + + + metric + vertical + + + Connections + metric-label + start + + + + + metric-value + start + 0 + True + + + + + + + metric + vertical + + + Reachability + metric-label + start + + + + + metric-value + start + 0 + True + + + + + + + metric + vertical + + + Clock Offset + metric-label + start + + + + + metric-value + start + 0 + + + + + + + + + + + card + vertical + 14 + + + Validator Network + section-title + start + + + + + metric + vertical + + + Active Validators + metric-label + start + + + + + metric-value + start + 0 + True + + + + + + + metric + vertical + + + Total Staked Power + metric-label + start + + + + + metric-value + start + 0 + True + + + + + + + metric + vertical + + + Average Score + metric-label + start + + + + + metric-value + start + 0 + True + + + + + + + metric + vertical + + + In Committee Now + metric-label + start + + + + + metric-value + start + 0 + + + + + + + + + + + card sync-card + vertical + 6 + + + Sync Status + section-title + start + + + + + center + center + 8 + 8 + + + + + sync-status + center + + + + + metric + vertical + center + + + Remaining Blocks + metric-label + center + + + + + metric-value + center + + + + + + + + + + + card + vertical + 12 - start - ๐Ÿ”„ Syncing Progress: - start - - 0 - 16 - - - - - - 0.01 - True - - 1 - 16 - + Local Node Info + section-title + start + + + + + info-grid + 24 + 10 + + + info-key + start + start + + 0 + 0 + + + + + + info-val + start + 0 + True + True + end + + 1 + 0 + + + + + + Network + info-key + start + + 0 + 1 + + + + + + info-val + start + 0 + True + True + + 1 + 1 + + + + + + Moniker + info-key + start + + 0 + 2 + + + + + + info-val + start + 0 + True + True + + 1 + 2 + + + + + + Pruned + info-key + start + + 0 + 3 + + + + + + info-val + start + 0 + True + + 1 + 3 + + + + + + Node Agent + info-key + start + + 0 + 4 + + + + + + info-val + start + 0 + True + True + end + + 1 + 4 + + + + + + Network ID + info-key + start + start + + 0 + 5 + + + + + + info-val info-mono + start + 0 + True + True + True + char + + 1 + 5 + + + diff --git a/cmd/gtk/assets/ui/widget_validator.ui b/cmd/gtk/assets/ui/widget_validator.ui index f4195439a..9ea9e27bc 100644 --- a/cmd/gtk/assets/ui/widget_validator.ui +++ b/cmd/gtk/assets/ui/widget_validator.ui @@ -1,19 +1,49 @@ - - + + node-content + vertical + 12 + - - True + + My Validators + page-title + start + 0 + + + + + + card table-card + vertical True + 10 + + + Validators + section-title + start + + - + + True True + never + + + True + True + True + + - \ No newline at end of file + diff --git a/cmd/gtk/assets/ui/widget_wallet.ui b/cmd/gtk/assets/ui/widget_wallet.ui index 3e0ceae86..377278d5f 100644 --- a/cmd/gtk/assets/ui/widget_wallet.ui +++ b/cmd/gtk/assets/ui/widget_wallet.ui @@ -1,241 +1,278 @@ - - widget-main-box - True + node-content vertical + 12 + + - - left + + page-header + 8 + + + vertical + True + center + + + Wallet + page-title + start + 0 + + + + + page-subtitle + start + 0 + True + + + + + + + suggested-action + center + + + + + center + + + + + center + + + + + center + + + + + + + + + True + 12 + + + card + vertical + 4 + + + Total Balance + section-title + start + + + + + stat-value + start + 0 + True + end + + + + - + + card vertical + 4 + + + Total Stake + section-title + start + + + + + stat-value + start + 0 + True + end + + + + + + + card + vertical + 4 + + + Default Fee + section-title + start + + + + + stat-value + start + 0 + True + end + + + + + + + + + + + card + vertical + 10 + + + True + 24 - 8 - widget-grid - True - 8 - True + info-grid + 16 + 10 + Driver + info-key start - Name: - start - - 0 - 0 - - - - - - start - True - start - - 1 - 0 - - - - - - start - Location: - start - - 0 - 1 - - - - - - start - True - start - - 1 - 1 - - - - - - start - Driver: - True - start - - 0 - 2 - + 00 + info-val start + 0 + True True - start - - 1 - 2 - + 10 + Created At + info-key start - Created At: - True - start - - 0 - 3 - + 01 + info-val start + 0 + True True - start - - 1 - 3 - + 11 + + + + + info-grid + 16 + 10 + Encrypted + info-key start - Encrypted: - start - - 0 - 4 - + 00 + info-val start - start - - 1 - 4 - + 0 + True + 10 + Location + info-key start - Total Balance: - start - - 0 - 5 - + 01 - - start - start - - 1 - 5 - - - - - - start - Total Stake: - start - - 0 - 6 - - - - - - start - start - - 1 - 6 - - - - - - start - Default Fee: - start - - 0 - 7 - - - - - + + info-val start - start - - 1 - 7 - + 0 + True + True + end + 11 - - - Info - - + + + + + + + wallet-tabs + True vertical + 10 - vertical + 8 - - - - True - - + + Addresses + section-title + start + True - - toolbar - - - - - - - - - - - - - - - + + center + + + + + + + True + True + never + + + True + True @@ -250,30 +287,44 @@ vertical + 10 - vertical + 8 + + + Transactions + section-title + start + True + + + + + center + + - - - - True - - + + center - - toolbar - - - - - - - - - + + center + + + + + + + True + True + never + + + True + True diff --git a/cmd/gtk/controller/committee_widget_controller.go b/cmd/gtk/controller/committee_widget_controller.go index 831be70af..7d208fba7 100644 --- a/cmd/gtk/controller/committee_widget_controller.go +++ b/cmd/gtk/controller/committee_widget_controller.go @@ -48,27 +48,33 @@ func NewCommitteeWidgetController( func (c *CommitteeWidgetController) BuildView(ctx context.Context) error { gtkutil.IdleAddSync(func() { - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewMembers, "No", func(row committeeRow) string { - return strconv.Itoa(row.no) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewMembers, "Address", func(row committeeRow) string { + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewMembers, "No", 0, false, "cell-dim", + func(row committeeRow) string { + return strconv.Itoa(row.no) + }) + gtkutil.ColumnViewAppendAddressColumn(c.view.ColViewMembers, "Address", func(row committeeRow) string { return row.val.GetAddress() }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewMembers, "Stake", func(row committeeRow) string { - return amount.Amount(row.val.GetStake()).String() - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewMembers, "Bonding Height", func(row committeeRow) string { - return strconv.Itoa(int(row.val.GetLastBondingHeight())) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewMembers, "Sortition Height", func(row committeeRow) string { - return strconv.Itoa(int(row.val.GetLastSortitionHeight())) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewMembers, "Protocol Version", func(row committeeRow) string { - return strconv.Itoa(int(row.val.GetProtocolVersion())) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewMembers, "Availability Score", func(row committeeRow) string { - return gtkutil.AvailabilityScorePercent(row.val.GetAvailabilityScore()) - }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewMembers, "Stake", 1, false, "cell-num", + func(row committeeRow) string { + return amount.Amount(row.val.GetStake()).String() + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewMembers, "Bonding Height", 1, false, "cell-num", + func(row committeeRow) string { + return strconv.Itoa(int(row.val.GetLastBondingHeight())) + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewMembers, "Sortition Height", 1, false, "cell-num", + func(row committeeRow) string { + return strconv.Itoa(int(row.val.GetLastSortitionHeight())) + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewMembers, "Protocol", 1, false, "cell-num", + func(row committeeRow) string { + return strconv.Itoa(int(row.val.GetProtocolVersion())) + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewMembers, "Availability", 1, false, "cell-num", + func(row committeeRow) string { + return gtkutil.AvailabilityScorePercent(row.val.GetAvailabilityScore()) + }) }) scheduler.Every(refreshCommitteeInterval).Do(ctx, func(ctx context.Context) { diff --git a/cmd/gtk/controller/navigator.go b/cmd/gtk/controller/navigator.go index 088794981..6d7d983fd 100644 --- a/cmd/gtk/controller/navigator.go +++ b/cmd/gtk/controller/navigator.go @@ -3,6 +3,7 @@ package controller import ( + adw "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" @@ -162,6 +163,18 @@ func (n *Navigator) Quit() { n.gtkApp.Quit() } +// SetDarkMode forces the light or dark appearance at runtime through the +// libadwaita style manager, independent of the operating system theme. +// It must be called on the UI thread. +func (*Navigator) SetDarkMode(enabled bool) { + scheme := adw.ColorSchemeForceLight + if enabled { + scheme = adw.ColorSchemeForceDark + } + adw.StyleManagerGetDefault().SetColorScheme(scheme) + gtkutil.SaveDarkMode(enabled) +} + func (n *Navigator) CreateMenu(isLocal bool) *gio.Menu { // Helper to create an app action that calls a given function createAppAction := func(name string, callback func()) { diff --git a/cmd/gtk/controller/network_widget_controller.go b/cmd/gtk/controller/network_widget_controller.go index b16f0c9b5..88cf160a7 100644 --- a/cmd/gtk/controller/network_widget_controller.go +++ b/cmd/gtk/controller/network_widget_controller.go @@ -56,27 +56,31 @@ func NewNetworkWidgetController( func (c *NetworkWidgetController) BuildView(ctx context.Context) error { gtkutil.IdleAddSync(func() { - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewPeers, "No", func(row peerRow) string { - return strconv.Itoa(row.no) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewPeers, "Moniker", func(row peerRow) string { - return row.peer.GetMoniker() - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewPeers, "Address", func(row peerRow) string { + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewPeers, "No", 0, false, "cell-dim", + func(row peerRow) string { + return strconv.Itoa(row.no) + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewPeers, "Moniker", 0, false, "", + func(row peerRow) string { + return row.peer.GetMoniker() + }) + gtkutil.ColumnViewAppendAddressColumn(c.view.ColViewPeers, "Address", func(row peerRow) string { return row.peer.GetAddress() }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewPeers, "Peer ID", func(row peerRow) string { + gtkutil.ColumnViewAppendAddressColumn(c.view.ColViewPeers, "Peer ID", func(row peerRow) string { return row.peer.GetPeerId() }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewPeers, "Height", func(row peerRow) string { - return strconv.Itoa(int(row.peer.GetHeight())) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewPeers, "Agent", func(row peerRow) string { + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewPeers, "Height", 1, false, "cell-num", + func(row peerRow) string { + return strconv.Itoa(int(row.peer.GetHeight())) + }) + gtkutil.ColumnViewAppendEllipsizedColumn(c.view.ColViewPeers, "Agent", func(row peerRow) string { return row.peer.GetAgent() }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewPeers, "Direction", func(row peerRow) string { - return peerDirectionString(row.peer.GetDirection()) - }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewPeers, "Direction", 0, false, "", + func(row peerRow) string { + return peerDirectionString(row.peer.GetDirection()) + }) }) scheduler.Every(refreshNetworkInterval).Do(ctx, func(ctx context.Context) { diff --git a/cmd/gtk/controller/node_widget_controller.go b/cmd/gtk/controller/node_widget_controller.go index e0e9cb243..fd82936a5 100644 --- a/cmd/gtk/controller/node_widget_controller.go +++ b/cmd/gtk/controller/node_widget_controller.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "strconv" + "strings" "time" "github.com/pactus-project/gopkg/scheduler" @@ -49,7 +50,8 @@ func (c *NodeWidgetController) BuildView(ctx context.Context, connectionLabel, c c.view.LabelConnectionValue.SetText(connectionValue) c.view.LabelNetwork.SetText(nodeInfo.NetworkName) c.view.LabelNetworkID.SetText(nodeInfo.PeerId) - c.view.LabelAgent.SetText(nodeInfo.Agent) + c.view.LabelAgent.SetText(parseAgent(nodeInfo.Agent)) + c.view.LabelAgent.SetTooltipText(nodeInfo.Agent) c.view.LabelMoniker.SetText(nodeInfo.Moniker) c.view.LabelIsPrune.SetText(strconv.FormatBool(chainInfo.IsPruned)) }) @@ -89,11 +91,22 @@ func (c *NodeWidgetController) timeoutProgress() { } else { c.view.LabelBlocksLeft.SetText(strconv.FormatInt(chainInfo.BlocksLeft, 10)) } - c.view.ProgressBarSynced.SetFraction(percentage) - c.view.ProgressBarSynced.SetText(fmt.Sprintf("%s %%", strconv.FormatFloat(percentage*100, 'f', 2, 64))) + c.view.ProgressSynced.SetFraction(percentage) + c.setSyncStatus(percentage) }) } +// setSyncStatus shows a short caption under the sync ring. +func (c *NodeWidgetController) setSyncStatus(percentage float64) { + if percentage >= 1 { + c.view.LabelSyncStatus.SetMarkup( + "Fully synced") + + return + } + c.view.LabelSyncStatus.SetText("Syncing blocksโ€ฆ") +} + func (c *NodeWidgetController) timeoutInfo() { chainInfo, err := c.model.GetBlockchainInfo() if err != nil { @@ -131,7 +144,7 @@ func (c *NodeWidgetController) timeoutInfo() { c.view.LabelTotalPower.SetText(totalStake.String()) c.view.LabelAverageScore.SetText(fmt.Sprintf("%.2f", chainInfo.AverageScore)) c.view.LabelNumConnections.SetText(numConnections) - c.view.LabelReachability.SetText(reachability) + c.setReachability(reachability) c.setInCommittee(chainInfo.InCommittee) }) @@ -160,9 +173,64 @@ func (c *NodeWidgetController) setClockOffset(offset time.Duration, offsetErr er c.view.LabelClockOffset.RemoveCSSClass("warning") } +// setReachability shows the reachability with a status color. +func (c *NodeWidgetController) setReachability(reachability string) { + color := "" + switch strings.ToLower(reachability) { + case "public": + color = "#00a085" + case "private": + color = "#e0a500" + } + + if color != "" { + c.view.LabelReachability.SetMarkup( + fmt.Sprintf("%s", color, reachability)) + + return + } + + c.view.LabelReachability.SetText(reachability) +} + +// parseAgent turns the raw node agent string into a friendly summary, e.g. +// "node=gui/node-version=1.17.0-beta/protocol-version=4/os=windows/arch=amd64" +// becomes "GUI ยท v1.17.0-beta ยท windows/amd64 ยท protocol 4". +func parseAgent(agent string) string { + fields := map[string]string{} + for _, part := range strings.Split(agent, "/") { + if kv := strings.SplitN(part, "=", 2); len(kv) == 2 { + fields[kv[0]] = kv[1] + } + } + + var parts []string + if node := fields["node"]; node != "" { + parts = append(parts, strings.ToUpper(node)) + } + if version := fields["node-version"]; version != "" { + parts = append(parts, "v"+version) + } + if os := fields["os"]; os != "" { + parts = append(parts, strings.ToUpper(os[:1])+os[1:]) + } + if arch := fields["arch"]; arch != "" { + parts = append(parts, arch) + } + if protocol := fields["protocol-version"]; protocol != "" { + parts = append(parts, "protocol "+protocol) + } + + if len(parts) == 0 { + return agent + } + + return strings.Join(parts, " ยท ") +} + func (c *NodeWidgetController) setInCommittee(inCommittee bool) { if inCommittee { - c.view.LabelInCommittee.SetMarkup("Yes") + c.view.LabelInCommittee.SetMarkup("Yes") return } diff --git a/cmd/gtk/controller/validator_widget_controller.go b/cmd/gtk/controller/validator_widget_controller.go index 654bd6f14..677d334bb 100644 --- a/cmd/gtk/controller/validator_widget_controller.go +++ b/cmd/gtk/controller/validator_widget_controller.go @@ -43,27 +43,33 @@ func NewValidatorWidgetController( func (c *ValidatorWidgetController) BuildView(ctx context.Context) error { gtkutil.IdleAddSync(func() { - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewValidators, "No", func(row validatorRow) string { - return strconv.Itoa(row.no) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewValidators, "Address", func(row validatorRow) string { + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewValidators, "No", 0, false, "cell-dim", + func(row validatorRow) string { + return strconv.Itoa(row.no) + }) + gtkutil.ColumnViewAppendAddressColumn(c.view.ColViewValidators, "Address", func(row validatorRow) string { return row.val.GetAddress() }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewValidators, "Stake", func(row validatorRow) string { - return amount.Amount(row.val.GetStake()).String() - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewValidators, "Bonding Height", func(row validatorRow) string { - return strconv.Itoa(int(row.val.GetLastBondingHeight())) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewValidators, "Sortition Height", func(row validatorRow) string { - return strconv.Itoa(int(row.val.GetLastSortitionHeight())) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewValidators, "Unbonding Height", func(row validatorRow) string { - return strconv.Itoa(int(row.val.GetUnbondingHeight())) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewValidators, "Availability Score", func(row validatorRow) string { - return gtkutil.AvailabilityScorePercent(row.val.GetAvailabilityScore()) - }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewValidators, "Stake", 1, false, "cell-num", + func(row validatorRow) string { + return amount.Amount(row.val.GetStake()).String() + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewValidators, "Bonding Height", 1, false, "cell-num", + func(row validatorRow) string { + return strconv.Itoa(int(row.val.GetLastBondingHeight())) + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewValidators, "Sortition Height", 1, false, "cell-num", + func(row validatorRow) string { + return strconv.Itoa(int(row.val.GetLastSortitionHeight())) + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewValidators, "Unbonding Height", 1, false, "cell-num", + func(row validatorRow) string { + return strconv.Itoa(int(row.val.GetUnbondingHeight())) + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewValidators, "Availability Score", 1, false, "cell-num", + func(row validatorRow) string { + return gtkutil.AvailabilityScorePercent(row.val.GetAvailabilityScore()) + }) }) scheduler.Every(refreshValidatorsInterval).Do(ctx, func(ctx context.Context) { diff --git a/cmd/gtk/controller/wallet_widget_controller.go b/cmd/gtk/controller/wallet_widget_controller.go index 56e5bf1bb..4432e8ff8 100644 --- a/cmd/gtk/controller/wallet_widget_controller.go +++ b/cmd/gtk/controller/wallet_widget_controller.go @@ -61,10 +61,11 @@ func (c *WalletWidgetController) BuildView(ctx context.Context, nav *Navigator) gtkutil.ColumnViewSetup(c.view.ColViewTransactions, c.lsTransactions) // Setup address columns - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewAddresses, "No", func(row addressRow) string { - return strconv.Itoa(row.no) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewAddresses, "Address", func(row addressRow) string { + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewAddresses, "No", 0, false, "cell-dim", + func(row addressRow) string { + return strconv.Itoa(row.no) + }) + gtkutil.ColumnViewAppendAddressColumn(c.view.ColViewAddresses, "Address", func(row addressRow) string { return row.addr.Address }) gtkutil.ColumnViewAppendTextColumn(c.view.ColViewAddresses, "Type", func(row addressRow) string { @@ -75,41 +76,49 @@ func (c *WalletWidgetController) BuildView(ctx context.Context, nav *Navigator) return typ.String() }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewAddresses, "Label", func(row addressRow) string { - return row.addr.Label - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewAddresses, "Balance", func(row addressRow) string { - return amount.Amount(row.addr.Balance).String() - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewAddresses, "Stake", func(row addressRow) string { - return amount.Amount(row.addr.Stake).String() - }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewAddresses, "Label", 0, false, "", + func(row addressRow) string { + return row.addr.Label + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewAddresses, "Balance", 1, false, "cell-num", + func(row addressRow) string { + return amount.Amount(row.addr.Balance).String() + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewAddresses, "Stake", 1, false, "cell-num", + func(row addressRow) string { + return amount.Amount(row.addr.Stake).String() + }) // Setup transaction columns - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewTransactions, "No", func(row transactionRow) string { - return strconv.Itoa(int(row.trx.No)) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewTransactions, "ID", func(row transactionRow) string { + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewTransactions, "No", 0, false, "cell-dim", + func(row transactionRow) string { + return strconv.Itoa(int(row.trx.No)) + }) + gtkutil.ColumnViewAppendAddressColumn(c.view.ColViewTransactions, "ID", func(row transactionRow) string { return row.trx.TxId }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewTransactions, "Sender", func(row transactionRow) string { + gtkutil.ColumnViewAppendAddressColumn(c.view.ColViewTransactions, "Sender", func(row transactionRow) string { return row.trx.Sender }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewTransactions, "Receiver", func(row transactionRow) string { + gtkutil.ColumnViewAppendAddressColumn(c.view.ColViewTransactions, "Receiver", func(row transactionRow) string { return row.trx.Receiver }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewTransactions, "Type", func(row transactionRow) string { - return payload.Type(row.trx.PayloadType).String() - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewTransactions, "Amount", func(row transactionRow) string { - return amount.Amount(row.trx.Amount).String() - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewTransactions, "Direction", func(row transactionRow) string { - return getDirectionTextWithIcon(types.TxDirection(row.trx.Direction)) - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewTransactions, "Status", func(row transactionRow) string { - return types.TransactionStatus(row.trx.Status).String() - }) - gtkutil.ColumnViewAppendTextColumn(c.view.ColViewTransactions, "Comment", func(row transactionRow) string { + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewTransactions, "Type", 0, false, "", + func(row transactionRow) string { + return payload.Type(row.trx.PayloadType).String() + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewTransactions, "Amount", 1, false, "cell-num", + func(row transactionRow) string { + return amount.Amount(row.trx.Amount).String() + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewTransactions, "Direction", 0, false, "", + func(row transactionRow) string { + return getDirectionTextWithIcon(types.TxDirection(row.trx.Direction)) + }) + gtkutil.ColumnViewAppendTextColumnEx(c.view.ColViewTransactions, "Status", 0, false, "", + func(row transactionRow) string { + return types.TransactionStatus(row.trx.Status).String() + }) + gtkutil.ColumnViewAppendEllipsizedColumn(c.view.ColViewTransactions, "Comment", func(row transactionRow) string { return row.trx.Comment }) }) diff --git a/cmd/gtk/gtkutil/center_other.go b/cmd/gtk/gtkutil/center_other.go new file mode 100644 index 000000000..09fa0aef3 --- /dev/null +++ b/cmd/gtk/gtkutil/center_other.go @@ -0,0 +1,7 @@ +//go:build gtk && !windows + +package gtkutil + +// CenterActiveWindow is a no-op on platforms other than Windows, where the +// window manager already positions new toplevels reasonably. +func CenterActiveWindow() {} diff --git a/cmd/gtk/gtkutil/center_windows.go b/cmd/gtk/gtkutil/center_windows.go new file mode 100644 index 000000000..a2f20aeae --- /dev/null +++ b/cmd/gtk/gtkutil/center_windows.go @@ -0,0 +1,57 @@ +//go:build gtk && windows + +package gtkutil + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + procGetForegroundWindow = user32.NewProc("GetForegroundWindow") + procGetWindowThreadProcessID = user32.NewProc("GetWindowThreadProcessId") + procGetWindowRect = user32.NewProc("GetWindowRect") + procSetWindowPos = user32.NewProc("SetWindowPos") + procGetSystemMetrics = user32.NewProc("GetSystemMetrics") +) + +type winRect struct { + left, top, right, bottom int32 +} + +// CenterActiveWindow centers this process's foreground window on the primary +// monitor. GTK4 provides no way to move a window, so parentless/undecorated +// windows such as the splash are centered natively on Windows. +func CenterActiveWindow() { + hwnd, _, _ := procGetForegroundWindow.Call() + if hwnd == 0 { + return + } + + var pid uint32 + procGetWindowThreadProcessID.Call(hwnd, uintptr(unsafe.Pointer(&pid))) + if pid != windows.GetCurrentProcessId() { + return + } + + var rect winRect + procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect))) + width := rect.right - rect.left + height := rect.bottom - rect.top + + screenW, _, _ := procGetSystemMetrics.Call(0) // SM_CXSCREEN + screenH, _, _ := procGetSystemMetrics.Call(1) // SM_CYSCREEN + + x := (int32(screenW) - width) / 2 + y := (int32(screenH) - height) / 2 + + const ( + swpNoSize = 0x0001 + swpNoZOrder = 0x0004 + swpNoActivate = 0x0010 + ) + procSetWindowPos.Call(hwnd, 0, uintptr(x), uintptr(y), 0, 0, + swpNoSize|swpNoZOrder|swpNoActivate) +} diff --git a/cmd/gtk/gtkutil/columnview.go b/cmd/gtk/gtkutil/columnview.go index 51196bc9d..9a2d40bb2 100644 --- a/cmd/gtk/gtkutil/columnview.go +++ b/cmd/gtk/gtkutil/columnview.go @@ -6,6 +6,7 @@ import ( "github.com/diamondburned/gotk4/pkg/core/gioutil" "github.com/diamondburned/gotk4/pkg/core/glib" "github.com/diamondburned/gotk4/pkg/gtk/v4" + "github.com/diamondburned/gotk4/pkg/pango" ) func ColumnViewAppendTextColumn[T any](colView *gtk.ColumnView, title string, extractor func(T) string) { @@ -16,6 +17,98 @@ func ColumnViewAppendTextColumn[T any](colView *gtk.ColumnView, title string, ex colView.AppendColumn(column) } +// ColumnViewAppendTextColumnEx appends a text column with control over the cell +// text alignment (xalign 0 = left, 1 = right), whether the column expands to +// fill remaining width, and an optional CSS class applied to the cell label. +func ColumnViewAppendTextColumnEx[T any](colView *gtk.ColumnView, title string, + xalign float32, expand bool, cssClass string, extractor func(T) string, +) { + factory := gtk.NewSignalListItemFactory() + factory.ConnectSetup(func(obj *glib.Object) { + cell := obj.Cast().(*gtk.ColumnViewCell) + label := gtk.NewLabel("") + label.SetHExpand(true) + label.SetXAlign(xalign) + if cssClass != "" { + label.AddCSSClass(cssClass) + } + cell.SetChild(label) + }) + factory.ConnectBind(func(obj *glib.Object) { + cell := obj.Cast().(*gtk.ColumnViewCell) + row := gioutil.ObjectValue[T](cell.Item()) + label := cell.Child().(*gtk.Label) + label.SetText(extractor(row)) + }) + + column := gtk.NewColumnViewColumn(title, &factory.ListItemFactory) + column.SetTitle(title) + column.SetExpand(expand) + column.SetResizable(true) + + colView.AppendColumn(column) +} + +// ColumnViewAppendEllipsizedColumn appends an expanding column whose long text +// is ellipsized at the end, with the full value available on hover. +func ColumnViewAppendEllipsizedColumn[T any](colView *gtk.ColumnView, title string, extractor func(T) string) { + factory := gtk.NewSignalListItemFactory() + factory.ConnectSetup(func(obj *glib.Object) { + cell := obj.Cast().(*gtk.ColumnViewCell) + label := gtk.NewLabel("") + label.SetHExpand(true) + label.SetXAlign(0) + label.SetEllipsize(pango.EllipsizeEnd) + cell.SetChild(label) + }) + factory.ConnectBind(func(obj *glib.Object) { + cell := obj.Cast().(*gtk.ColumnViewCell) + row := gioutil.ObjectValue[T](cell.Item()) + label := cell.Child().(*gtk.Label) + value := extractor(row) + label.SetText(value) + label.SetTooltipText(value) + }) + + column := gtk.NewColumnViewColumn(title, &factory.ListItemFactory) + column.SetTitle(title) + column.SetExpand(true) + column.SetResizable(true) + + colView.AppendColumn(column) +} + +// ColumnViewAppendAddressColumn appends an expanding column that middle- +// ellipsizes long identifiers such as addresses and shows the full value on +// hover, so the table never needs horizontal scrolling. +func ColumnViewAppendAddressColumn[T any](colView *gtk.ColumnView, title string, extractor func(T) string) { + factory := gtk.NewSignalListItemFactory() + factory.ConnectSetup(func(obj *glib.Object) { + cell := obj.Cast().(*gtk.ColumnViewCell) + label := gtk.NewLabel("") + label.SetHExpand(true) + label.SetXAlign(0) + label.SetEllipsize(pango.EllipsizeMiddle) + label.AddCSSClass("cell-mono") + cell.SetChild(label) + }) + factory.ConnectBind(func(obj *glib.Object) { + cell := obj.Cast().(*gtk.ColumnViewCell) + row := gioutil.ObjectValue[T](cell.Item()) + label := cell.Child().(*gtk.Label) + value := extractor(row) + label.SetText(value) + label.SetTooltipText(value) + }) + + column := gtk.NewColumnViewColumn(title, &factory.ListItemFactory) + column.SetTitle(title) + column.SetExpand(true) + column.SetResizable(true) + + colView.AppendColumn(column) +} + func ColumnViewCreateTextColumn[T any](title string, extractor func(T) string) *gtk.ColumnViewColumn { factory := gtk.NewSignalListItemFactory() factory.ConnectSetup(func(obj *glib.Object) { @@ -84,7 +177,8 @@ func ColumnViewGetSelectedItem[T any](colView *gtk.ColumnView, model *gioutil.Li } func ColumnViewSetDefaultProperties(colView *gtk.ColumnView) { - colView.SetShowRowSeparators(true) - colView.SetShowColumnSeparators(true) + colView.SetShowRowSeparators(false) + colView.SetShowColumnSeparators(false) colView.SetSingleClickActivate(true) + colView.AddCSSClass("data-table") } diff --git a/cmd/gtk/gtkutil/gtkutil.go b/cmd/gtk/gtkutil/gtkutil.go index 691d1ec3f..938fb17a1 100644 --- a/cmd/gtk/gtkutil/gtkutil.go +++ b/cmd/gtk/gtkutil/gtkutil.go @@ -20,7 +20,6 @@ import ( "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" - "github.com/pactus-project/pactus/cmd/gtk/assets" ) func ShowQuestionDialog(parent *gtk.Window, msg string, @@ -154,17 +153,58 @@ func BuildExtendedEntry(builder *gtk.Builder, overlayID string) *gtk.Entry { return entry } +// mainWindow is the top-level window used as the transient parent for dialogs +// so they open centered over the application instead of at the screen corner. +var mainWindow *gtk.Window + +// SetMainWindow registers the main window as the parent for dialog windows. +func SetMainWindow(win *gtk.Window) { + mainWindow = win +} + +// parentForDialog makes the dialog transient for the main window so it opens +// centered over it. It reports whether a parent was set; when it was not (for +// example dialogs shown during startup before the main window exists), the +// caller centers the window natively instead. +func parentForDialog(win *gtk.Window) bool { + if mainWindow != nil && mainWindow != win { + win.SetTransientFor(mainWindow) + + return true + } + + return false +} + +// centerParentlessWindow centers a window that has no transient parent once it +// has been mapped. This is a no-op on platforms other than Windows. +func centerParentlessWindow() { + glib.TimeoutAdd(80, func() bool { + CenterActiveWindow() + + return false + }) +} + func ShowNonModalWindow(win *gtk.Window) { IdleAddSync(func() { + hasParent := parentForDialog(win) win.SetModal(false) win.Present() + if !hasParent { + centerParentlessWindow() + } }) } func ShowModalWindow(win *gtk.Window) { IdleAddAsync(func() { + hasParent := parentForDialog(win) win.SetModal(true) win.Present() + if !hasParent { + centerParentlessWindow() + } }) } @@ -276,31 +316,30 @@ func GoroutineID() int64 { } func UpdateSendButton(button *gtk.Button) { - ExtendImageButton(button, "_Send", "Send this transaction", assets.IconSendTexture) + ExtendImageButton(button, "_Send", "Send this transaction", nil) + button.AddCSSClass("suggested-action") } func UpdateOKButton(window *gtk.Window, button *gtk.Button) { - ExtendImageButton(button, "_OK", "Perform this operation", assets.IconOkTexture) + ExtendImageButton(button, "_OK", "Perform this operation", nil) + button.AddCSSClass("suggested-action") window.SetDefaultWidget(button) } func UpdateCancelButton(button *gtk.Button) { - ExtendImageButton(button, "_Cancel", "Cancel this operation", assets.IconCancelTexture) + ExtendImageButton(button, "_Cancel", "Cancel this operation", nil) } func UpdateCloseButton(button *gtk.Button) { - ExtendImageButton(button, "_Close", "Close this window", assets.IconCloseTexture) + ExtendImageButton(button, "_Close", "Close this window", nil) } -func ExtendImageButton(btn *gtk.Button, text, tooltip string, texture *gdk.Texture) { - box := gtk.NewBox(gtk.OrientationHorizontal, 4) - pic := NewScaledPictureFromTexture(texture, 16, 16) - label := gtk.NewLabel(text) - label.SetUseUnderline(true) - - box.Append(pic) - box.Append(label) - btn.SetChild(box) +// ExtendImageButton sets a clean, text-only label on a dialog button. The +// texture argument is kept for backwards compatibility and ignored, as the +// dated icon glyphs were replaced by a flat, modern button style. +func ExtendImageButton(btn *gtk.Button, text, tooltip string, _ *gdk.Texture) { + btn.SetLabel(text) + btn.SetUseUnderline(true) btn.SetTooltipText(tooltip) } @@ -374,6 +413,29 @@ func IsWidgetShowing(widget *gtk.Widget) bool { return widget.Mapped() } +// DisableLabelSelection walks the widget tree and makes every label +// non-selectable. GtkAboutDialog otherwise auto-selects its program-name label +// when it grabs focus on open, which looks like highlighted text. +func DisableLabelSelection(root gtk.Widgetter) { + parent, ok := root.(interface{ FirstChild() gtk.Widgetter }) + if !ok { + return + } + + for child := parent.FirstChild(); child != nil; { + if label, isLabel := child.(*gtk.Label); isLabel { + label.SetSelectable(false) + } + DisableLabelSelection(child) + + sibling, hasSibling := child.(interface{ NextSibling() gtk.Widgetter }) + if !hasSibling { + break + } + child = sibling.NextSibling() + } +} + func ClearLable(label *gtk.Label) { label.SetText("") } diff --git a/cmd/gtk/gtkutil/settings.go b/cmd/gtk/gtkutil/settings.go new file mode 100644 index 000000000..dd360150b --- /dev/null +++ b/cmd/gtk/gtkutil/settings.go @@ -0,0 +1,55 @@ +//go:build gtk + +package gtkutil + +import ( + "os" + "path/filepath" + "strings" +) + +// themePrefPath returns the file that stores the user's light/dark choice, +// kept in the per-user config directory so it is shared across networks. +func themePrefPath() string { + dir, err := os.UserConfigDir() + if err != nil { + return "" + } + + return filepath.Join(dir, "pactus", "gui_dark_mode") +} + +// SaveDarkMode persists the user's dark-mode choice. +func SaveDarkMode(dark bool) { + path := themePrefPath() + if path == "" { + return + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + + value := "0" + if dark { + value = "1" + } + + _ = os.WriteFile(path, []byte(value), 0o644) +} + +// LoadDarkMode returns the persisted dark-mode choice. ok is false when the +// user has not made a choice yet, in which case the system theme is followed. +func LoadDarkMode() (dark, ok bool) { + path := themePrefPath() + if path == "" { + return false, false + } + + data, err := os.ReadFile(path) + if err != nil { + return false, false + } + + return strings.TrimSpace(string(data)) == "1", true +} diff --git a/cmd/gtk/locale_other.go b/cmd/gtk/locale_other.go new file mode 100644 index 000000000..d81414b21 --- /dev/null +++ b/cmd/gtk/locale_other.go @@ -0,0 +1,7 @@ +//go:build gtk && !windows + +package main + +// forceEnglishUILanguage is a no-op outside Windows, where the LANGUAGE +// environment variable already controls GTK's message catalogs. +func forceEnglishUILanguage() {} diff --git a/cmd/gtk/locale_windows.go b/cmd/gtk/locale_windows.go new file mode 100644 index 000000000..773c4830d --- /dev/null +++ b/cmd/gtk/locale_windows.go @@ -0,0 +1,16 @@ +//go:build gtk && windows + +package main + +import "golang.org/x/sys/windows" + +// forceEnglishUILanguage sets the thread UI language to English (en-US), so +// GTK and libadwaita load their English catalogs instead of following the +// Windows display language. On Windows GTK reads this rather than the LANGUAGE +// environment variable. +func forceEnglishUILanguage() { + const langEnUS = 0x0409 + + proc := windows.NewLazySystemDLL("kernel32.dll").NewProc("SetThreadUILanguage") + _, _, _ = proc.Call(uintptr(langEnUS)) +} diff --git a/cmd/gtk/main.go b/cmd/gtk/main.go index 5bd5052e5..fd293a80d 100644 --- a/cmd/gtk/main.go +++ b/cmd/gtk/main.go @@ -13,8 +13,10 @@ import ( "sync" "time" + adw "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gio/v2" + "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/gofrs/flock" "github.com/pactus-project/gopkg/signal" @@ -63,7 +65,21 @@ func init() { _ = os.Setenv("PANGOCAIRO_BACKEND", "fontconfig") } + // The GUI text is English only, so force GTK's built-in widget strings + // (assistant navigation, about dialog, file choosers ...) to English too + // instead of following the operating system locale. This affects message + // translation only, not number or date formatting. + // Use g_setenv, not os.Setenv: on Windows the latter uses the Win32 + // environment block, which the C runtime that GTK/libintl links against + // does not read, so the change would be invisible to gettext. + glib.Setenv("LANGUAGE", "en", true) + glib.Setenv("LC_MESSAGES", "en", true) + forceEnglishUILanguage() + gtk.Init() + // Initialize libadwaita so its StyleManager controls the light/dark + // appearance, independent of the operating system theme. + adw.Init() } //nolint:gocognit // needs refactoring @@ -79,8 +95,6 @@ func main() { // Create a new app. app := gtk.NewApplication(appID, gio.ApplicationNonUnique) gtk.WidgetSetDefaultDirection(gtk.TextDirLTR) - settings := gtk.SettingsGetDefault() - settings.Object.SetObjectProperty("gtk-application-prefer-dark-theme", true) // apply custom css provider := gtk.NewCSSProvider() diff --git a/cmd/gtk/view/about_dialog_view.go b/cmd/gtk/view/about_dialog_view.go index 4b6853b0c..44d651e64 100644 --- a/cmd/gtk/view/about_dialog_view.go +++ b/cmd/gtk/view/about_dialog_view.go @@ -15,5 +15,7 @@ func NewAboutDialog() *gtk.AboutDialog { pic := gtkutil.NewScaledPictureFromTexture(assets.ImagePactusLogoTexture, 128, 128) dlg.SetLogo(pic.Paintable()) + gtkutil.DisableLabelSelection(dlg) + return dlg } diff --git a/cmd/gtk/view/about_gtk_dialog_view.go b/cmd/gtk/view/about_gtk_dialog_view.go index 81ed0f7f4..c2cf81f52 100644 --- a/cmd/gtk/view/about_gtk_dialog_view.go +++ b/cmd/gtk/view/about_gtk_dialog_view.go @@ -15,5 +15,7 @@ func NewAboutGTKDialog() *gtk.AboutDialog { pic := gtkutil.NewScaledPictureFromTexture(assets.ImageGTKLogoTexture, 128, 128) dlg.SetLogo(pic.Paintable()) + gtkutil.DisableLabelSelection(dlg) + return dlg } diff --git a/cmd/gtk/view/circular_progress.go b/cmd/gtk/view/circular_progress.go new file mode 100644 index 000000000..0f97bdbc7 --- /dev/null +++ b/cmd/gtk/view/circular_progress.go @@ -0,0 +1,92 @@ +//go:build gtk + +package view + +import ( + "fmt" + "math" + + "github.com/diamondburned/gotk4/pkg/cairo" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +// Pactus accent green (#00a085), matching the CSS accent color. +const ( + accentRed = 0x00 / 255.0 + accentGreen = 0xa0 / 255.0 + accentBlue = 0x85 / 255.0 +) + +// CircularProgress is a ring gauge that fills clockwise with the sync progress +// and shows the percentage in its center. The track color follows the current +// theme so it reads well in both light and dark mode. +type CircularProgress struct { + *gtk.Overlay + + area *gtk.DrawingArea + label *gtk.Label + fraction float64 +} + +// NewCircularProgress creates a ring gauge with the given square size in pixels. +func NewCircularProgress(size int) *CircularProgress { + area := gtk.NewDrawingArea() + area.SetContentWidth(size) + area.SetContentHeight(size) + + label := gtk.NewLabel("0%") + label.SetHAlign(gtk.AlignCenter) + label.SetVAlign(gtk.AlignCenter) + label.AddCSSClass("circular-progress-label") + + overlay := gtk.NewOverlay() + overlay.SetChild(area) + overlay.AddOverlay(label) + + progress := &CircularProgress{ + Overlay: overlay, + area: area, + label: label, + } + + area.SetDrawFunc(func(_ *gtk.DrawingArea, cr *cairo.Context, width, height int) { + progress.draw(cr, width, height) + }) + + return progress +} + +// SetFraction sets the progress in the range [0, 1] and refreshes the ring. +func (cp *CircularProgress) SetFraction(fraction float64) { + fraction = math.Max(0, math.Min(1, fraction)) + cp.fraction = fraction + cp.label.SetText(fmt.Sprintf("%.0f%%", fraction*100)) + cp.area.QueueDraw() +} + +func (cp *CircularProgress) draw(cr *cairo.Context, width, height int) { + w := float64(width) + h := float64(height) + lineWidth := math.Max(6, math.Min(w, h)*0.09) + radius := math.Min(w, h)/2 - lineWidth + centerX := w / 2 + centerY := h / 2 + + cr.SetLineWidth(lineWidth) + cr.SetLineCap(cairo.LineCapRound) + + // Track: widget foreground color at low opacity, so it adapts to the theme. + fg := cp.area.Color() + cr.SetSourceRGBA(float64(fg.Red()), float64(fg.Green()), float64(fg.Blue()), 0.15) + cr.Arc(centerX, centerY, radius, 0, 2*math.Pi) + cr.Stroke() + + // Progress arc, clockwise starting from the top (-90 degrees). + if cp.fraction > 0 { + start := -math.Pi / 2 + end := start + 2*math.Pi*cp.fraction + cr.SetSourceRGBA(accentRed, accentGreen, accentBlue, 1) + cr.Arc(centerX, centerY, radius, start, end) + cr.Stroke() + } +} diff --git a/cmd/gtk/view/main_window_view.go b/cmd/gtk/view/main_window_view.go index 6c4ee58eb..74f87ea96 100644 --- a/cmd/gtk/view/main_window_view.go +++ b/cmd/gtk/view/main_window_view.go @@ -19,6 +19,9 @@ type MainWindowView struct { BoxCommittee *gtk.Box BoxNetwork *gtk.Box + SidebarList *gtk.ListBox + Stack *gtk.Stack + // HideOnClose controls the window close behavior. // When true, the window is hidden instead of destroyed on close-request. HideOnClose bool @@ -35,9 +38,27 @@ func NewMainWindowView() *MainWindowView { BoxValidators: builder.GetBoxObj("id_box_validators"), BoxCommittee: builder.GetBoxObj("id_box_committee"), BoxNetwork: builder.GetBoxObj("id_box_network"), + SidebarList: GetObj[*gtk.ListBox](builder.builder, "id_sidebar_list"), + Stack: GetObj[*gtk.Stack](builder.builder, "id_main_stack"), HideOnClose: true, } + // Assign the bundled sidebar icons. + GetObj[*gtk.Image](builder.builder, "id_icon_overview").SetFromPaintable(assets.IconNavOverviewTexture) + GetObj[*gtk.Image](builder.builder, "id_icon_committee").SetFromPaintable(assets.IconNavCommitteeTexture) + GetObj[*gtk.Image](builder.builder, "id_icon_network").SetFromPaintable(assets.IconNavNetworkTexture) + GetObj[*gtk.Image](builder.builder, "id_icon_validators").SetFromPaintable(assets.IconNavValidatorsTexture) + GetObj[*gtk.Image](builder.builder, "id_icon_wallet").SetFromPaintable(assets.IconNavWalletTexture) + + // Switch the visible page when a sidebar row is selected, then preselect + // the first entry (Node Overview). + view.SidebarList.ConnectRowSelected(func(row *gtk.ListBoxRow) { + if row != nil { + view.Stack.SetVisibleChildName(row.Name()) + } + }) + view.SidebarList.SelectRow(view.SidebarList.RowAtIndex(0)) + // Intercept the close-request signal to hide instead of destroy. view.Window.ConnectCloseRequest(func() (ok bool) { if view.HideOnClose { diff --git a/cmd/gtk/view/node_widget_view.go b/cmd/gtk/view/node_widget_view.go index e16ab11b0..8f97521d3 100644 --- a/cmd/gtk/view/node_widget_view.go +++ b/cmd/gtk/view/node_widget_view.go @@ -24,7 +24,8 @@ type NodeWidgetView struct { LabelLastBlockTime *gtk.Label LabelLastBlockHeight *gtk.Label LabelBlocksLeft *gtk.Label - ProgressBarSynced *gtk.ProgressBar + ProgressSynced *CircularProgress + LabelSyncStatus *gtk.Label LabelActiveValidator *gtk.Label LabelInCommittee *gtk.Label LabelTotalPower *gtk.Label @@ -51,14 +52,17 @@ func NewNodeWidgetView() *NodeWidgetView { LabelLastBlockTime: builder.GetLabelObj("id_label_last_block_time"), LabelLastBlockHeight: builder.GetLabelObj("id_label_last_block_height"), LabelBlocksLeft: builder.GetLabelObj("id_label_blocks_left"), - ProgressBarSynced: builder.GetProgressBarObj("id_progress_synced"), LabelActiveValidator: builder.GetLabelObj("id_label_active_validators"), LabelInCommittee: builder.GetLabelObj("id_label_in_committee"), LabelTotalPower: builder.GetLabelObj("id_label_total_power"), LabelAverageScore: builder.GetLabelObj("id_label_average_score"), LabelNumConnections: builder.GetLabelObj("id_label_num_connections"), LabelReachability: builder.GetLabelObj("id_label_reachability"), + LabelSyncStatus: builder.GetLabelObj("id_label_sync_status"), } + view.ProgressSynced = NewCircularProgress(128) + builder.GetBoxObj("id_sync_container").Append(view.ProgressSynced) + return view } diff --git a/cmd/gtk/view/splash_window_view.go b/cmd/gtk/view/splash_window_view.go index b171676b6..b60c2fa6f 100644 --- a/cmd/gtk/view/splash_window_view.go +++ b/cmd/gtk/view/splash_window_view.go @@ -3,6 +3,7 @@ package view import ( + "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/pactus-project/pactus/cmd/gtk/assets" "github.com/pactus-project/pactus/cmd/gtk/gtkutil" @@ -66,6 +67,14 @@ func NewSplashWindow(app *gtk.Application) *SplashWindow { func (s *SplashWindow) ShowAll() { s.window.Present() s.spinner.Start() + + // The splash has no parent to center over and GTK4 cannot move a window, + // so center it natively once it has been mapped. + glib.TimeoutAdd(80, func() bool { + gtkutil.CenterActiveWindow() + + return false + }) } func (s *SplashWindow) Destroy() { diff --git a/go.mod b/go.mod index e6345db72..d93622812 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/c-bata/go-prompt v0.2.6 github.com/consensys/gnark-crypto v0.20.1 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 - github.com/diamondburned/gotk4/pkg v0.3.1 + github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a github.com/fxamacker/cbor/v2 v2.9.2 github.com/glebarez/go-sqlite v1.22.1-0.20250214171204-e6de9fc0c320 github.com/go-zeromq/zmq4 v0.17.0 @@ -69,6 +69,7 @@ require ( github.com/creachadair/mds v0.29.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect + github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6 // indirect github.com/dunglas/httpsfv v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect diff --git a/go.sum b/go.sum index 2056c682a..5e24eb675 100644 --- a/go.sum +++ b/go.sum @@ -55,8 +55,12 @@ github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6 h1:WzOC3KtvrC1hJMz3fbJBg0Ye50nt4Tafor+a/bBHNEA= +github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6/go.mod h1:ZzYiyPe0TqsukfPHi0sK/WwKzm0wIJdSRylLnuvAZNw= github.com/diamondburned/gotk4/pkg v0.3.1 h1:uhkXSUPUsCyz3yujdvl7DSN8jiLS2BgNTQE95hk6ygg= github.com/diamondburned/gotk4/pkg v0.3.1/go.mod h1:DqeOW+MxSZFg9OO+esk4JgQk0TiUJJUBfMltKhG+ub4= +github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a h1:dN2jYYZ71hFhoKFSn24pQdKWLZb/XDydBt8pEIkFjJo= +github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a/go.mod h1:O9K8+PGNFGJpAu8+u5D2Sn5Wae4hxWzHB+AeZNbV/2Q= github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=