VulcanBoard.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  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. from os import path, getenv, name
  15. import subprocess
  16. import sys
  17. from dataclasses import dataclass
  18. import yaml
  19. from termcolor import colored
  20. import colorama
  21. from kivy.app import App
  22. from kivy.uix.button import Button
  23. from kivy.uix.gridlayout import GridLayout
  24. from kivy.utils import get_color_from_hex
  25. from kivy.factory import Factory
  26. def log(message: str, color="green") -> None:
  27. print(colored("[*] {}".format(message), color)) # pyright: ignore
  28. def get_config_path():
  29. if name == "nt":
  30. return path.join(getenv("APPDATA", ""), "VulcanBoard", "config.yml")
  31. xdg_config_home = getenv("XDG_CONFIG_HOME", path.expanduser("~/.config"))
  32. return path.join(xdg_config_home, "VulcanBoard", "config.yml")
  33. VALID_HEX_COLORS = list("0123456789abcdef")
  34. def is_valid_hexcolor(hexcolor: str) -> bool:
  35. if len(hexcolor) != 6:
  36. return False
  37. for char in hexcolor.lower():
  38. if char not in VALID_HEX_COLORS:
  39. return False
  40. return True
  41. @dataclass
  42. class Config:
  43. columns: int
  44. rows: int
  45. buttons: list[dict]
  46. spacing: int
  47. padding: int
  48. class CustomException(Exception):
  49. pass
  50. @dataclass
  51. class ConfigLoader:
  52. config_path: str
  53. def __post_init__(self) -> None:
  54. self.columns = 0
  55. self.rows = 0
  56. self.buttons = []
  57. self.padding = 0
  58. self.spacing = 0
  59. def get_config(self) -> Config | str:
  60. try:
  61. with open(self.config_path, "r", encoding="utf-8") as config_reader:
  62. yaml_config = yaml.safe_load(config_reader)
  63. self.columns = yaml_config.get("columns")
  64. self.rows = yaml_config.get("rows")
  65. self.buttons = yaml_config.get("buttons")
  66. self.padding = yaml_config.get("padding", 5)
  67. self.spacing = yaml_config.get("spacing", 5)
  68. return self.__interpret_config()
  69. except (FileNotFoundError, PermissionError, IOError) as error:
  70. return f"Error: Could not access config file at {self.config_path}. Reason: {error}"
  71. except (yaml.YAMLError, CustomException) as error:
  72. return f"Error parsing config file. Reason: {error}"
  73. def __interpret_config(self) -> Config:
  74. self.__validate_dimensions()
  75. self.__validate_buttons()
  76. self.__validate_styling()
  77. return Config(
  78. self.columns, self.rows, self.buttons, self.spacing, self.padding
  79. )
  80. def __validate_buttons(self) -> None:
  81. if not isinstance(self.buttons, list):
  82. raise CustomException(
  83. "invalid button config. needs to be a list of dicts."
  84. )
  85. for button in self.buttons:
  86. if not isinstance(button, dict):
  87. raise CustomException(
  88. "invalid button config. needs to be a list of dicts."
  89. )
  90. if (
  91. not isinstance(dimensions := button.get("position", ""), list)
  92. or (not isinstance(dimensions[0], int))
  93. or (not isinstance(dimensions[1], int))
  94. or (0 > dimensions[0] or dimensions[0] > self.rows - 1)
  95. or (0 > dimensions[1] or dimensions[1] > self.columns - 1)
  96. ):
  97. raise CustomException(
  98. f"invalid button 'position' subentry: '{dimensions}'"
  99. )
  100. for entry in ("txt", "cmd"):
  101. if not isinstance(result := button.get(entry, ""), str):
  102. raise CustomException(
  103. f"invalid button '{entry}' subentry: '{result}'"
  104. )
  105. if not isinstance(
  106. bg_color := button.get("bg_color", "cccccc"), str
  107. ) or not is_valid_hexcolor(bg_color):
  108. raise CustomException(
  109. f"invalid button 'bg_color' subentry: '{bg_color}'"
  110. )
  111. if not isinstance(
  112. fg_color := button.get("fg_color", "ffffff"), str
  113. ) or not is_valid_hexcolor(bg_color):
  114. raise CustomException(
  115. f"invalid button 'fg_color' subentry: '{fg_color}'"
  116. )
  117. if (
  118. not isinstance(fontsize := button.get("fontsize", ""), int)
  119. or 0 > fontsize
  120. ):
  121. raise CustomException(
  122. f"invalid button 'fontsize' subentry: '{fontsize}'"
  123. )
  124. def __validate_dimensions(self) -> None:
  125. for dimension in (self.columns, self.rows):
  126. if not isinstance(dimension, int) or (dimension <= 0):
  127. raise CustomException(f"invalid dimension: {dimension}")
  128. def __validate_styling(self) -> None:
  129. for styling in (self.spacing, self.padding):
  130. if not isinstance(styling, int) or (styling <= 0):
  131. raise CustomException(f"invalid styling: {styling}")
  132. class VulcanBoardApp(App):
  133. def build(self):
  134. config_loader = ConfigLoader(get_config_path())
  135. config = config_loader.get_config() # pyright: ignore
  136. print(type(config))
  137. print(config)
  138. if isinstance(config, str):
  139. popup = Factory.ErrorPopup()
  140. popup.message.text = config
  141. popup.open()
  142. popup.error_exit = lambda: sys.exit(1)
  143. else:
  144. config: Config = config
  145. button_map = {
  146. (btn["position"][0], btn["position"][1]): btn
  147. for btn in config.buttons
  148. }
  149. layout = GridLayout(
  150. cols=config.columns,
  151. rows=config.rows,
  152. spacing=config.spacing,
  153. padding=config.padding,
  154. )
  155. # Populate grid with buttons and placeholders
  156. for row in range(config.rows):
  157. for col in range(config.columns):
  158. defined_button = button_map.get((row, col))
  159. if defined_button:
  160. btn = Button(
  161. text=defined_button.get("txt", ""),
  162. background_color=get_color_from_hex(
  163. defined_button.get("bg_color", "aaaaff")
  164. ),
  165. color=get_color_from_hex(
  166. defined_button.get("fg_color", "ffffff")
  167. ),
  168. font_size=defined_button.get("fontsize", 14),
  169. halign="center",
  170. valign="middle",
  171. background_normal="",
  172. )
  173. cmd = defined_button.get("cmd", "")
  174. # pylint: disable=no-member
  175. btn.bind( # pyright: ignore
  176. on_release=lambda _, cmd=cmd: self.execute_command_async(
  177. cmd
  178. )
  179. )
  180. else:
  181. btn = Button(
  182. background_color=get_color_from_hex("cccccc"),
  183. )
  184. layout.add_widget(btn)
  185. return layout
  186. def config_error_exit(self, popup):
  187. print("wow")
  188. popup.dismiss()
  189. sys.exit(1)
  190. def execute_command_async(self, cmd):
  191. if cmd:
  192. try:
  193. subprocess.Popen( # pylint: disable=consider-using-with
  194. cmd, shell=True
  195. )
  196. log(f"Executed command: {cmd}")
  197. except Exception as e:
  198. log(f"Error executing command: {e}", color="yellow")
  199. if __name__ == "__main__":
  200. colorama.init()
  201. VulcanBoardApp().run()