#!/usr/bin/python3
"""
Yafti GTK - A simple GTK GUI for running scripts from yafti.yml
"""
import subprocess
import sys
import threading
import gi
import yaml
gi.require_version('Gtk', '3.0')
from gi.repository import GLib, Gtk
# Constants
APP_ID = 'io.github.ublue_os.yafti_gtk'
APP_TITLE = 'Bazzite Portal'
DEFAULT_WINDOW_WIDTH = 800
DEFAULT_WINDOW_HEIGHT = 600
STATUS_TIMEOUT_SECONDS = 3
ACTION_DIALOG_WIDTH = 420
def set_widget_margins(widget, top=10, bottom=10, start=10, end=10):
"""Apply consistent margins to a widget."""
widget.set_margin_top(top)
widget.set_margin_bottom(bottom)
widget.set_margin_start(start)
widget.set_margin_end(end)
def clear_container(container):
"""Remove all children from a container widget."""
for child in container.get_children():
container.remove(child)
def show_error_dialog(parent, title, message):
"""Display an error dialog with the given title and message."""
dialog = Gtk.MessageDialog(
transient_for=parent,
flags=0,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text=title
)
dialog.format_secondary_text(message)
dialog.run()
dialog.destroy()
def initialize_gtk():
"""Initialize GTK and application metadata."""
GLib.set_prgname(APP_ID)
Gtk.init([])
try:
Gtk.Window.set_default_icon_name(APP_ID)
except Exception as e:
print(f"Warning: Could not set app icon: {e}")
def build_terminal_command(script):
"""Return the default terminal launcher command."""
return [
"xdg-terminal-exec",
f"--app-id={APP_ID}",
f"--title={APP_TITLE}",
"--",
"bash",
"--noprofile",
"--norc",
"-lc",
script,
]
def build_headless_command(script):
"""Return the non-interactive command used for status checks."""
return [
"bash",
"--noprofile",
"--norc",
"-lc",
script,
]
def escape_markup(text):
"""Escape text before using it in a GTK markup label."""
return GLib.markup_escape_text(text or "")
class YaftiGTK(Gtk.Window):
def __init__(self, config_file='yafti.yml'):
super().__init__(title=APP_TITLE)
self.set_default_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
self.set_border_width(10)
self.active_dialog_state = None
# Load YAML configuration
self.config = self.load_config(config_file)
self.screens = self.config.get('screens', [])
self.actions_index = self._build_actions_index()
# Create main container
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.add(vbox)
# Search bar at the top
search_entry = Gtk.SearchEntry()
search_entry.set_placeholder_text("Search Apps and Actions")
set_widget_margins(search_entry, 4, 4, 4, 4)
search_entry.connect("search-changed", self.on_search_changed)
vbox.pack_start(search_entry, False, False, 0)
# Notebook (tabs) directly below search
self.notebook = Gtk.Notebook()
self.notebook.set_scrollable(True)
# Add tabs for each screen from YAML
for screen in self.screens:
page = self.create_screen_page(screen)
label = Gtk.Label(label=screen.get('title', 'Tab'))
self.notebook.append_page(page, label)
# Stack to switch between notebook and search results
self.content_stack = Gtk.Stack()
self.content_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
self.content_stack.set_transition_duration(150)
# Add notebook to stack
self.content_stack.add_named(self.notebook, "tabs")
# Search results page
search_scrolled = Gtk.ScrolledWindow()
search_scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
results_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
set_widget_margins(results_box, 10, 10, 10, 10)
self.search_results_box = results_box
search_scrolled.add(results_box)
self.content_stack.add_named(search_scrolled, "search")
# Start with tabs visible
self.content_stack.set_visible_child_name("tabs")
vbox.pack_start(self.content_stack, True, True, 0)
self.connect("focus-in-event", self.on_window_focus_in)
def load_config(self, config_file):
"""Load and parse the YAML configuration file."""
try:
with open(config_file, 'r') as f:
return yaml.safe_load(f) or {}
except FileNotFoundError:
show_error_dialog(
self,
"Configuration file not found",
f"Could not find {config_file} in the current directory."
)
sys.exit(1)
except yaml.YAMLError as e:
show_error_dialog(self, "YAML parsing error", str(e))
sys.exit(1)
def create_screen_page(self, screen):
"""Create a page for a screen with all its actions."""
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
page_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
set_widget_margins(page_box, 10, 10, 10, 10)
for action in screen.get('actions', []):
action_box = self.create_action_item(action)
page_box.pack_start(action_box, False, False, 0)
scrolled.add(page_box)
return scrolled
def create_action_item(self, action):
"""Create a clickable action item."""
button = Gtk.Button()
button.set_relief(Gtk.ReliefStyle.NONE)
button.set_hexpand(True)
button.set_halign(Gtk.Align.FILL)
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
set_widget_margins(button_box, 8, 8, 8, 8)
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
title_label = Gtk.Label()
title_label.set_markup(f"{escape_markup(action.get('title', 'Action'))}")
title_label.set_xalign(0)
text_box.pack_start(title_label, False, False, 0)
if action.get('description'):
desc_label = Gtk.Label(label=action['description'])
desc_label.set_xalign(0)
desc_label.set_line_wrap(True)
desc_label.set_max_width_chars(60)
desc_label.get_style_context().add_class('dim-label')
text_box.pack_start(desc_label, False, False, 0)
button_box.pack_start(text_box, True, True, 0)
button.add(button_box)
button.connect("clicked", self.on_action_clicked, action)
frame = Gtk.Frame()
frame.set_shadow_type(Gtk.ShadowType.IN)
frame.add(button)
return frame
def _build_actions_index(self):
"""Flatten actions for search lookup."""
index = []
for screen in self.screens or []:
for action in screen.get('actions', []):
index.append({'action': action})
return index
def get_action_options(self, action):
"""Return explicit modal options from the config."""
options = action.get('options')
if isinstance(options, list) and options:
return options
return []
def action_uses_modal(self, action):
"""Return True when the action should open the management modal."""
if self.get_action_options(action):
return True
return bool((action.get('status_script') or "").strip())
def on_search_changed(self, entry):
query = entry.get_text().strip()
if not query:
clear_container(self.search_results_box)
self.content_stack.set_visible_child_name("tabs")
return
lowered = query.lower()
matches = []
for item in self.actions_index:
action = item['action']
title = action.get('title', '')
desc = action.get('description', '')
if lowered in title.lower() or lowered in desc.lower():
matches.append(item)
clear_container(self.search_results_box)
header = Gtk.Label()
header.set_markup("Search results")
header.set_xalign(0)
self.search_results_box.pack_start(header, False, False, 0)
if matches:
for item in matches:
self.search_results_box.pack_start(
self.create_action_item(item['action']), False, False, 0
)
else:
empty = Gtk.Label(label="No matches found")
empty.set_xalign(0)
self.search_results_box.pack_start(empty, False, False, 0)
self.search_results_box.show_all()
self.content_stack.set_visible_child_name("search")
def on_action_clicked(self, _button, action):
"""Open a management modal or run the action directly."""
if not self.action_uses_modal(action):
script = (action.get('script') or "").strip()
if not script:
return
error_message = self.launch_terminal(script)
if error_message is None:
return
show_error_dialog(
self,
"No terminal available",
"Could not open a terminal automatically.\n\n"
+ error_message
+ "\n\nYou can also run the following command manually:\n\n"
+ script
)
return
dialog = Gtk.Dialog(title=action.get('title', 'Action'), transient_for=self, flags=0)
dialog.set_modal(True)
dialog.set_destroy_with_parent(True)
dialog.set_default_size(ACTION_DIALOG_WIDTH, -1)
dialog.set_resizable(False)
state = {
'action': action,
'dialog': dialog,
'dirty': False,
'loading': False,
'closed': False,
'request_id': 0,
'status_token': None,
'status_timed_out': False,
}
self.active_dialog_state = state
dialog.connect("destroy", self.on_dialog_destroy, state)
dialog.connect("focus-in-event", self.on_dialog_focus_in, state)
if (action.get('status_script') or "").strip():
self.refresh_action_dialog(state)
else:
self.build_action_dialog_content(state, None)
def on_dialog_destroy(self, _dialog, state):
"""Clear the active dialog reference when the modal closes."""
state['closed'] = True
if self.active_dialog_state is state:
self.active_dialog_state = None
def on_window_focus_in(self, _widget, _event):
"""Refresh the active dialog on focus return when needed."""
state = self.active_dialog_state
if self.should_refresh_dialog(state):
self.refresh_action_dialog(state)
return False
def on_dialog_focus_in(self, _dialog, _event, state):
"""Refresh the focused dialog after a launched action when needed."""
if self.should_refresh_dialog(state):
self.refresh_action_dialog(state)
return False
def should_refresh_dialog(self, state):
"""Return True when a dialog should refresh its status on focus return."""
if not state or state.get('closed'):
return False
if self.active_dialog_state is not state:
return False
if state.get('loading'):
return False
return state.get('dirty', False)
def refresh_action_dialog(self, state):
"""Show the loading state and rerun the dialog status check."""
if not state or state.get('closed'):
return
action = state['action']
status_script = (action.get('status_script') or "").strip()
if not status_script:
self.build_action_dialog_content(state, None)
return
state['dirty'] = False
state['request_id'] += 1
request_id = state['request_id']
self.build_action_dialog_loading(state)
thread = threading.Thread(
target=self.run_status_check,
args=(state, request_id, status_script),
daemon=True,
)
thread.start()
def build_action_dialog_loading(self, state):
"""Render the loading-only modal view."""
dialog = state['dialog']
content_area = dialog.get_content_area()
clear_container(content_area)
state['loading'] = True
loading_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
loading_box.set_halign(Gtk.Align.CENTER)
loading_box.set_valign(Gtk.Align.CENTER)
set_widget_margins(loading_box, 24, 24, 24, 24)
spinner = Gtk.Spinner()
spinner.start()
loading_box.pack_start(spinner, False, False, 0)
label = Gtk.Label(label="Loading...")
loading_box.pack_start(label, False, False, 0)
content_area.pack_start(loading_box, False, False, 0)
dialog.show_all()
def run_status_check(self, state, request_id, status_script):
"""Run the modal status check in the background."""
status_token = "unknown"
status_timed_out = False
try:
result = subprocess.run(
build_headless_command(status_script),
capture_output=True,
text=True,
timeout=STATUS_TIMEOUT_SECONDS,
check=False,
)
if result.returncode == 0:
for line in result.stdout.splitlines():
token = line.strip()
if token:
status_token = token
break
except subprocess.TimeoutExpired:
status_timed_out = True
except Exception:
status_token = "unknown"
GLib.idle_add(
self.finish_status_check,
state,
request_id,
status_token,
status_timed_out,
)
def finish_status_check(self, state, request_id, status_token, status_timed_out):
"""Update the dialog once the status check completes."""
if not state or state.get('closed'):
return False
if self.active_dialog_state is not state:
return False
if state.get('request_id') != request_id:
return False
self.build_action_dialog_content(state, status_token, status_timed_out)
return False
def build_action_dialog_content(self, state, status_token, status_timed_out=False):
"""Render the full action dialog after status is known."""
dialog = state['dialog']
action = state['action']
content_area = dialog.get_content_area()
clear_container(content_area)
state['loading'] = False
state['status_token'] = status_token
state['status_timed_out'] = status_timed_out
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
set_widget_margins(root, 16, 16, 16, 16)
title_label = Gtk.Label()
title_label.set_markup(f"{escape_markup(action.get('title', 'Action'))}")
title_label.set_xalign(0)
root.pack_start(title_label, False, False, 0)
description = action.get('description')
if description:
desc_label = Gtk.Label(label=description)
desc_label.set_xalign(0)
desc_label.set_line_wrap(True)
desc_label.get_style_context().add_class('dim-label')
root.pack_start(desc_label, False, False, 0)
if status_timed_out:
status_label = Gtk.Label()
status_label.set_markup(
"Status check timed out. You can still run the action."
)
status_label.set_xalign(0)
status_label.set_line_wrap(True)
root.pack_start(status_label, False, False, 0)
actions_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
for option in self.get_action_options(action):
option_button = Gtk.Button(label=option.get('label', 'Run'))
option_button.set_hexpand(True)
option_button.set_halign(Gtk.Align.FILL)
if self.option_is_highlighted(option, status_token):
option_button.get_style_context().add_class("suggested-action")
option_button.connect("clicked", self.on_option_clicked, state, option)
actions_box.pack_start(option_button, False, False, 0)
root.pack_start(actions_box, False, False, 0)
close_button = Gtk.Button(label="Close")
close_button.connect("clicked", lambda _button: dialog.destroy())
root.pack_start(close_button, False, False, 0)
content_area.pack_start(root, False, False, 0)
dialog.show_all()
def option_is_highlighted(self, option, status_token):
"""Return True when the option ID matches the current status token."""
if not status_token or status_token == "unknown":
return False
option_id = (option.get('id') or "").strip().lower()
current_status = status_token.strip().lower()
return bool(option_id) and option_id == current_status
def on_option_clicked(self, _button, state, option):
"""Launch the selected modal action in a terminal."""
script = (option.get('script') or "").strip()
if not script:
return
error_message = self.launch_terminal(script)
if error_message is None:
if (state['action'].get('status_script') or "").strip():
state['dirty'] = True
return
show_error_dialog(
state['dialog'],
"No terminal available",
"Could not open a terminal automatically.\n\n"
+ error_message
+ "\n\nYou can also run the following command manually:\n\n"
+ script
)
def launch_terminal(self, script):
"""Attempt to run a command in a terminal. Returns None on success."""
try:
subprocess.Popen(build_terminal_command(script))
return None
except FileNotFoundError:
return "The default terminal launcher (xdg-terminal-exec) was not found."
except Exception as e:
return f"Terminal launch failed: {e}"
def main():
# Check command-line arguments
if len(sys.argv) != 2:
print(f"Usage: {APP_ID} CONFIG_FILE")
print("Example: python3 yafti_gtk.py /path/to/yafti.yml")
sys.exit(1)
config_file = sys.argv[1]
# Initialize GTK before creating the window.
initialize_gtk()
# Create and show window
win = YaftiGTK(config_file)
win.connect("destroy", Gtk.main_quit)
win.show_all()
Gtk.main()
if __name__ == '__main__':
main()