canvas_util.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727
  1. import tkinter as tk
  2. import cmath
  3. import sys
  4. import logging
  5. from decimal import Decimal
  6. # these imports make autodoc easier to run
  7. try:
  8. from engineering_notation import EngNumber
  9. except ImportError:
  10. pass
  11. try:
  12. from tk_tools.images import (
  13. rotary_scale,
  14. led_green,
  15. led_green_on,
  16. led_yellow,
  17. led_yellow_on,
  18. led_red,
  19. led_red_on,
  20. led_grey,
  21. )
  22. except ImportError:
  23. pass
  24. logger = logging.getLogger(__name__)
  25. logger.setLevel(logging.DEBUG)
  26. if getattr(sys, "frozen", False):
  27. frozen = True
  28. else:
  29. frozen = False
  30. logger.info("frozen: {}".format(frozen))
  31. class Dial(tk.Frame):
  32. """
  33. Base class for all dials and dial-like widgets
  34. """
  35. def __init__(self, parent, size: int = 100, **options):
  36. self._parent = parent
  37. super().__init__(self._parent, padx=3, pady=3, borderwidth=2, **options)
  38. self.size = size
  39. def to_absolute(self, x, y):
  40. """
  41. Converts coordinates provided with reference to the center \
  42. of the canvas (0, 0) to absolute coordinates which are used \
  43. by the canvas object in which (0, 0) is located in the top \
  44. left of the object.
  45. :param x: x value in pixels
  46. :param y: x value in pixels
  47. :return: None
  48. """
  49. return x + self.size / 2, y + self.size / 2
  50. class Compass(Dial):
  51. """
  52. Displays a compass typically seen on a map
  53. """
  54. def __init__(self, parent, size: int = 100, **options):
  55. super().__init__(parent, size=size, **options)
  56. raise NotImplementedError()
  57. # todo: import an image, place the image on the canvas, then place
  58. # an arrow on top of the image
  59. class RotaryScale(Dial):
  60. """
  61. Shows a rotary scale, much like a speedometer.::
  62. rs = tk_tools.RotaryScale(root, max_value=100.0, size=100, unit='km/h')
  63. rs.grid(row=0, column=0)
  64. rs.set_value(10)
  65. :param parent: tkinter parent frame
  66. :param max_value: the value corresponding to the maximum value on the scale
  67. :param size: the size in pixels
  68. :param options: the frame options
  69. """
  70. def __init__(
  71. self,
  72. parent,
  73. max_value: (float, int) = 100.0,
  74. size: (float, int) = 100,
  75. unit: str = None,
  76. img_data: str = None,
  77. needle_color="blue",
  78. needle_thickness=0,
  79. **options
  80. ):
  81. super().__init__(parent, size=size, **options)
  82. self.max_value = float(max_value)
  83. self.size = size
  84. self.unit = "" if not unit else unit
  85. self.needle_color = needle_color
  86. self.needle_thickness = needle_thickness
  87. self.canvas = tk.Canvas(self, width=self.size, height=self.size)
  88. self.canvas.grid(row=0)
  89. self.readout = tk.Label(self, text="-{}".format(self.unit))
  90. self.readout.grid(row=1)
  91. if img_data:
  92. self.image = tk.PhotoImage(data=img_data)
  93. else:
  94. self.image = tk.PhotoImage(data=rotary_scale)
  95. self.image = self.image.subsample(int(200 / self.size), int(200 / self.size))
  96. initial_value = 0.0
  97. self.set_value(initial_value)
  98. def set_value(self, number: (float, int)):
  99. """
  100. Sets the value of the graphic
  101. :param number: the number (must be between 0 and \
  102. 'max_range' or the scale will peg the limits
  103. :return: None
  104. """
  105. self.canvas.delete("all")
  106. self.canvas.create_image(0, 0, image=self.image, anchor="nw")
  107. number = number if number <= self.max_value else self.max_value
  108. number = 0.0 if number < 0.0 else number
  109. radius = 0.9 * self.size / 2.0
  110. angle_in_radians = (2.0 * cmath.pi / 3.0) + number / self.max_value * (
  111. 5.0 * cmath.pi / 3.0
  112. )
  113. center = cmath.rect(0, 0)
  114. outer = cmath.rect(radius, angle_in_radians)
  115. if self.needle_thickness == 0:
  116. line_width = int(5 * self.size / 200)
  117. line_width = 1 if line_width < 1 else line_width
  118. else:
  119. line_width = self.needle_thickness
  120. self.canvas.create_line(
  121. *self.to_absolute(center.real, center.imag),
  122. *self.to_absolute(outer.real, outer.imag),
  123. width=line_width,
  124. fill=self.needle_color
  125. )
  126. self.readout["text"] = "{}{}".format(number, self.unit)
  127. def _draw_background(self, divisions: int = 10):
  128. """
  129. Draws the background of the dial
  130. :param divisions: the number of divisions
  131. between 'ticks' shown on the dial
  132. :return: None
  133. """
  134. self.canvas.create_arc(
  135. 2,
  136. 2,
  137. self.size - 2,
  138. self.size - 2,
  139. style=tk.PIESLICE,
  140. start=-60,
  141. extent=30,
  142. fill="red",
  143. )
  144. self.canvas.create_arc(
  145. 2,
  146. 2,
  147. self.size - 2,
  148. self.size - 2,
  149. style=tk.PIESLICE,
  150. start=-30,
  151. extent=60,
  152. fill="yellow",
  153. )
  154. self.canvas.create_arc(
  155. 2,
  156. 2,
  157. self.size - 2,
  158. self.size - 2,
  159. style=tk.PIESLICE,
  160. start=30,
  161. extent=210,
  162. fill="green",
  163. )
  164. # find the distance between the center and the inner tick radius
  165. inner_tick_radius = int(self.size * 0.4)
  166. outer_tick_radius = int(self.size * 0.5)
  167. for tick in range(divisions):
  168. angle_in_radians = (2.0 * cmath.pi / 3.0) + tick / divisions * (
  169. 5.0 * cmath.pi / 3.0
  170. )
  171. inner_point = cmath.rect(inner_tick_radius, angle_in_radians)
  172. outer_point = cmath.rect(outer_tick_radius, angle_in_radians)
  173. self.canvas.create_line(
  174. *self.to_absolute(inner_point.real, inner_point.imag),
  175. *self.to_absolute(outer_point.real, outer_point.imag),
  176. width=1
  177. )
  178. class Gauge(tk.Frame):
  179. """
  180. Shows a gauge, much like the RotaryGauge.::
  181. gauge = tk_tools.Gauge(root, max_value=100.0,
  182. label='speed', unit='km/h')
  183. gauge.grid()
  184. gauge.set_value(10)
  185. :param parent: tkinter parent frame
  186. :param width: canvas width
  187. :param height: canvas height
  188. :param min_value: the minimum value
  189. :param max_value: the maximum value
  190. :param label: the label on the scale
  191. :param unit: the unit to show on the scale
  192. :param divisions: the number of divisions on the scale
  193. :param yellow: the beginning of the yellow (warning) zone in percent
  194. :param red: the beginning of the red (danger) zone in percent
  195. :param yellow_low: in percent warning for low values
  196. :param red_low: in percent if very low values are a danger
  197. :param bg: background
  198. """
  199. def __init__(
  200. self,
  201. parent,
  202. width: int = 200,
  203. height: int = 100,
  204. min_value=0.0,
  205. max_value=100.0,
  206. label="",
  207. unit="",
  208. divisions=8,
  209. yellow=50,
  210. red=80,
  211. yellow_low=0,
  212. red_low=0,
  213. bg="lightgrey",
  214. ):
  215. self._parent = parent
  216. self._width = width
  217. self._height = height
  218. self._label = label
  219. self._unit = unit
  220. self._divisions = divisions
  221. self._min_value = EngNumber(min_value)
  222. self._max_value = EngNumber(max_value)
  223. self._average_value = EngNumber((max_value + min_value) / 2)
  224. self._yellow = yellow * 0.01
  225. self._red = red * 0.01
  226. self._yellow_low = yellow_low * 0.01
  227. self._red_low = red_low * 0.01
  228. super().__init__(self._parent)
  229. self._canvas = tk.Canvas(self, width=self._width, height=self._height, bg=bg)
  230. self._canvas.grid(row=0, column=0, sticky="news")
  231. self._min_value = EngNumber(min_value)
  232. self._max_value = EngNumber(max_value)
  233. self._value = self._min_value
  234. self._redraw()
  235. def _redraw(self):
  236. self._canvas.delete("all")
  237. max_angle = 120.0
  238. value_as_percent = (self._value - self._min_value) / (
  239. self._max_value - self._min_value
  240. )
  241. value = float(max_angle * value_as_percent)
  242. # no int() => accuracy
  243. # create the tick marks and colors across the top
  244. for i in range(self._divisions):
  245. extent = max_angle / self._divisions
  246. start = 150.0 - i * extent
  247. rate = (i + 1) / (self._divisions + 1)
  248. if rate < self._red_low:
  249. bg_color = "red"
  250. elif rate <= self._yellow_low:
  251. bg_color = "yellow"
  252. elif rate <= self._yellow:
  253. bg_color = "green"
  254. elif rate <= self._red:
  255. bg_color = "yellow"
  256. else:
  257. bg_color = "red"
  258. self._canvas.create_arc(
  259. 0,
  260. int(self._height * 0.15),
  261. self._width,
  262. int(self._height * 1.8),
  263. start=start,
  264. extent=-extent,
  265. width=2,
  266. fill=bg_color,
  267. style="pie",
  268. )
  269. bg_color = "white"
  270. red = "#c21807"
  271. ratio = 0.06
  272. self._canvas.create_arc(
  273. self._width * ratio,
  274. int(self._height * 0.25),
  275. self._width * (1.0 - ratio),
  276. int(self._height * 1.8 * (1.0 - ratio * 1.1)),
  277. start=150,
  278. extent=-120,
  279. width=2,
  280. fill=bg_color,
  281. style="pie",
  282. )
  283. # readout & title
  284. self.readout(self._value, "black") # BG black if OK
  285. # display lowest value
  286. value_text = "{}".format(self._min_value)
  287. self._canvas.create_text(
  288. self._width * 0.1,
  289. self._height * 0.7,
  290. font=("Courier New", 10),
  291. text=value_text,
  292. )
  293. # display greatest value
  294. value_text = "{}".format(self._max_value)
  295. self._canvas.create_text(
  296. self._width * 0.9,
  297. self._height * 0.7,
  298. font=("Courier New", 10),
  299. text=value_text,
  300. )
  301. # display center value
  302. value_text = "{}".format(self._average_value)
  303. self._canvas.create_text(
  304. self._width * 0.5,
  305. self._height * 0.1,
  306. font=("Courier New", 10),
  307. text=value_text,
  308. )
  309. # create first half (red needle)
  310. self._canvas.create_arc(
  311. 0,
  312. int(self._height * 0.15),
  313. self._width,
  314. int(self._height * 1.8),
  315. start=150,
  316. extent=-value,
  317. width=3,
  318. outline=red,
  319. )
  320. # create inset red
  321. self._canvas.create_arc(
  322. self._width * 0.35,
  323. int(self._height * 0.75),
  324. self._width * 0.65,
  325. int(self._height * 1.2),
  326. start=150,
  327. extent=-120,
  328. width=1,
  329. outline="grey",
  330. fill=red,
  331. style="pie",
  332. )
  333. # create the overlapping border
  334. self._canvas.create_arc(
  335. 0,
  336. int(self._height * 0.15),
  337. self._width,
  338. int(self._height * 1.8),
  339. start=150,
  340. extent=-120,
  341. width=4,
  342. outline="#343434",
  343. )
  344. def readout(self, value, bg): # value, BG color
  345. # draw the black behind the readout
  346. r_width = 95
  347. r_height = 20
  348. r_offset = 8
  349. self._canvas.create_rectangle(
  350. self._width / 2.0 - r_width / 2.0,
  351. self._height / 2.0 - r_height / 2.0 + r_offset,
  352. self._width / 2.0 + r_width / 2.0,
  353. self._height / 2.0 + r_height / 2.0 + r_offset,
  354. fill=bg,
  355. outline="grey",
  356. )
  357. # the digital readout
  358. self._canvas.create_text(
  359. self._width * 0.5,
  360. self._height * 0.5 - r_offset,
  361. font=("Courier New", 10),
  362. text=self._label,
  363. )
  364. value_text = "{}{}".format(self._value, self._unit)
  365. self._canvas.create_text(
  366. self._width * 0.5,
  367. self._height * 0.5 + r_offset,
  368. font=("Courier New", 10),
  369. text=value_text,
  370. fill="white",
  371. )
  372. def set_value(self, value):
  373. self._value = EngNumber(value)
  374. if self._min_value * 1.02 < value < self._max_value * 0.98:
  375. self._redraw() # refresh all
  376. else: # OFF limits refresh only readout
  377. self.readout(self._value, "red") # on RED BackGround
  378. class Graph(tk.Frame):
  379. """
  380. Tkinter native graph (pretty basic, but doesn't require heavy install).::
  381. graph = tk_tools.Graph(
  382. parent=root,
  383. x_min=-1.0,
  384. x_max=1.0,
  385. y_min=0.0,
  386. y_max=2.0,
  387. x_tick=0.2,
  388. y_tick=0.2,
  389. width=500,
  390. height=400
  391. )
  392. graph.grid(row=0, column=0)
  393. # create an initial line
  394. line_0 = [(x/10, x/10) for x in range(10)]
  395. graph.plot_line(line_0)
  396. :param parent: the parent frame
  397. :param x_min: the x minimum
  398. :param x_max: the x maximum
  399. :param y_min: the y minimum
  400. :param y_max: the y maximum
  401. :param x_tick: the 'tick' on the x-axis
  402. :param y_tick: the 'tick' on the y-axis
  403. :param options: additional valid tkinter.canvas options
  404. """
  405. def __init__(
  406. self,
  407. parent,
  408. x_min: float,
  409. x_max: float,
  410. y_min: float,
  411. y_max: float,
  412. x_tick: float,
  413. y_tick: float,
  414. **options
  415. ):
  416. self._parent = parent
  417. super().__init__(self._parent, **options)
  418. self.canvas = tk.Canvas(self)
  419. self.canvas.grid(row=0, column=0)
  420. self.w = float(self.canvas.config("width")[4])
  421. self.h = float(self.canvas.config("height")[4])
  422. self.x_min = x_min
  423. self.x_max = x_max
  424. self.x_tick = x_tick
  425. self.y_min = y_min
  426. self.y_max = y_max
  427. self.y_tick = y_tick
  428. self.px_x = (self.w - 100) / ((x_max - x_min) / x_tick)
  429. self.px_y = (self.h - 100) / ((y_max - y_min) / y_tick)
  430. self.draw_axes()
  431. def draw_axes(self):
  432. """
  433. Removes all existing series and re-draws the axes.
  434. :return: None
  435. """
  436. self.canvas.delete("all")
  437. rect = 50, 50, self.w - 50, self.h - 50
  438. self.canvas.create_rectangle(rect, outline="black")
  439. for x in self.frange(0, self.x_max - self.x_min + 1, self.x_tick):
  440. value = Decimal(self.x_min + x)
  441. if self.x_min <= value <= self.x_max:
  442. x_step = (self.px_x * x) / self.x_tick
  443. coord = 50 + x_step, self.h - 50, 50 + x_step, self.h - 45
  444. self.canvas.create_line(coord, fill="black")
  445. coord = 50 + x_step, self.h - 40
  446. label = round(Decimal(self.x_min + x), 1)
  447. self.canvas.create_text(coord, fill="black", text=label)
  448. for y in self.frange(0, self.y_max - self.y_min + 1, self.y_tick):
  449. value = Decimal(self.y_max - y)
  450. if self.y_min <= value <= self.y_max:
  451. y_step = (self.px_y * y) / self.y_tick
  452. coord = 45, 50 + y_step, 50, 50 + y_step
  453. self.canvas.create_line(coord, fill="black")
  454. coord = 35, 50 + y_step
  455. label = round(value, 1)
  456. self.canvas.create_text(coord, fill="black", text=label)
  457. def plot_point(self, x, y, visible=True, color="black", size=5):
  458. """
  459. Places a single point on the grid
  460. :param x: the x coordinate
  461. :param y: the y coordinate
  462. :param visible: True if the individual point should be visible
  463. :param color: the color of the point
  464. :param size: the point size in pixels
  465. :return: The absolute coordinates as a tuple
  466. """
  467. xp = (self.px_x * (x - self.x_min)) / self.x_tick
  468. yp = (self.px_y * (self.y_max - y)) / self.y_tick
  469. coord = 50 + xp, 50 + yp
  470. if visible:
  471. # divide down to an appropriate size
  472. size = int(size / 2) if int(size / 2) > 1 else 1
  473. x, y = coord
  474. self.canvas.create_oval(x - size, y - size, x + size, y + size, fill=color)
  475. return coord
  476. def plot_line(self, points: list, color="black", point_visibility=False):
  477. """
  478. Plot a line of points
  479. :param points: a list of tuples, each tuple containing an (x, y) point
  480. :param color: the color of the line
  481. :param point_visibility: True if the points \
  482. should be individually visible
  483. :return: None
  484. """
  485. last_point = ()
  486. for point in points:
  487. this_point = self.plot_point(
  488. point[0], point[1], color=color, visible=point_visibility
  489. )
  490. if last_point:
  491. self.canvas.create_line(last_point + this_point, fill=color)
  492. last_point = this_point
  493. # print last_point
  494. @staticmethod
  495. def frange(start, stop, step, digits_to_round=3):
  496. """
  497. Works like range for doubles
  498. :param start: starting value
  499. :param stop: ending value
  500. :param step: the increment_value
  501. :param digits_to_round: the digits to which to round \
  502. (makes floating-point numbers much easier to work with)
  503. :return: generator
  504. """
  505. while start < stop:
  506. yield round(start, digits_to_round)
  507. start += step
  508. class Led(tk.Frame):
  509. """
  510. Create an LED-like interface for the user.::
  511. led = tk_tools.Led(root, size=50)
  512. led.pack()
  513. led.to_red()
  514. led.to_green(on=True)
  515. The user also has the option of adding an `on_click_callback`
  516. function. When the button is clicked, the button will change
  517. state and the on-click callback will be executed. The
  518. callback must accept a single boolean parameter, `on`, which
  519. indicates if the LED was just turned on or off.
  520. :param parent: the parent frame
  521. :param size: the size in pixels
  522. :param on_click_callback: a callback which accepts a boolean parameter 'on'
  523. :param options: the frame options
  524. """
  525. def __init__(
  526. self,
  527. parent,
  528. size: int = 100,
  529. on_click_callback: callable = None,
  530. toggle_on_click: bool = False,
  531. **options
  532. ):
  533. self._parent = parent
  534. super().__init__(self._parent, padx=3, pady=3, borderwidth=0, **options)
  535. self._size = size
  536. canvas_opts = {}
  537. if "bg" in options.keys():
  538. canvas_opts["bg"] = options.get("bg")
  539. canvas_opts["highlightthickness"] = 0
  540. canvas_opts["width"] = self._size
  541. canvas_opts["height"] = self._size
  542. self._canvas = tk.Canvas(self, **canvas_opts)
  543. self._canvas.grid(row=0)
  544. self._image = None
  545. self._on = False
  546. self._user_click_callback = on_click_callback
  547. self._toggle_on_click = toggle_on_click
  548. self.to_grey()
  549. def on_click(e):
  550. if self._user_click_callback is not None:
  551. self._user_click_callback(self._on)
  552. self._canvas.bind("<Button-1>", on_click)
  553. def _load_new(self, img_data: str):
  554. """
  555. Load a new image.
  556. :param img_data: the image data as a base64 string
  557. :return: None
  558. """
  559. self._image = tk.PhotoImage(data=img_data)
  560. self._image = self._image.subsample(
  561. int(200 / self._size), int(200 / self._size)
  562. )
  563. self._canvas.delete("all")
  564. self._canvas.create_image(0, 0, image=self._image, anchor="nw")
  565. if self._user_click_callback is not None:
  566. self._user_click_callback(self._on)
  567. def to_grey(self, on: bool = False):
  568. """
  569. Change the LED to grey.
  570. :param on: Unused, here for API consistency with the other states
  571. :return: None
  572. """
  573. self._on = False
  574. self._load_new(led_grey)
  575. def to_green(self, on: bool = False):
  576. """
  577. Change the LED to green (on or off).
  578. :param on: True or False
  579. :return: None
  580. """
  581. self._on = on
  582. if on:
  583. self._load_new(led_green_on)
  584. if self._toggle_on_click:
  585. self._canvas.bind("<Button-1>", lambda x: self.to_green(False))
  586. else:
  587. self._load_new(led_green)
  588. if self._toggle_on_click:
  589. self._canvas.bind("<Button-1>", lambda x: self.to_green(True))
  590. def to_red(self, on: bool = False):
  591. """
  592. Change the LED to red (on or off)
  593. :param on: True or False
  594. :return: None
  595. """
  596. self._on = on
  597. if on:
  598. self._load_new(led_red_on)
  599. if self._toggle_on_click:
  600. self._canvas.bind("<Button-1>", lambda x: self.to_red(False))
  601. else:
  602. self._load_new(led_red)
  603. if self._toggle_on_click:
  604. self._canvas.bind("<Button-1>", lambda x: self.to_red(True))
  605. def to_yellow(self, on: bool = False):
  606. """
  607. Change the LED to yellow (on or off)
  608. :param on: True or False
  609. :return: None
  610. """
  611. self._on = on
  612. if on:
  613. self._load_new(led_yellow_on)
  614. if self._toggle_on_click:
  615. self._canvas.bind("<Button-1>", lambda x: self.to_yellow(False))
  616. else:
  617. self._load_new(led_yellow)
  618. if self._toggle_on_click:
  619. self._canvas.bind("<Button-1>", lambda x: self.to_yellow(True))