Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .github/assets/ui/gui_session_editor_C.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,13 @@ func (ac *AppConfig) validate() error {
return fmt.Errorf(errFormatRev, errInvalidLogLevel, ac.LogLevel)
}

// SessionTitle must not exceed 200 characters and must not contain <, &, or "
if len(ac.SessionTitle) > 200 {
return fmt.Errorf(errFormatRev, errInvalidSessionTitle, ac.SessionTitle)
return fmt.Errorf(errFormatRev, errInvalidSessionTitle, "session title exceeds 200 characters")
}

if strings.ContainsAny(ac.SessionTitle, "<&\"") {
return fmt.Errorf(errFormatRev, errInvalidSessionTitle, "session title contains illegal characters (<, &, or \")")
}

return nil
Expand Down
4 changes: 2 additions & 2 deletions internal/config/config.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# BLE Sync Cycle Configuration
# v0.61.0
# v0.62.0

[app]
session_title = "Session Title" # Short description of the current cycling session (0-200 characters)
session_title = "Session Title" # Short description of the current cycling session (0-200 characters, excluding ", &, and <)
logging_level = "info" # Log messages generated during execution ("debug", "info", "warn", "error")

[ble]
Expand Down
4 changes: 2 additions & 2 deletions internal/config/config_test.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# BLE Sync Cycle Configuration
# v0.61.0
# v0.62.0

[app]
session_title = "Session Title" # Short description of the current cycling session (0-200 characters)
session_title = "Session Title" # Short description of the current cycling session (0-200 characters, excluding ", &, and <)
logging_level = "info" # Log messages generated during execution ("debug", "info", "warn", "error")

