VulcanBoard.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. #!/usr/bin/env python3
  2. # Copyright © 2024 Noah Vogt <noah@noahvogt.com>
  3. # This program is free software: you can redistribute it and/or modify
  4. # it under the terms of the GNU General Public License as published by
  5. # the Free Software Foundation, either version 3 of the License, or
  6. # (at your option) any later version.
  7. # This program is distributed in the hope that it will be useful,
  8. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. # GNU General Public License for more details.
  11. # You should have received a copy of the GNU General Public License
  12. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  13. # pylint: disable=invalid-name
  14. import threading
  15. import sys
  16. import asyncio
  17. import time
  18. import colorama
  19. from kivy.app import App
  20. from kivy.uix.gridlayout import GridLayout
  21. from kivy.utils import get_color_from_hex
  22. from kivy.config import Config as kvConfig
  23. from kivy.core.window import Window
  24. from kivy.clock import Clock
  25. from util import log, error_exit_gui
  26. from config import (
  27. get_config_path,
  28. ConfigLoader,
  29. Config,
  30. get_state_from_id,
  31. get_state_id_from_exit_code,
  32. DEFAULT_BUTTON_BG_COLOR,
  33. DEFAULT_BUTTON_FG_COLOR,
  34. EMPTY_BUTTON_BG_COLOR,
  35. DEFAULT_STATE_ID,
  36. ERROR_SINK_STATE_ID,
  37. )
  38. from ui import AutoResizeButton
  39. class VulcanBoardApp(App):
  40. def __init__(self, **kwargs):
  41. super().__init__(**kwargs)
  42. self.last_touch_time = 0 # For debounce handling
  43. def build(self):
  44. self.loop = self.ensure_asyncio_loop_running()
  45. self.button_grid = {}
  46. self.button_config_map = {}
  47. self.icon = "icon.png"
  48. config_loader = ConfigLoader(get_config_path())
  49. config = config_loader.get_config() # pyright: ignore
  50. if isinstance(config, str):
  51. error_exit_gui(config)
  52. else:
  53. config: Config = config
  54. Window.borderless = config.borderless
  55. if config.set_window_pos:
  56. Window.left = config.window_pos_x
  57. Window.top = config.window_pos_y
  58. if config.use_auto_fullscreen_mode:
  59. Window.fullscreen = "auto"
  60. kvConfig.set("kivy", "exit_on_escape", "0")
  61. self.button_config_map = {
  62. (btn["position"][0], btn["position"][1]): btn
  63. for btn in config.buttons
  64. }
  65. layout = GridLayout(
  66. cols=config.columns,
  67. rows=config.rows,
  68. spacing=config.spacing,
  69. padding=config.padding,
  70. )
  71. # Populate grid with buttons and placeholders
  72. for row in range(config.rows):
  73. for col in range(config.columns):
  74. defined_button = self.button_config_map.get((row, col))
  75. if defined_button:
  76. states = defined_button.get("states", [])
  77. state = get_state_from_id(states, DEFAULT_STATE_ID)
  78. btn = AutoResizeButton(
  79. text=state.get("txt", ""),
  80. background_color=get_color_from_hex(
  81. state.get("bg_color", DEFAULT_BUTTON_BG_COLOR)
  82. ),
  83. color=get_color_from_hex(
  84. state.get("fg_color", DEFAULT_BUTTON_FG_COLOR)
  85. ),
  86. halign="center",
  87. valign="middle",
  88. background_normal="",
  89. state_id=DEFAULT_STATE_ID,
  90. )
  91. if defined_button.get("autostart", False):
  92. self.async_task(
  93. self.execute_command_async(defined_button, btn)
  94. )
  95. # Use debounce wrapper instead of raw on_release to
  96. # avoid double execution on single taps on touchscreens
  97. btn.bind( # pyright: ignore pylint: disable=no-member
  98. on_release=lambda btn_instance, button=defined_button: self.on_button_pressed_once(
  99. button, btn_instance
  100. )
  101. )
  102. else:
  103. btn = AutoResizeButton(
  104. background_color=get_color_from_hex(
  105. EMPTY_BUTTON_BG_COLOR
  106. ),
  107. )
  108. self.button_grid[(row, col)] = btn
  109. layout.add_widget(btn)
  110. return layout
  111. def on_button_pressed_once(self, button, btn_instance):
  112. now = time.time()
  113. if now - self.last_touch_time > 0.3: # 300 ms debounce
  114. self.last_touch_time = now
  115. self.async_task(self.execute_command_async(button, btn_instance))
  116. def async_task(self, coroutine):
  117. asyncio.run_coroutine_threadsafe(coroutine, self.loop)
  118. def ensure_asyncio_loop_running(self):
  119. if hasattr(self, "loop"):
  120. return self.loop
  121. loop = asyncio.new_event_loop()
  122. def run_loop():
  123. asyncio.set_event_loop(loop)
  124. loop.run_forever()
  125. threading.Thread(target=run_loop, daemon=True).start()
  126. return loop
  127. def config_error_exit(self, popup):
  128. popup.dismiss()
  129. sys.exit(1)
  130. async def execute_command_async(self, button: dict, btn: AutoResizeButton):
  131. follow_up_state_loop = True
  132. states = button["states"]
  133. while follow_up_state_loop:
  134. state = get_state_from_id(states, btn.state_id)
  135. follow_up_state_loop = False
  136. try:
  137. process = await asyncio.create_subprocess_shell(
  138. state["cmd"], shell=True
  139. )
  140. exit_code = await process.wait()
  141. log(f"Executed command: {state['cmd']}")
  142. except Exception as e:
  143. exit_code = ERROR_SINK_STATE_ID
  144. log(f"Error executing command: {e}", color="yellow")
  145. if len(states) != 1:
  146. if isinstance(
  147. follow_up_state := state.get("follow_up_state"), int
  148. ):
  149. follow_up_state_loop = True
  150. exit_code = follow_up_state
  151. Clock.schedule_once(
  152. lambda _: self.update_button_feedback(
  153. states, btn, exit_code
  154. )
  155. )
  156. affects_buttons = button.get("affects_buttons", None)
  157. if affects_buttons:
  158. for affected_btn_dims in affects_buttons:
  159. btn_pos = (affected_btn_dims[0], affected_btn_dims[1])
  160. affected_button = self.button_grid[btn_pos]
  161. Clock.schedule_once(
  162. lambda _, btn_pos=btn_pos, affected_button=affected_button: self.update_button_feedback(
  163. self.button_config_map[btn_pos]["states"],
  164. affected_button,
  165. exit_code,
  166. )
  167. )
  168. def update_button_feedback(
  169. self, states: list, btn: AutoResizeButton, exit_code: int
  170. ):
  171. state_id = get_state_id_from_exit_code(states, exit_code)
  172. state = get_state_from_id(states, state_id)
  173. btn.text = state.get("txt", "")
  174. btn.state_id = state_id
  175. btn.background_color = get_color_from_hex(
  176. state.get("bg_color", DEFAULT_BUTTON_BG_COLOR)
  177. )
  178. btn.color = get_color_from_hex(
  179. state.get("fg_color", DEFAULT_BUTTON_FG_COLOR)
  180. )
  181. def start_asyncio_loop():
  182. asyncio.set_event_loop(asyncio.new_event_loop())
  183. asyncio.get_event_loop().run_forever()
  184. if __name__ == "__main__":
  185. colorama.init()
  186. VulcanBoardApp().run()