From 3a93841f00fb031cb4416589fe9580058b4650ab Mon Sep 17 00:00:00 2001 From: dpc00 Date: Fri, 9 Oct 2026 21:11:25 -0600 Subject: [PATCH 1/2] Run the OS color picker off the plugin host main thread With "use_os_color_picker" on, the picker call blocked the plugin host's main thread until the native dialog closed, so Sublime showed "Not Responding" for as long as the dialog stayed open (125 s in one test on Windows) and every other package in the same plugin host stopped with it. Run the dialog on a worker thread and hand the result back with sublime.set_timeout. Because the editor now stays usable while the dialog is open, remember where the picker was started (a hidden region that follows edits) and put the result there afterwards: back in the original view, with the original selection, or nowhere if that view was closed. Each invocation uses its own region key, and a second request while a dialog is open only shows a status message. Tested with the stock release as baseline (freeze reproduced and fixed) on Windows and with kcolorchooser under WSL: OK and Cancel, edits and a moved caret while the dialog is open, switching tabs, closing the file, plugin reload and package unload with the dialog open, overlapping dialogs, and the built-in picker (use_os_color_picker false) unchanged. macOS is untested. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01QrLXeSos7CbhMgJJponrpj --- ch_panel.py | 74 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/ch_panel.py b/ch_panel.py index c20ffe72..2a6cf0eb 100755 --- a/ch_panel.py +++ b/ch_panel.py @@ -6,6 +6,8 @@ """ import sublime import sublime_plugin +import threading +import uuid import mdpopups from .lib import colorbox from html.parser import HTMLParser @@ -21,6 +23,11 @@ PALETTE_SCALE_Y = 2 BORDER_SIZE = 1 +# Set while an OS color picker dialog is open, so a second one is not started. +_native_picker_busy = threading.Event() +# Prefix of the key of the hidden region that follows the selection the picker was started from. +_PICKER_TARGET = "color_helper_picker_target" + ########################### # Main Code @@ -245,17 +252,62 @@ def color_picker(self, color): if self.os_color_picker: self.view.hide_popup() - new_color = native_picker(self.base(color).convert("srgb").fit(**self.gamut_map)) - if new_color is not None: - sublime.set_timeout( - lambda c=new_color.to_string(**util.COLOR_FULL_PREC): self.view.run_command( - "color_helper", - {"color": c, 'mode': 'result', 'result_type': '__color_picker__'} - ), - 200 - ) - else: - sublime.set_timeout(self.show_color_info, 200) + if _native_picker_busy.is_set(): + sublime.status_message("ColorHelper: the color picker dialog is already open") + return + start = self.base(color).convert("srgb").fit(**self.gamut_map) + + # The native dialog is modal and does not return until it is closed. Run it off the + # plugin host's main thread so that Sublime keeps responding while it is open. + # Remember where the picker was started: the result belongs there, even if the user + # has typed, moved the caret or switched tabs while the dialog was open. + view = self.view + # Each invocation has its own key, so overlapping pickers (possible after a plugin + # reload, which resets the busy flag) can never restore each other's selection. + key = _PICKER_TARGET + uuid.uuid4().hex + view.add_regions(key, list(view.sel()), flags=sublime.HIDDEN) + + def finish(new_color): + try: + regions = view.get_regions(key) + view.erase_regions(key) + window = view.window() + if window is None or not view.is_valid(): + # The view was closed while the dialog was open: there is no place for it. + return + window.focus_view(view) + if regions: + view.sel().clear() + view.sel().add_all(regions) + view.show(regions[0]) + if new_color is None: + self.show_color_info() + else: + view.run_command( + "color_helper", + {"color": new_color, 'mode': 'result', 'result_type': '__color_picker__'} + ) + finally: + _native_picker_busy.clear() + + def run(): + new_color = None + try: + new_color = native_picker(start) + if new_color is not None: + new_color = new_color.to_string(**util.COLOR_FULL_PREC) + except Exception: + new_color = None + finally: + sublime.set_timeout(lambda: finish(new_color), 200) + + _native_picker_busy.set() + try: + threading.Thread(target=run, daemon=True).start() + except Exception: + _native_picker_busy.clear() + view.erase_regions(key) + raise else: if not self.no_info: on_cancel = {'command': 'color_helper', 'args': {'mode': "info"}} From 1946ad40c9f98b3ea9886336701865eddb51fde7 Mon Sep 17 00:00:00 2001 From: dpc00 Date: Sat, 10 Oct 2026 09:57:16 -0600 Subject: [PATCH 2/2] Report errors from the native color picker The picker now runs on a worker thread, so an exception there no longer reaches the main thread and was swallowed. Print the traceback to the console and show a status-bar message. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TUDTsjkUebDhyzXUW1bYjj --- ch_panel.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ch_panel.py b/ch_panel.py index 2a6cf0eb..021ddcb2 100755 --- a/ch_panel.py +++ b/ch_panel.py @@ -7,6 +7,7 @@ import sublime import sublime_plugin import threading +import traceback import uuid import mdpopups from .lib import colorbox @@ -297,6 +298,12 @@ def run(): if new_color is not None: new_color = new_color.to_string(**util.COLOR_FULL_PREC) except Exception: + # Do not lose the error: it used to surface from the main thread. + traceback.print_exc() + sublime.set_timeout( + lambda: sublime.status_message("ColorHelper: the color picker failed, see the console"), + 0 + ) new_color = None finally: sublime.set_timeout(lambda: finish(new_color), 200)