[ble]
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const ConfigTemplate = `# BLE Sync Cycle Configuration (TOML)
# {{.Version}}

[app]
session_title = "{{.App.SessionTitle}}"{{pad (printf "session_title = \"%s\"" .App.SessionTitle)}}# Short description of the current cycling session (0-200 characters)
session_title = "{{.App.SessionTitle}}"{{pad (printf "session_title = \"%s\"" .App.SessionTitle)}}# Short description of the current cycling session (0-200 characters, excluding ", &, and <)
logging_level = "{{.App.LogLevel}}"{{pad (printf "logging_level = \"%s\"" .App.LogLevel)}}# Log messages generated during execution ("debug", "info", "warn", "error")

[ble]
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package config
// Application name and version information
const (
appName = "BLE Sync Cycle"
appVersion = "v0.61.0"
appVersion = "v0.62.0"
)

// GetVersion returns the current application version
Expand Down
2 changes: 1 addition & 1 deletion internal/video/video_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ func TestUpdateDisplay(t *testing.T) {
t.Fatalf("updateDisplay failed: %v", err)
}

if mockPlayer.lastShowText != "Paused" {
if mockPlayer.lastShowText != "Cycle Speed: 0.0 mph\nPlayback Speed: 0.00x\nTime Remaining: 00:00:00\nPAUSED" {
t.Errorf("expected OSD text 'Paused', got %q", mockPlayer.lastShowText)
}

Expand Down
12 changes: 11 additions & 1 deletion ui/assets/bsc_gui.ui
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@
<property name="show-apply-button">True</property>
<property name="text">n/a</property>
<property name="title" translatable="yes">Session Title</property>
<property name="tooltip-text">Short description of the current cycling session (0-200 characters)</property>
<property name="tooltip-text">Short description of the current cycling session (0-200 characters, excluding &quot;, &amp;, and &lt;)</property>
<property name="sensitive">False</property>
</object>
</child>
Expand Down Expand Up @@ -821,6 +821,16 @@
<property name="orientation">horizontal</property>
<property name="spacing">12</property>
<property name="sensitive">True</property>
<child>
<object class="GtkButton" id="delete_session_button">
<property name="sensitive">False</property>
<property name="label" translatable="yes">Delete</property>
<style>
<class name="destructive-action" />
<class name="pill" />
</style>
</object>
</child>
<child>
<object class="GtkButton" id="save_as_button">
<property name="sensitive">False</property>
Expand Down
5 changes: 4 additions & 1 deletion ui/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ type PageSessionEditor struct {
AlignX *adw.ComboRow
AlignY *adw.ComboRow

// Save Actions
// Save/Delete Actions
SaveRow *adw.ActionRow
DeleteButton *gtk.Button
SaveButton *gtk.Button
SaveAsButton *gtk.Button
}
Expand Down Expand Up @@ -280,6 +281,7 @@ func hydrateSessionEditor(builder *gtk.Builder) *PageSessionEditor {
AlignX: objGTK[*adw.ComboRow](builder, "align_x_combo"),
AlignY: objGTK[*adw.ComboRow](builder, "align_y_combo"),
SaveRow: objGTK[*adw.ActionRow](builder, "edit_save_row"),
DeleteButton: objGTK[*gtk.Button](builder, "delete_session_button"),
SaveButton: objGTK[*gtk.Button](builder, "save_button"),
SaveAsButton: objGTK[*gtk.Button](builder, "save_as_button"),
}
Expand All @@ -295,6 +297,7 @@ func setupAllSignals(sc *SessionController) {
logger.Debug(logger.BackgroundCtx, logger.GUI, "view switched to Session Select: refreshing session list...")
sc.scanForSessions()
sc.PopulateSessionList()
sc.CheckForNoSessions()
},

"page2": func() {
Expand Down
108 changes: 106 additions & 2 deletions ui/ui_session_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"slices"
"strings"

"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"github.com/richbl/go-ble-sync-cycle/internal/config"
Expand Down Expand Up @@ -50,7 +51,8 @@ func (sc *SessionController) setupSessionEditSignals() {
sc.updateSaveButtonState()
}

// Define widget validators for BD_ADDR and video seek/start time
// Define widget validators for Session Title, BD_ADDR, and video seek/start time
bindValidator(sc.UI.Page4.TitleEntry, `^[^<&\"]{1,200}$`, updateSaveButtons)
bindValidator(sc.UI.Page4.BTAddressEntry, `^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$`, updateSaveButtons)
bindValidator(sc.UI.Page4.StartTimeEntry, `^\d{2}:[0-5]\d:[0-5]\d$`, updateSaveButtons)

Expand All @@ -70,22 +72,32 @@ func (sc *SessionController) setupSessionEditSignals() {
sc.saveSession(true) // Save As new path
})

// Delete button
sc.UI.Page4.DeleteButton.ConnectClicked(func() {
sc.deleteSession()
})

}

// updateSaveButtonState checks the validity of fields and toggles the Save buttons
func (sc *SessionController) updateSaveButtonState() {

titleEntry := sc.UI.Page4.TitleEntry
bdAddrEntry := sc.UI.Page4.BTAddressEntry
timeEntry := sc.UI.Page4.StartTimeEntry

isTitleValid := titleEntry.Text() != "" && !titleEntry.HasCSSClass("error")
isBDAddrValid := bdAddrEntry.Text() != "" && !bdAddrEntry.HasCSSClass("error")
isTimeValid := timeEntry.Text() != "" && !timeEntry.HasCSSClass("error")

canSave := isBDAddrValid && isTimeValid
canSave := isTitleValid && isBDAddrValid && isTimeValid

sc.UI.Page4.SaveButton.SetSensitive(canSave)
sc.UI.Page4.SaveAsButton.SetSensitive(canSave)

// Delete is only allowed if we have a file path to delete
sc.UI.Page4.DeleteButton.SetSensitive(sc.SessionManager.EditConfigPath() != "")

}

