VulcanBoard.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  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. def error_msg(msg: str):
  26. print(colored("[*] Error: {}".format(msg), "red"))
  27. sys.exit(1)
  28. def log(message: str, color="green") -> None:
  29. print(colored("[*] {}".format(message), color)) # pyright: ignore
  30. def get_config_path():
  31. if name == "nt":
  32. return path.join(getenv("APPDATA", ""), "VulcanBoard", "config.yml")
  33. xdg_config_home = getenv("XDG_CONFIG_HOME", path.expanduser("~/.config"))
  34. return path.join(xdg_config_home, "VulcanBoard", "config.yml")
  35. def is_valid_hexcolor(hexcolor: str) -> bool:
  36. if len(hexcolor) != 6:
  37. return False
  38. valid_hex_chars = list("012345789abcdef")
  39. for char in hexcolor.lower():
  40. if char not in valid_hex_chars:
  41. return False
  42. return True
  43. @dataclass
  44. class Config:
  45. columns: int
  46. rows: int
  47. buttons: list[dict]
  48. @dataclass
  49. class ConfigLoader:
  50. config_path: str
  51. def __post_init__( # pylint: disable=inconsistent-return-statements
  52. self,
  53. ) -> Config:
  54. try:
  55. with open(self.config_path, "r", encoding="utf-8") as config_reader:
  56. yaml_config = yaml.safe_load(config_reader)
  57. self.columns = yaml_config.get("columns")
  58. self.rows = yaml_config.get("rows")
  59. self.buttons = yaml_config.get("buttons")
  60. return self.__interpret_config()
  61. except (FileNotFoundError, PermissionError, IOError) as error:
  62. error_msg(
  63. f"Error: Could not access config file at {self.config_path}. Reason: {error}"
  64. )
  65. except yaml.YAMLError as error:
  66. error_msg(f"Error parsing config file. Reason: {error}")
  67. def __interpret_config(self) -> Config:
  68. self.__validate_dimensions()
  69. self.__validate_buttons()
  70. return Config(self.columns, self.rows, self.buttons)
  71. def __validate_buttons(self) -> None:
  72. if not isinstance(self.buttons, list):
  73. error_msg("invalid button config. needs to be a list of dicts.")
  74. for button in self.buttons:
  75. if not isinstance(button, dict):
  76. error_msg("invalid button config. needs to be a list of dicts.")
  77. if (
  78. not isinstance(dimensions := button.get("position", ""), list)
  79. or (not isinstance(dimensions[0], int))
  80. or (not isinstance(dimensions[1], int))
  81. or (0 > dimensions[0] or dimensions[0] > self.rows - 1)
  82. or (0 > dimensions[1] or dimensions[1] > self.columns - 1)
  83. ):
  84. error_msg(f"invalid button 'position' subentry: '{dimensions}'")
  85. for entry in ("txt", "cmd"):
  86. if not isinstance(result := button.get(entry, ""), str):
  87. error_msg(f"invalid button '{entry}' subentry: '{result}'")
  88. if not isinstance(
  89. bg_color := button.get("bg_color", "cccccc"), str
  90. ) or not is_valid_hexcolor(bg_color):
  91. error_msg(f"invalid button 'bg_color' subentry: '{bg_color}'")
  92. if not isinstance(
  93. fg_color := button.get("fg_color", "ffffff"), str
  94. ) or not is_valid_hexcolor(bg_color):
  95. error_msg(f"invalid button 'fg_color' subentry: '{fg_color}'")
  96. if (
  97. not isinstance(fontsize := button.get("fontsize", ""), int)
  98. or 0 > fontsize
  99. ):
  100. error_msg(f"invalid button 'fontsize' subentry: '{fontsize}'")
  101. def __validate_dimensions(self) -> None:
  102. for dimension in (self.columns, self.rows):
  103. if not isinstance(dimension, int) or (dimension <= 0):
  104. error_msg(f"invalid dimension: {dimension}")
  105. class VulcanBoardApp(App):
  106. def build(self) -> GridLayout:
  107. config = ConfigLoader(get_config_path())
  108. button_map = {
  109. (btn["position"][0], btn["position"][1]): btn
  110. for btn in config.buttons
  111. }
  112. layout = GridLayout(
  113. cols=config.columns, rows=config.rows, spacing=5, padding=5
  114. )
  115. # Populate grid with buttons and placeholders
  116. for row in range(config.rows):
  117. for col in range(config.columns):
  118. defined_button = button_map.get((row, col))
  119. if defined_button:
  120. btn = Button(
  121. text=defined_button.get("txt", ""),
  122. background_color=get_color_from_hex(
  123. defined_button.get("bg_color", "aaaaff")
  124. ),
  125. color=get_color_from_hex(
  126. defined_button.get("fg_color", "ffffff")
  127. ),
  128. font_size=defined_button.get("fontsize", 14),
  129. halign="center",
  130. valign="middle",
  131. background_normal="",
  132. )
  133. cmd = defined_button.get("cmd", "")
  134. # pylint: disable=no-member
  135. btn.bind( # pyright: ignore
  136. on_release=lambda instance, cmd=cmd: self.execute_command_async(
  137. cmd
  138. )
  139. )
  140. else:
  141. btn = Button(
  142. background_color=get_color_from_hex("cccccc"),
  143. )
  144. layout.add_widget(btn)
  145. return layout
  146. def execute_command_async(self, cmd):
  147. if cmd:
  148. try:
  149. subprocess.Popen( # pylint: disable=consider-using-with
  150. cmd, shell=True
  151. )
  152. log(f"Executed command: {cmd}")
  153. except Exception as e:
  154. log(f"Error executing command: {e}", color="yellow")
  155. if __name__ == "__main__":
  156. colorama.init()
  157. VulcanBoardApp().run()