-rw-r--r-- qtools/widget/habit_tracker.py
1 # Copyright (c) 2020 Matt Colligan 2 # 3 # Permission is hereby granted, free of charge, to any person obtaining a copy 4 # of this software and associated documentation files (the "Software"), to deal 5 # in the Software without restriction, including without limitation the rights 6 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 # copies of the Software, and to permit persons to whom the Software is 8 # furnished to do so, subject to the following conditions: 9 # 10 # The above copyright notice and this permission notice shall be included in 11 # all copies or substantial portions of the Software. 12 # 13 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 # SOFTWARE. 20 21 22 import json 23 import os 24 from datetime import datetime, timedelta 25 26 from libqtile import bar 27 from libqtile.log_utils import logger 28 from libqtile.utils import get_cache_dir 29 from libqtile.widget import base 30 31 32 _CACHE = os.path.join(get_cache_dir(), 'habit_tracker_count.json') 33 34 35 class HabitTracker(base._Widget): 36 """ 37 A don't-break-the-chain style habit tracker widget. 38 39 The current chain lengths are stored in a JSON file containing a dictionary where 40 each key is the name of a habit. This habit can be passed to the widget to identify 41 a chain. 42 43 The chain can be drawn in different styles: 44 45 - "chain": A simple chain of connected squares that grows through the grid as it 46 increases in length. 47 - "base": A grid of squares where each column represents one digit in a counting 48 scheme using the base (rows + 1). For example, HabitTracker(rows=1) would draw 49 a single row of squares that are filled in to represent a binary count. 50 51 """ 52 defaults = [ 53 ("colour", "1667EB", "Fill colour."), 54 ("chain_file", _CACHE, "File that stores the chain lengths."), 55 ("habit", "anon", "Habit name. Used for identifying the chain in the cache file."), 56 ("margin_x", 4, "X margin."), 57 ("margin_y", 4, "Y margin."), 58 ("style", "chain", "Counter style, one of: chain, base"), 59 ("rows", 2, "Number of rows."), 60 ("columns", 4, "Number of columns."), 61 ("blank_colour", None, "Colour for placeholder blocks."), 62 ] 63 64 def __init__(self, **config): 65 base._Widget.__init__(self, bar.CALCULATED, **config) 66 self.add_defaults(HabitTracker.defaults) 67 self._chain = None 68 self._block_size = 0 69 70 self._load_chain() 71 72 if not hasattr(self, "draw_{0}".format(self.style)): 73 logger.warning("HabitTracker style '{0}' invalid.".format(self.style)) 74 self.style = "chain" 75 76 if 'Button1' not in self.mouse_callbacks: 77 self.mouse_callbacks.update({'Button1': self.cmd_increment}) 78 if 'Button2' not in self.mouse_callbacks: 79 self.mouse_callbacks.update({'Button2': self.cmd_reset}) 80 if 'Button3' not in self.mouse_callbacks: 81 self.mouse_callbacks.update({'Button3': self.cmd_decrement}) 82 83 def _load_chain(self): 84 if os.path.isfile(self.chain_file): 85 with open(self.chain_file, 'r') as fd: 86 cache = json.load(fd) 87 if self.habit in cache.keys(): 88 start_date = datetime.strptime(cache.get(self.habit), "%Y-%m-%d") 89 self._chain = (datetime.now() - start_date).days 90 return 91 self._chain = 0 92 self._save_chain() 93 94 def _save_chain(self): 95 cache = {} 96 if os.path.isfile(self.chain_file): 97 with open(self.chain_file, 'r') as fd: 98 cache.update(json.load(fd)) 99 start_date = datetime.now() - timedelta(days=self._chain) 100 cache.update({self.habit: start_date.strftime("%Y-%m-%d")}) 101 with open(self.chain_file, 'w') as fd: 102 json.dump(cache, fd) 103 104 def cmd_increment(self, qtile=None): 105 self._chain += 1 106 self._save_chain() 107 self.draw() 108 109 def cmd_decrement(self, qtile=None): 110 if self._chain > 0: 111 self._chain -= 1 112 self._save_chain() 113 self.draw() 114 115 def cmd_reset(self, qtile=None): 116 self._chain = 0 117 self._save_chain() 118 self.draw() 119 120 def calculate_length(self): 121 space = self.bar.height - self.margin_y * 2 122 self._block_size = space // (2 * self.rows - 1) 123 length = self._block_size * (2 * self.columns - 1) 124 return length + self.margin_x * 2 125 126 def draw(self): 127 self.drawer.clear(self.background or self.bar.background) 128 getattr(self, "draw_{0}".format(self.style))() 129 130 def draw_chain(self): 131 block_size = self._block_size 132 start_y = self.bar.height - self.margin_y - block_size 133 134 if self.blank_colour: 135 self.drawer.set_source_rgb(self.blank_colour) 136 for col in range(self.columns): 137 x_pos = self.margin_x + col * 2 * block_size 138 for row in range(self.rows): 139 y_pos = start_y - row * 2 * block_size 140 self.drawer.ctx.rectangle(x_pos, y_pos, block_size, block_size) 141 self.drawer.ctx.fill() 142 143 self.drawer.set_source_rgb(self.colour) 144 chain = self._chain 145 for col in range(chain // self.rows + 1): 146 x_pos = self.margin_x + col * 2 * block_size 147 this_col = min(chain - col * self.rows, self.rows) 148 rows = range(this_col) 149 if col % 2: 150 rows = [self.rows - 1 - i for i in rows] 151 for row in rows: 152 chain % ((col + 1) * self.rows) 153 y_pos = start_y - row * 2 * block_size 154 self.drawer.ctx.rectangle(x_pos, y_pos, block_size, block_size) 155 156 self.drawer.ctx.fill() 157 self.drawer.draw(offsetx=self.offset, width=self.length) 158 159 def draw_base(self): 160 block_size = self._block_size 161 start_y = self.bar.height - self.margin_y - block_size 162 163 if self.blank_colour: 164 self.drawer.set_source_rgb(self.blank_colour) 165 for col in range(self.columns): 166 x_pos = self.margin_x + col * 2 * block_size 167 for row in range(self.rows): 168 y_pos = start_y - row * 2 * block_size 169 self.drawer.ctx.rectangle(x_pos, y_pos, block_size, block_size) 170 self.drawer.ctx.fill() 171 172 self.drawer.set_source_rgb(self.colour) 173 chain = self._chain 174 for col in reversed(range(self.columns)): 175 units, chain = divmod(chain, (self.rows + 1) ** col) 176 if units: 177 x_pos = self.margin_x + col * 2 * block_size 178 for row in range(units): 179 y_pos = start_y - row * 2 * block_size 180 self.drawer.ctx.rectangle(x_pos, y_pos, block_size, block_size) 181 182 self.drawer.ctx.fill() 183 self.drawer.draw(offsetx=self.offset, width=self.length) 184