// loadAndNavigateToEditor handles loading the session config and switching the view
Expand Down Expand Up @@ -158,15 +170,25 @@ func (sc *SessionController) populateEditor() {
// Enable all widgets
toggleSensitive(p4, true)

// Refresh button states (Save, Delete)
sc.updateSaveButtonState()

}

// toggleSensitive enables or disables widgets
func toggleSensitive(p4 *PageSessionEditor, enabled bool) {

// Use reflection to iterate through the widgets and set their sensitivity
v := reflect.ValueOf(p4).Elem()
t := v.Type()

for i := range v.NumField() {

// Skip ScrolledWindow to ensure the page remains scrollable even when widgets are disabled
if t.Field(i).Name == "ScrolledWindow" {
continue
}

field := v.Field(i)
if field.CanInterface() {
widget, ok := field.Interface().(interface{ SetSensitive(enabled bool) })
Expand Down Expand Up @@ -427,6 +449,88 @@ func (sc *SessionController) handleLoadedSessionUpdate(path string, cfg *config.

}

// performDelete contains the core logic for deleting a session file and updating the UI
func (sc *SessionController) performDelete(path, title, loadedPath string) {

logger.Debug(logger.BackgroundCtx, logger.GUI, "attempting to delete session file: "+path)

if err := os.Remove(path); err != nil {
logger.Error(logger.BackgroundCtx, logger.GUI, fmt.Sprintf("failed to delete session file: %v", err))
safeUpdateUI(func() {
displayAlertDialog(sc.UI.Window, "BSC Session Delete Error", fmt.Sprintf("The file %s could not be deleted.\n\nPlease review the BSC Session Log for details.", path))
})

return
}

logger.Info(logger.BackgroundCtx, logger.GUI, fmt.Sprintf("session file '%s' deleted from: %s", title, path))

isLoadedSession := (path == loadedPath)
sc.SessionManager.Reset()

safeUpdateUI(func() {
sc.resetEditorAfterDelete()

if isLoadedSession {
sc.clearPage2()
}

sc.scanForSessions()
sc.PopulateSessionList()

displayAlertDialog(sc.UI.Window, "BSC Session Deleted", fmt.Sprintf("'%s' has been deleted.", title))
})

}

// resetEditorAfterDelete clears and disables the editor UI after a deletion
func (sc *SessionController) resetEditorAfterDelete() {

p4 := sc.UI.Page4

// Clear text fields
p4.TitleEntry.SetText("")
p4.BTAddressEntry.SetText("")
p4.StartTimeEntry.SetText("")
p4.VideoFileRow.SetSubtitle("/")

// Disable all widgets
toggleSensitive(p4, false)

}

// deleteSession initiates the session deletion process
func (sc *SessionController) deleteSession() {

path := sc.SessionManager.EditConfigPath()
if path == "" {
return
}

loadedPath := sc.SessionManager.LoadedConfigPath()
if path == loadedPath && sc.SessionManager.SessionState() > session.StateLoaded {
displayAlertDialog(sc.UI.Window, "Active BSC Session Error", "The BSC session file you are attempting to delete is currently running.\n\nYou must first stop the current session, and then delete the session.")

return
}

title := "Unknown"
if cfg := sc.SessionManager.Config(); cfg != nil {
title = cfg.App.SessionTitle
}

displayConfirmationDialog(
sc.UI.Window,
"Delete BSC Session?",
fmt.Sprintf("Are you sure you want to delete '%s'?\n\nThis action cannot be undone.", title),
adw.ResponseDestructive,
func() {
sc.performDelete(path, title, loadedPath)
},
)

}

// convertSessionTitle converts a session title into a string for use as a filename
func convertSessionTitle(sessionTitle string) string {

Expand Down
43 changes: 22 additions & 21 deletions ui/ui_session_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,27 @@ func (sc *SessionController) PopulateSessionList() {

}

// CheckForNoSessions checks if any session files exist and prompts to create one if not
func (sc *SessionController) CheckForNoSessions() {

if len(sc.Sessions) == 0 {
logger.Debug(logger.BackgroundCtx, logger.GUI, "no session configuration files found, prompting to create new session...")

safeUpdateUI(func() {
displayConfirmationDialog(
sc.UI.Window,
"No BSC Sessions",
"No Configuration files found in the BSC configuration directory.\n\nDo you want to create a new BSC session file?",
adw.ResponseSuggested,
func() {
sc.createNewDefaultSession()
},
)
})
}

}

// setupSessionSelectSignals wires up event listeners for the session selection tab (Page 1)
func (sc *SessionController) setupSessionSelectSignals() {

Expand Down Expand Up @@ -132,23 +153,6 @@ func (sc *SessionController) scanForSessions() {

logger.Debug(logger.BackgroundCtx, logger.GUI, fmt.Sprintf("session scan complete: found %d valid session(s)", len(sc.Sessions)))

// Check if any files were actually found
if len(sc.Sessions) == 0 {
logger.Debug(logger.BackgroundCtx, logger.GUI, "no session configuration files found")

safeUpdateUI(func() {
displayConfirmationDialog(
sc.UI.Window,
"No BSC Sessions",
"No Configuration files found in the BSC configuration directory.\n\nDo you want to create a new BSC session file?",
adw.ResponseSuggested,
func() {
sc.createNewDefaultSession()
},
)
})
}

}

// createNewDefaultSession creates a default configuration file, a placeholder video, and refreshes the list
Expand Down Expand Up @@ -203,17 +207,14 @@ func (sc *SessionController) createNewDefaultSession() {
sc.PopulateSessionList()
})

// Navigate to the newly created session for editing
sc.loadAndNavigateToEditor(sc.Sessions[0])

}

// createDefaultConfig returns a Config struct populated with default values
func createDefaultConfig(videoPath string) *config.Config {

return &config.Config{
App: config.AppConfig{
SessionTitle: "New Session",
SessionTitle: "New BSC Session",
LogLevel: "info",
},
BLE: config.BLEConfig{
Expand Down
21 changes: 21 additions & 0 deletions ui/ui_session_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,27 @@ func (sc *SessionController) resetMetrics() {

}

// clearPage2 resets the Page 2 UI elements to their default (no session) state
func (sc *SessionController) clearPage2() {

// Reset labels and icons
sc.UI.Page2.SessionNameRow.SetSubtitle("n/a")
sc.UI.Page2.SessionFileLocationRow.SetSubtitle("n/a")
sc.updatePage2Status(StatusNotConnected, StatusNotConnected, StatusUnknown)
sc.resetMetrics()

// Disable all rows
sc.UI.Page2.SessionNameRow.SetSensitive(false)
sc.UI.Page2.SessionFileLocationRow.SetSensitive(false)
sc.UI.Page2.SensorStatusRow.SetSensitive(false)
sc.UI.Page2.SensorBatteryRow.SetSensitive(false)
sc.UI.Page2.SpeedRow.SetSensitive(false)
sc.UI.Page2.PlaybackSpeedRow.SetSensitive(false)
sc.UI.Page2.TimeRemainingRow.SetSensitive(false)
sc.UI.Page2.SessionControlRow.SetSensitive(false)

}

// updatePage2Status updates the BLE and Battery status indicators on Page 2
func (sc *SessionController) updatePage2Status(bleStatus Status, batteryStatus Status, batteryLevel string) {

Expand Down
1 change: 1 addition & 0 deletions ui/ui_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func setupGUIApplication(app *gtk.Application, shutdownMgr *services.ShutdownMan
sessionCtrl := NewSessionController(ui, shutdownMgr)
sessionCtrl.scanForSessions()
sessionCtrl.PopulateSessionList()
sessionCtrl.CheckForNoSessions()

setupAllSignals(sessionCtrl)
ui.Window.SetApplication(app)
Expand Down
Loading
Loading