diff --git a/Windows_and_Linux/WritingToolApp.py b/Windows_and_Linux/WritingToolApp.py index b230a12f..ccbef1b2 100644 --- a/Windows_and_Linux/WritingToolApp.py +++ b/Windows_and_Linux/WritingToolApp.py @@ -14,7 +14,8 @@ import ui.OnboardingWindow import ui.ResponseWindow import ui.SettingsWindow -from aiprovider import GeminiProvider, OllamaProvider, OpenAICompatibleProvider, obfuscate_api_key +from aiprovider import GeminiProvider, OllamaCloudProvider, OllamaProvider, OpenAICompatibleProvider, obfuscate_api_key +from history_manager import HistoryManager from pynput import keyboard as pykeyboard from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtCore import QLocale, Signal, Slot @@ -93,7 +94,18 @@ def __init__(self, argv): self.setup_ctrl_c_listener() # Setup available AI providers - self.providers = [GeminiProvider(self), OpenAICompatibleProvider(self), OllamaProvider(self)] + # Order matters: the FIRST entry is the default for new installs and + # the order they're presented to the user in the Settings dropdown. + # 1) Gemini (Recommended) — daily-quota free tier, very fast + # 2) Ollama Cloud (Recommended) — weekly-quota free tier, no install + # 3) OpenAI Compatible — for users with their own key + # 4) Ollama Local — for users running Ollama themselves + self.providers = [ + GeminiProvider(self), + OllamaCloudProvider(self), + OpenAICompatibleProvider(self), + OllamaProvider(self), + ] if not self.config: logging.debug('No config found, showing onboarding') @@ -996,8 +1008,13 @@ def process_thread(): return_response=True ) - elif isinstance(self.current_provider, OllamaProvider): # - # For Ollama, prepare messages with system instruction and history + elif isinstance(self.current_provider, (OllamaProvider, OllamaCloudProvider)): + # For both Ollama variants (local server and Ollama Cloud), + # prepare messages with system instruction + history. The + # two providers share the same OpenAI-style message-array + # contract, so they share a branch. The actual endpoint + # (localhost vs ollama.com) and auth are encapsulated in + # each provider's `after_load()`. messages = [{"role": "system", "content": system_instruction}] for msg in history: @@ -1006,7 +1023,7 @@ def process_thread(): "content": msg["content"] }) - # Get response from Ollama + # Get response from Ollama / Ollama Cloud response_text = self.current_provider.get_response( system_instruction, messages, diff --git a/Windows_and_Linux/aiprovider.py b/Windows_and_Linux/aiprovider.py index 273579ec..c6f97ec1 100644 --- a/Windows_and_Linux/aiprovider.py +++ b/Windows_and_Linux/aiprovider.py @@ -2,7 +2,7 @@ AI Provider Architecture for Writing Tools -------------------------------------------- -This module handles different AI model providers (Gemini, OpenAI-compatible, Ollama) and manages their interactions +This module handles different AI model providers (Gemini, Ollama Cloud, OpenAI-compatible, Ollama local) and manages their interactions with the main application. It uses an abstract base class pattern for provider implementations. Key Components: @@ -17,9 +17,11 @@ • Cancelling an ongoing request 3. Provider Implementations: - • GeminiProvider – Uses Google’s Generative AI API (Gemini) to generate content. + • GeminiProvider – Uses Google’s Generative AI API (Gemini) to generate content. + • OllamaCloudProvider – Connects to Ollama Cloud (https://ollama.com) using the ollama Python client + with a Bearer API key. Free tier available — recommended for most users. • OpenAICompatibleProvider – Connects to any OpenAI-compatible API (v1/chat/completions) - • OllamaProvider – Connects to a locally running Ollama server (e.g. for llama.cpp) + • OllamaProvider – Connects to a locally running Ollama server (e.g. for llama.cpp). For Experts. Response Flow: • The main app calls get_response() with a system instruction and a prompt. @@ -257,7 +259,9 @@ def __init__(self, app, provider_name: str, settings: List[AIProviderSetting], description: str = "An unfinished AI provider!", logo: str = "generic", button_text: str = "Go to URL", - button_action: callable = None): + button_action: callable = None, + secondary_button_text: str = None, + secondary_button_action: callable = None): self.provider_name = provider_name self.settings = settings self.app = app @@ -265,6 +269,13 @@ def __init__(self, app, provider_name: str, settings: List[AIProviderSetting], self.logo = logo self.button_text = button_text self.button_action = button_action + # Optional second button next to the primary one (used by Ollama Cloud + # for "Get Free API Key" + "Open API Key Dashboard", and kept generic + # for any future provider that needs a pair of CTAs). Both must be + # provided for the button to render; if either is None we fall back to + # the original single-button layout for backward compatibility. + self.secondary_button_text = secondary_button_text + self.secondary_button_action = secondary_button_action @abstractmethod def get_response(self, system_instruction: str, prompt: str) -> str: @@ -584,12 +595,253 @@ def cancel(self): self.close_requested = True +class OllamaCloudProvider(AIProvider): + """ + Provider for Ollama Cloud (https://ollama.com). + + Recommended for most users: no local install required, just sign in at + ollama.com, generate a free API key, paste it here, and you're set. The + free tier ships with a limited weekly usage budget — comparable in + spirit to Gemini's daily quota but refreshed weekly. + + We use the same `ollama` Python client as the local provider, just + pointing `host` at `https://ollama.com` and passing the API key in the + `Authorization: Bearer ...` header (the `headers=` kwarg of + `ollama.Client`). This means the request shape is identical to local + Ollama — we reuse the same `client.chat(...)` call path, just with + different endpoint and auth. + + References: + • https://docs.ollama.com/cloud + • https://docs.ollama.com/api/authentication + • https://ollama.com/signin (sign up + free key) + • https://ollama.com/settings/keys (manage / regenerate keys) + """ + # Hostname of the Ollama Cloud API. Exposed as a class-level constant + # so tests and tools (e.g. the Settings UI) can reference it without + # hard-coding the string in multiple places. + OLLAMA_CLOUD_HOST = "https://ollama.com" + + def __init__(self, app): + self.close_requested = None + self.client = None + self.app = app + + settings = [ + TextSetting( + name="api_key", + display_name="API Key", + description="Paste your Ollama Cloud API key here", + ), + DropdownSetting( + name="api_model", + display_name="Model", + # Default to gemma4:31b — verified available on Ollama Cloud. + # Users can switch via the dropdown at any time. + default_value="gemma4:31b", + description="Select an Ollama Cloud model to use", + options=[ + # Verified available models on Ollama Cloud (https://ollama.com/api/tags). + # Model IDs use exact tags from the official catalog to avoid 404 errors. + ("gemma4:31b", "gemma4:31b"), + ("gemma3:12b", "gemma3:12b"), + ("deepseek-v4-flash", "deepseek-v4-flash"), + ("nemotron-3-nano:30b", "nemotron-3-nano:30b"), + ("gpt-oss:20b", "gpt-oss:20b") + ], + allow_custom=True, + # The cloud catalog evolves quickly; the Custom escape hatch + # keeps the provider useful as new models land without + # requiring a code update. + custom_placeholder="e.g., gemma4:31b (without -cloud prefix)", + ), + # num_ctx default 4096: matches the local Ollama provider. + TextSetting("num_ctx", "Context window size (num_ctx)", "4096", + "E.g. 4096. Larger = more memory, but supports longer inputs."), + # num_predict cap bounds latency on chatty cloud models. + TextSetting("num_predict", "Max output tokens (num_predict)", "1000", + "E.g. 1000. Caps response length to bound latency."), + # 0.4 is a good writing-assistant default — deterministic enough + # for proofreading, not robotic for rewrites. Range 0.0–1.0. + TextSetting("temperature", "Temperature", "0.4", + "0.0 = deterministic, 1.0 = creative. 0.4 is a good writing-assistant default."), + ] + + super().__init__( + app, + "Ollama Cloud (Recommended, free tier)", + settings, + # Friendly description for the Settings panel. We mirror the + # Gemini panel's "•"-bulleted style so the two recommended + # providers feel visually consistent. + "• Ollama Cloud lets you run powerful models (Gemma, Gemini, DeepSeek, Nemotron...) with no local install.\n" + "• Sign up for free at ollama.com, generate an API key, and paste it below.\n" + "• The free tier includes a limited amount of usage that refreshes weekly.\n" + "• Click the button below to open your API key dashboard.", + "ollama", + # Single CTA: open the key dashboard directly. Users who need + # to sign up can do so from the same page. + "Open API Key Dashboard", + lambda: webbrowser.open("https://ollama.com/settings/keys"), + ) + + def _safe_int(self, value, default): + """Parse a TextSetting value as int, falling back to `default` on bad input.""" + try: + return int(value) if str(value).strip() else default + except (ValueError, TypeError): + return default + + def _safe_float(self, value, default): + """Parse a TextSetting value as float, falling back to `default` on bad input.""" + try: + return float(value) if str(value).strip() else default + except (ValueError, TypeError): + return default + + def get_response(self, system_instruction: str, prompt, return_response: bool = False) -> str: + """ + Send a chat request to Ollama Cloud. + + Uses the same `/api/chat` schema and response shape as the local + Ollama provider — only the endpoint and auth header differ. The + cloud endpoint uses `Authorization: Bearer `, passed via + the `ollama.Client(headers=...)` kwarg. + + Always performs a non-streaming request. + Returns the response text if return_response is True, otherwise + emits it via output_ready_signal. + """ + self.close_requested = False + + if isinstance(prompt, list): + messages = prompt + else: + messages = [ + {"role": "system", "content": system_instruction}, + {"role": "user", "content": prompt} + ] + + # GPT-OSS only accepts string think levels; everything else accepts + # a bool. "low" is the closest to "off" GPT-OSS offers, and matches + # the local provider's behaviour exactly. (No public Ollama Cloud + # model is named gpt-oss at the moment, but we keep the rule for + # forward-compatibility in case one shows up.) + think_value = "low" if "gpt-oss" in (self.api_model or "").lower() else False + + options = { + "num_ctx": self._safe_int(getattr(self, "num_ctx", None), 4096), + "num_predict": self._safe_int(getattr(self, "num_predict", None), 1000), + "temperature": self._safe_float(getattr(self, "temperature", None), 0.4), + } + + try: + logging.debug(f"Ollama Cloud request: model={self.api_model}, options={options}") + response = self.client.chat( + model=self.api_model, + messages=messages, + think=think_value, + options=options, + ) + response_text = response['message']['content'].strip() + if not return_response and not hasattr(self.app, 'current_response_window'): + self.app.output_ready_signal.emit(response_text) + return response_text + except Exception as e: + error_str = str(e) + logging.error(f"Error during Ollama Cloud chat: {e}") + # Be a little more user-friendly than the local provider's + # generic message: free-tier / quota hits are the most common + # cloud failure mode, and the user can fix them by waiting or + # upgrading at ollama.com — not by editing local config. + if "401" in error_str or "unauthorized" in error_str.lower() or "invalid api key" in error_str.lower(): + self.app.show_message_signal.emit( + "Invalid Ollama Cloud API Key", + "Your Ollama Cloud API key was rejected. " + "Open the API Key Dashboard (button above) to copy a valid key, " + "then paste it here and save again." + ) + elif "402" in error_str or "quota" in error_str.lower() or "usage limit" in error_str.lower(): + self.app.show_message_signal.emit( + "Ollama Cloud Free Tier Limit", + "You've used up this week's free-tier quota on Ollama Cloud. " + "It will reset on a rolling weekly basis, or you can upgrade your plan at ollama.com." + ) + else: + self.app.output_ready_signal.emit("An error occurred during Ollama Cloud chat.") + return "" + + def after_load(self): + """ + Build the Ollama Cloud client. + + The host is fixed to `https://ollama.com`; the API key is passed in + the `Authorization: Bearer ...` header via the `headers=` kwarg + (the official cloud-auth pattern documented at + https://docs.ollama.com/api/authentication). + """ + # Guard against an empty key at startup — `ollama.Client` would + # happily build without one and only fail at request time. We + # still build the client (so the rest of the provider's lifecycle + # works) but tag it so we can short-circuit on send. + headers = {'Authorization': f'Bearer {self.api_key}'} if self.api_key else {} + self.client = OllamaClient(host=self.OLLAMA_CLOUD_HOST, headers=headers) + + def before_load(self): + self.client = None + + def load_config(self, config: dict): + """ + Load configuration, deobfuscating the API key if needed. + Mirrors the Gemini provider's pattern so an existing user upgrading + from plaintext storage (or migrating in from a different machine) + still gets a working key. + """ + if 'api_key' in config: + config = config.copy() # Don't mutate the caller's dict + config['api_key'] = deobfuscate_api_key(config['api_key']) + super().load_config(config) + + def save_config(self): + """ + Save configuration, obfuscating the API key for storage. + Same XOR+base64 obfuscation Gemini uses — at-rest defence in + depth only (anyone with the obfuscated blob can trivially reverse + it on the same machine), but it stops a casual Ctrl+F in the + config file from harvesting live keys. + """ + config = {} + for setting in self.settings: + value = setting.get_value() + if setting.name == 'api_key': + value = obfuscate_api_key(value) + config[setting.name] = value + self.app.config["providers"][self.provider_name] = config + self.app.save_config(self.app.config) + + def cancel(self): + self.close_requested = True + + class OllamaProvider(AIProvider): """ - Provider for connecting to an Ollama server. - + Provider for connecting to a LOCAL Ollama server. + + This is the "For Experts" option — it requires the user to install + and run `ollama serve` on their own machine and to pull a model + locally. If you just want to get up and running, use Ollama Cloud + (the recommended provider listed above this one in Settings). + Uses the /chat endpoint of the Ollama server to generate a response. Streaming is not used. + + Thinking is explicitly disabled (`think=False`) so reasoning-capable + models like qwen3 / deepseek-r1 / gpt-oss don't burn latency on an + internal chain-of-thought before answering. For GPT-OSS specifically, + Ollama only accepts string levels ("low" / "medium" / "high") instead + of a boolean, so we use "low" — the closest to "off" it offers. + + Reference: https://docs.ollama.com/capabilities/thinking """ def __init__(self, app): self.close_requested = None @@ -598,17 +850,40 @@ def __init__(self, app): settings = [ TextSetting("api_base", "API Base URL", "http://localhost:11434", "E.g. http://localhost:11434"), TextSetting("api_model", "API Model", "llama3.1:8b", "E.g. llama3.1:8b"), - TextSetting("keep_alive", "Time to keep the model loaded in memory in minutes", "5", "E.g. 5") + TextSetting("keep_alive", "Time to keep the model loaded in memory in minutes", "5", "E.g. 5"), + # num_ctx default 4096: Ollama's default of 2048 is too small for our + # system prompt + long user input, which used to cause silent truncation. + TextSetting("num_ctx", "Context window size (num_ctx)", "4096", "E.g. 4096. Larger = more memory, but supports longer inputs."), + # num_predict cap bounds latency and prevents runaway generation on local + # models. Our responses (proofread/rewrite/summary) are short by design. + TextSetting("num_predict", "Max output tokens (num_predict)", "1000", "E.g. 1000. Caps response length to bound latency."), + # 0.4 is a good writing-assistant default — deterministic enough for + # proofreading, not robotic for rewrites. Range is 0.0–1.0. + TextSetting("temperature", "Temperature", "0.4", "0.0 = deterministic, 1.0 = creative. 0.4 is a good writing-assistant default."), ] super().__init__(app, "Ollama (For Experts)", settings, "• Connect to an Ollama server (local LLM).", "ollama", "Ollama Set-up Instructions", lambda: webbrowser.open("https://github.com/theJayTea/WritingTools?tab=readme-ov-file#-optional-ollama-local-llm-instructions-for-windows-v7-onwards")) + def _safe_int(self, value, default): + """Parse a TextSetting value as int, falling back to `default` on bad input.""" + try: + return int(value) if str(value).strip() else default + except (ValueError, TypeError): + return default + + def _safe_float(self, value, default): + """Parse a TextSetting value as float, falling back to `default` on bad input.""" + try: + return float(value) if str(value).strip() else default + except (ValueError, TypeError): + return default + def get_response(self, system_instruction: str, prompt: str | list, return_response: bool = False) -> str: """ Send a chat request to the Ollama server. - + Always performs a non-streaming request. Returns the response text if return_response is True, otherwise emits it via output_ready_signal. @@ -623,8 +898,23 @@ def get_response(self, system_instruction: str, prompt: str | list, return_respo {"role": "user", "content": prompt} ] + # GPT-OSS only accepts string think levels; everything else accepts bool. + # "low" is the closest to "off" GPT-OSS offers. + think_value = "low" if "gpt-oss" in (self.api_model or "").lower() else False + + options = { + "num_ctx": self._safe_int(getattr(self, "num_ctx", None), 4096), + "num_predict": self._safe_int(getattr(self, "num_predict", None), 1000), + "temperature": self._safe_float(getattr(self, "temperature", None), 0.4), + } + try: - response = self.client.chat(model=self.api_model, messages=messages) + response = self.client.chat( + model=self.api_model, + messages=messages, + think=think_value, + options=options, + ) response_text = response['message']['content'].strip() if not return_response and not hasattr(self.app, 'current_response_window'): self.app.output_ready_signal.emit(response_text) diff --git a/Windows_and_Linux/locales/en/LC_MESSAGES/messages.po b/Windows_and_Linux/locales/en/LC_MESSAGES/messages.po index 2a8e717d..be3f710c 100644 --- a/Windows_and_Linux/locales/en/LC_MESSAGES/messages.po +++ b/Windows_and_Linux/locales/en/LC_MESSAGES/messages.po @@ -165,6 +165,10 @@ msgid "Gemini 2.0" msgstr "" #: ui/OnboardingWindow.py:54 ui/OnboardingWindow.py:56 +msgid "Ollama Cloud (free tier — no install needed)" +msgstr "" + +#: ui/OnboardingWindow.py:55 ui/OnboardingWindow.py:57 msgid "ANY OpenAI Compatible API — including local LLMs!" msgstr "" diff --git a/Windows_and_Linux/ui/OnboardingWindow.py b/Windows_and_Linux/ui/OnboardingWindow.py index 6b62f80f..bc13cb22 100644 --- a/Windows_and_Linux/ui/OnboardingWindow.py +++ b/Windows_and_Linux/ui/OnboardingWindow.py @@ -44,7 +44,7 @@ def show_welcome_screen(self): self.content_layout.addWidget(title_label, alignment=QtCore.Qt.AlignmentFlag.AlignCenter) features_text = f""" - • {_('Instantly optimize your writing with AI by selecting your text and invoking Writing Tools with "ctrl+space", anywhere.')} + • {_('Instantly optimize your writing with AI by selecting your text and invoking Writing Tools with "ctrl+space", anywhere.')} • {_('Get a summary you can chat with of articles, YouTube videos, or documents by select all text with "ctrl+a"')} {_('(or select the YouTube transcript from its description), invoking Writing Tools, and choosing Summary.')} @@ -53,6 +53,7 @@ def show_welcome_screen(self): • {_('Supports an extensive range of AI models:')} - {_('Gemini 2.0')} + - {_('Ollama Cloud (free tier — no install needed)')} - {_('ANY OpenAI Compatible API — including local LLMs!')} """ features_label = QtWidgets.QLabel(features_text) diff --git a/Windows_and_Linux/ui/SettingsWindow.py b/Windows_and_Linux/ui/SettingsWindow.py index e3a77b2f..a39536f6 100644 --- a/Windows_and_Linux/ui/SettingsWindow.py +++ b/Windows_and_Linux/ui/SettingsWindow.py @@ -74,64 +74,47 @@ def init_provider_ui(self, provider: AIProvider, layout): description_label.setWordWrap(True) self.current_provider_layout.addWidget(description_label) - if hasattr(provider, 'ollama_button_text'): - # Create container for buttons + # The Settings panel supports up to TWO side-by-side CTA buttons under + # the provider description. We trigger the two-button layout in two + # ways, in order of preference: + # 1. The provider exposes a generic `secondary_button_text` + + # `secondary_button_action` pair (Ollama Cloud uses this). + # 2. The provider exposes the legacy `ollama_button_text` pair + # (kept for any external providers that may still use it). + # Providers that only expose a single `button_text` get the original + # single centred button — no behaviour change for Gemini, OpenAI, or + # the local Ollama provider. + secondary_text = getattr(provider, 'secondary_button_text', None) + secondary_action = getattr(provider, 'secondary_button_action', None) + if secondary_text and secondary_action: + # Generic two-button row (case 1) — preferred. + self._render_two_buttons( + provider.button_text, provider.button_action, + secondary_text, secondary_action, + self.current_provider_layout, + ) + elif hasattr(provider, 'ollama_button_text'): + # Legacy two-button row (case 2) — backwards compatibility. button_layout = QtWidgets.QHBoxLayout() - - # Add Ollama setup button + + # Add legacy Ollama setup button ollama_button = QtWidgets.QPushButton(provider.ollama_button_text) - ollama_button.setStyleSheet(f""" - QPushButton {{ - background-color: {'#4CAF50' if colorMode == 'dark' else '#008CBA'}; - color: white; - padding: 10px; - font-size: 16px; - border: none; - border-radius: 5px; - }} - QPushButton:hover {{ - background-color: {'#45a049' if colorMode == 'dark' else '#007095'}; - }} - """) + ollama_button.setStyleSheet(self._button_stylesheet()) ollama_button.clicked.connect(provider.ollama_button_action) button_layout.addWidget(ollama_button) - + # Add original button main_button = QtWidgets.QPushButton(provider.button_text) - main_button.setStyleSheet(f""" - QPushButton {{ - background-color: {'#4CAF50' if colorMode == 'dark' else '#008CBA'}; - color: white; - padding: 10px; - font-size: 16px; - border: none; - border-radius: 5px; - }} - QPushButton:hover {{ - background-color: {'#45a049' if colorMode == 'dark' else '#007095'}; - }} - """) + main_button.setStyleSheet(self._button_stylesheet()) main_button.clicked.connect(provider.button_action) button_layout.addWidget(main_button) - + self.current_provider_layout.addLayout(button_layout) else: # Original single button logic if provider.button_text: button = QtWidgets.QPushButton(provider.button_text) - button.setStyleSheet(f""" - QPushButton {{ - background-color: {'#4CAF50' if colorMode == 'dark' else '#008CBA'}; - color: white; - padding: 10px; - font-size: 16px; - border: none; - border-radius: 5px; - }} - QPushButton:hover {{ - background-color: {'#45a049' if colorMode == 'dark' else '#007095'}; - }} - """) + button.setStyleSheet(self._button_stylesheet()) button.clicked.connect(provider.button_action) self.current_provider_layout.addWidget(button, alignment=QtCore.Qt.AlignmentFlag.AlignCenter) @@ -344,6 +327,66 @@ def init_ui(self): desired_height = min(720, max_height) # Cap at 720px or 85% of screen height self.resize(592, desired_height) # Use an exact width of 592px so stuff looks good! + @staticmethod + def _button_stylesheet(): + """ + Shared QSS for the primary/secondary CTA buttons rendered in a + provider's settings panel. Centralised here so both the single-button + and the two-button paths look identical, and so any future theme + tweak happens in one place. + + The colour shifts slightly with the system theme via `colorMode`: + • dark → green (#4CAF50 / #45a049) + • light → blue (#008CBA / #007095) + """ + return f""" + QPushButton {{ + background-color: {'#4CAF50' if colorMode == 'dark' else '#008CBA'}; + color: white; + padding: 10px; + font-size: 16px; + border: none; + border-radius: 5px; + }} + QPushButton:hover {{ + background-color: {'#45a049' if colorMode == 'dark' else '#007095'}; + }} + """ + + def _render_two_buttons(self, + primary_text, primary_action, + secondary_text, secondary_action, + parent_layout): + """ + Render the two-button CTA row used by providers that need more than + one action next to their description (currently only Ollama Cloud, + with "Get Free API Key" + "Open API Key Dashboard"). Buttons share + the row 50/50 via stretch factors so a long primary label doesn't + crowd the secondary one. + + Caller is expected to have validated that all four arguments are + non-None before calling. + """ + button_layout = QtWidgets.QHBoxLayout() + button_layout.setSpacing(10) + + # Primary button (left) — e.g. "Get Free API Key". Treated as the + # visually dominant action. + primary_button = QtWidgets.QPushButton(primary_text) + primary_button.setStyleSheet(self._button_stylesheet()) + primary_button.clicked.connect(primary_action) + button_layout.addWidget(primary_button, stretch=1) + + # Secondary button (right) — e.g. "Open API Key Dashboard". Same + # styling; the 50/50 split is purely to make the two equal-weight + # CTAs feel balanced rather than to dim the secondary. + secondary_button = QtWidgets.QPushButton(secondary_text) + secondary_button.setStyleSheet(self._button_stylesheet()) + secondary_button.clicked.connect(secondary_action) + button_layout.addWidget(secondary_button, stretch=1) + + parent_layout.addLayout(button_layout) + @staticmethod def toggle_autostart(state): """Toggle the autostart setting."""