|
1 | 1 | import locale |
2 | 2 | import textwrap |
3 | 3 | import re |
| 4 | +import subprocess |
4 | 5 | import units as gc_units |
5 | 6 |
|
6 | 7 | from translation import _ |
7 | 8 | from flox import Flox |
8 | 9 |
|
| 10 | +locale.setlocale(locale.LC_NUMERIC, "") |
| 11 | + |
| 12 | + |
| 13 | +# --------------------------------------------------------------------------- |
| 14 | +# Unit lookup helpers |
| 15 | +# --------------------------------------------------------------------------- |
| 16 | + |
| 17 | +def _find_unit(abbr: str): |
| 18 | + """Return (category_key, unit_tuple) or (None, None).""" |
| 19 | + for cat_key, cat in gc_units.units.items(): |
| 20 | + for u in cat["units"]: |
| 21 | + if abbr in (u[0], u[1], u[2]): |
| 22 | + return cat_key, u |
| 23 | + return None, None |
| 24 | + |
| 25 | + |
| 26 | +# --------------------------------------------------------------------------- |
| 27 | +# Public API |
| 28 | +# --------------------------------------------------------------------------- |
| 29 | + |
| 30 | +def get_all_units(short: bool = False) -> list: |
| 31 | + """Return [[category, unit, ...], ...] — index 0 is always the category name.""" |
| 32 | + result = [] |
| 33 | + for cat_key, cat in gc_units.units.items(): |
| 34 | + row = [cat_key] + [u[0] if short else f"{u[1]} ({u[0]})" for u in cat["units"]] |
| 35 | + result.append(row) |
| 36 | + return result |
| 37 | + |
| 38 | + |
| 39 | +def get_hints_for_category(from_unit: str) -> list: |
| 40 | + """Return abbreviations of all other units in the same category.""" |
| 41 | + cat_key, _ = _find_unit(from_unit) |
| 42 | + if not cat_key: |
| 43 | + return [_("no valid units")] |
| 44 | + return [u[0] for u in gc_units.units[cat_key]["units"] if u[0] != from_unit] |
| 45 | + |
| 46 | + |
| 47 | +def gen_convert(amount: float, from_unit: str, to_unit: str) -> dict: |
| 48 | + """Convert amount from from_unit to to_unit. |
| 49 | +
|
| 50 | + Returns a result dict on success or {"Error": reason} on failure. |
| 51 | + """ |
| 52 | + if from_unit == to_unit: |
| 53 | + return {"Error": _("To and from unit is the same")} |
| 54 | + |
| 55 | + from_cat, src = _find_unit(from_unit) |
| 56 | + to_cat, dst = _find_unit(to_unit) |
| 57 | + |
| 58 | + if not src or not dst: |
| 59 | + return {"Error": _("Problem converting {} and {}").format(from_unit, to_unit)} |
| 60 | + if from_cat != to_cat: |
| 61 | + return {"Error": _("Units are from different categories")} |
| 62 | + |
| 63 | + return { |
| 64 | + "category": from_cat, |
| 65 | + "converted": gc_units.convert(amount, src[0], dst[0], from_cat), |
| 66 | + "fromabbrev": src[0], |
| 67 | + "fromlong": src[1], |
| 68 | + "fromplural": src[2], |
| 69 | + "toabbrev": dst[0], |
| 70 | + "tolong": dst[1], |
| 71 | + "toplural": dst[2], |
| 72 | + } |
| 73 | + |
| 74 | + |
| 75 | +def smart_precision(separator: str, amount: float, preferred: int = 3) -> int: |
| 76 | + """Return an appropriate number of decimal places for display.""" |
| 77 | + s = str(amount) |
| 78 | + dec_places = s[::-1].find(separator) |
| 79 | + if dec_places == -1: |
| 80 | + return 0 |
| 81 | + frac = s[-dec_places:] |
| 82 | + if int(frac) == 0: |
| 83 | + return 0 |
| 84 | + fnz = re.search(r"[1-9]", frac).start() |
| 85 | + return preferred if fnz < preferred else fnz + 1 |
| 86 | + |
| 87 | + |
| 88 | +# --------------------------------------------------------------------------- |
| 89 | +# Flox plugin |
| 90 | +# --------------------------------------------------------------------------- |
9 | 91 |
|
10 | 92 | class GenConvert(Flox): |
11 | | - locale.setlocale(locale.LC_NUMERIC, "") |
12 | 93 |
|
13 | 94 | def __init__(self, *args, **kwargs): |
14 | 95 | super().__init__(*args, **kwargs) |
15 | 96 | self.logger_level("info") |
16 | 97 |
|
17 | 98 | def query(self, query): |
18 | | - q = query.strip() |
19 | | - args = q.split(" ") |
20 | | - # Just keyword - show all units if the setting allows |
| 99 | + args = query.strip().split() |
| 100 | + |
21 | 101 | if len(args) == 1: |
22 | | - all_units = get_all_units() |
23 | | - self.add_item( |
24 | | - title=_("General Converter"), |
25 | | - subtitle=_( |
26 | | - "<Hotkey> <Amount> <Source unit - case sensitive> <Destination unit - case sensitive>" |
27 | | - ), |
28 | | - ) |
29 | | - if self.settings.get("show_helper_text"): |
30 | | - for cat in all_units: |
31 | | - title = str(cat[0]) |
32 | | - subtitle = ", ".join([str(elem) for elem in cat[1:]]) |
33 | | - lines = textwrap.wrap(subtitle, 110, break_long_words=False) |
34 | | - if len(lines) > 1: |
35 | | - self.add_item( |
36 | | - title=(title), |
37 | | - subtitle=(lines[0]), |
38 | | - icon=f"assets/{title}.ico", |
39 | | - ) |
40 | | - for line in range(1, len(lines)): |
41 | | - self.add_item( |
42 | | - title=(title), |
43 | | - subtitle=(lines[line]), |
44 | | - icon=f"assets/{title}.ico", |
45 | | - ) |
46 | | - else: |
47 | | - self.add_item( |
48 | | - title=(title), |
49 | | - subtitle=(subtitle), |
50 | | - icon=f"assets/{title}.ico", |
51 | | - ) |
52 | | - # Keyword and first unit to convert from - show what it can be converted to |
| 102 | + self._show_all_units() |
53 | 103 | elif len(args) == 2: |
54 | 104 | hints = get_hints_for_category(args[1]) |
55 | 105 | self.add_item( |
56 | 106 | title=_("Available conversions"), |
57 | | - subtitle=(f"{args[0]} {args[1]} to {', '.join(hints)}"), |
| 107 | + subtitle=f"{args[0]} {args[1]} to {', '.join(hints)}", |
58 | 108 | ) |
59 | | - # Keyword and two units to convert from and to - try to convert |
60 | 109 | elif len(args) == 3: |
61 | | - try: |
62 | | - # Units are case sensitive. |
63 | | - do_convert = gen_convert(float(args[0]), args[1], args[2]) |
64 | | - if "Error" in do_convert: |
65 | | - if do_convert["Error"] == _("To and from unit is the same"): |
66 | | - self.add_item( |
67 | | - title=_("{}".format(do_convert["Error"])), |
68 | | - subtitle=_("Choose two different units"), |
69 | | - ) |
70 | | - else: |
71 | | - self.add_item( |
72 | | - # f strings seem to break babel so use string formatting instead |
73 | | - title=_("Error - {}").format(do_convert["Error"]), |
74 | | - subtitle=_("Check documentation for accepted units"), |
75 | | - ) |
76 | | - else: |
77 | | - converted = do_convert["converted"] |
78 | | - category = do_convert["category"] |
79 | | - to_long = do_convert["toplural"] |
80 | | - to_abb = do_convert["toabbrev"] |
81 | | - from_long = do_convert["fromplural"] |
82 | | - from_abb = do_convert["fromabbrev"] |
83 | | - converted_precision = smart_precision( |
84 | | - locale.localeconv()["decimal_point"], converted, 3 |
85 | | - ) |
86 | | - self.add_item( |
87 | | - title=(category), |
88 | | - subtitle=( |
89 | | - f"{locale.format_string('%.10g', float(args[0]), grouping=True)} {from_long} ({from_abb}) = {locale.format_string(f'%.{converted_precision}f', converted, grouping=True)} {to_long} ({to_abb})" |
90 | | - ), |
91 | | - icon=f"assets/{do_convert['category']}.ico", |
92 | | - ) |
93 | | - do_convert = [] |
94 | | - except Exception as e: |
95 | | - self.add_item(title="Error - {}").format(repr(e), subtitle="") |
96 | | - # Always show the usage while there isn't a valid query |
| 110 | + self._do_convert(*args) |
97 | 111 | else: |
98 | 112 | self.add_item( |
99 | 113 | title=_("General Converter"), |
100 | 114 | subtitle=_("<Hotkey> <Amount> <Source unit> <Destination unit>"), |
101 | 115 | ) |
102 | 116 |
|
103 | | - |
104 | | -def get_all_units(short: bool = False): |
105 | | - """Returns all available units as a list of lists by category |
106 | | -
|
107 | | - :param short: if True only unit abbreviations are returned, default is False |
108 | | - :type amount: bool |
109 | | -
|
110 | | - :rtype: list of lists |
111 | | - :return: A list of lists for each category in units. Index 0 of each internal list |
112 | | - is the category description |
113 | | - """ |
114 | | - |
115 | | - full_list = [] |
116 | | - for u in gc_units.units: |
117 | | - cat_list = [] |
118 | | - cat_list.append(u) |
119 | | - for u2 in gc_units.units[u]: |
120 | | - cat_list.append(u2[0] if short else f"{u2[1]} ({u2[0]})") |
121 | | - full_list.append(cat_list) |
122 | | - return full_list |
123 | | - |
124 | | - |
125 | | -def get_hints_for_category(from_unit: str): |
126 | | - """Takes an input unit and returns a list of units it can be converted to |
127 | | -
|
128 | | - :param from_short: unit abbreviation |
129 | | - :type amount: str |
130 | | -
|
131 | | - :rtype: list |
132 | | - :return: A list of other unit abbreviations in the same category |
133 | | - """ |
134 | | - c = [] |
135 | | - category = "" |
136 | | - |
137 | | - # Find the category it's in |
138 | | - for u in gc_units.units: |
139 | | - for u2 in gc_units.units[u]: |
140 | | - if u2[0] == from_unit or u2[1] == from_unit or u2[2] == from_unit: |
141 | | - category = str(u) |
142 | | - for uu in gc_units.units[category]: |
143 | | - if uu[0] != from_unit: |
144 | | - c.append(uu[0]) |
145 | | - if category: |
146 | | - return c |
147 | | - else: |
148 | | - return ["no valid units"] |
149 | | - |
150 | | - |
151 | | -def gen_convert(amount: float, from_unit: str, to_unit: str): |
152 | | - """Converts from one unit to another |
153 | | -
|
154 | | - :param amount: amount of source unit to convert |
155 | | - :type amount: float |
156 | | - :param from_unit: abbreviation of unit to convert from |
157 | | - :type from_unit: str |
158 | | - :param to_unit: abbreviation of unit to convert to |
159 | | - :type to_unit: str |
160 | | -
|
161 | | - :rtype: dict |
162 | | - :return: if to_unit and from_unit are valid returns a dictionary |
163 | | - { |
164 | | - "category":{category of units}, |
165 | | - "converted":{converted amount}, |
166 | | - "fromabbrev":{from unit abbreviation}, |
167 | | - "fromlong":{from unit long name}, |
168 | | - "fromplural":{from unit plural name}, |
169 | | - "toabbrev":{to unit abbreviation}, |
170 | | - "tolong":{to unit long name}, |
171 | | - "toplural":{to unit plural name}, |
172 | | - } |
173 | | -
|
174 | | - else returns a dictionary with error status |
175 | | - {"Error": {error text}} |
176 | | - """ |
177 | | - conversions = {} |
178 | | - found_from = found_to = [] |
179 | | - if from_unit == to_unit: |
180 | | - conversions["Error"] = _("To and from unit is the same") |
181 | | - return conversions |
182 | | - for u in gc_units.units: |
183 | | - for u2 in gc_units.units[u]: |
184 | | - if u2[0] == from_unit or u2[1] == from_unit or u2[2] == from_unit: |
185 | | - found_from = u2 |
186 | | - if u2[0] == to_unit or u2[1] == to_unit or u2[2] == to_unit: |
187 | | - found_to = u2 |
188 | | - # If we haven't both in the same category, reset |
189 | | - if found_to and found_from: |
190 | | - found_category = u |
191 | | - break |
192 | | - else: |
193 | | - found_from = found_to = [] |
194 | | - if found_to and found_from: |
195 | | - base_unit_conversion = eval(found_from[3].replace("x", str(amount))) |
196 | | - final_conversion = eval(found_to[4].replace("x", str(base_unit_conversion))) |
197 | | - conversions["category"] = found_category |
198 | | - conversions["converted"] = final_conversion |
199 | | - conversions["fromabbrev"] = found_from[0] |
200 | | - conversions["fromlong"] = found_from[1] |
201 | | - conversions["fromplural"] = found_from[2] |
202 | | - conversions["toabbrev"] = found_to[0] |
203 | | - conversions["tolong"] = found_to[1] |
204 | | - conversions["toplural"] = found_to[2] |
205 | | - |
206 | | - else: |
207 | | - conversions["Error"] = "Problem converting {} and {}".format(from_unit, to_unit) |
208 | | - return conversions |
209 | | - |
210 | | - |
211 | | -def smart_precision(separator, amount, preferred=3): |
212 | | - str_amt = str(amount) |
213 | | - dec_places = str_amt[::-1].find(separator) |
214 | | - # whole number |
215 | | - if dec_places == -1: |
216 | | - return 0 |
217 | | - frac_part = str_amt[-dec_places::] |
218 | | - # fraction is just zeroes |
219 | | - if int(frac_part) == 0: |
220 | | - return 0 |
221 | | - fnz = re.search(r"[1-9]", frac_part).start() |
222 | | - if fnz < preferred: |
223 | | - return preferred |
224 | | - return fnz + 1 |
| 117 | + def _show_all_units(self): |
| 118 | + self.add_item( |
| 119 | + title=_("General Converter"), |
| 120 | + subtitle=_("<Hotkey> <Amount> <Source unit - case sensitive> <Destination unit - case sensitive>"), |
| 121 | + ) |
| 122 | + if not self.settings.get("show_helper_text"): |
| 123 | + return |
| 124 | + for cat in get_all_units(): |
| 125 | + title = str(cat[0]) |
| 126 | + subtitle = ", ".join(str(e) for e in cat[1:]) |
| 127 | + icon = f"assets/{title}.ico" |
| 128 | + for line in textwrap.wrap(subtitle, 110, break_long_words=False) or [subtitle]: |
| 129 | + self.add_item(title=title, subtitle=line, icon=icon) |
| 130 | + |
| 131 | + def _do_convert(self, raw_amount: str, from_unit: str, to_unit: str): |
| 132 | + try: |
| 133 | + result = gen_convert(float(raw_amount), from_unit, to_unit) |
| 134 | + except ValueError: |
| 135 | + self.add_item(title=_("Error - invalid number"), subtitle=raw_amount) |
| 136 | + return |
| 137 | + |
| 138 | + if "Error" in result: |
| 139 | + err = result["Error"] |
| 140 | + sub = (_("Choose two different units") |
| 141 | + if err == _("To and from unit is the same") |
| 142 | + else _("Check documentation for accepted units")) |
| 143 | + self.add_item(title=err, subtitle=sub) |
| 144 | + return |
| 145 | + |
| 146 | + dp = locale.localeconv()["decimal_point"] |
| 147 | + precision = smart_precision(dp, result["converted"], 3) |
| 148 | + amount_fmt = locale.format_string("%.10g", float(raw_amount), grouping=True) |
| 149 | + converted_fmt = locale.format_string(f"%.{precision}f", result["converted"], grouping=True) |
| 150 | + copy_value = locale.format_string(f"%.{precision}f", result["converted"]) # no thousands sep |
| 151 | + |
| 152 | + self.add_item( |
| 153 | + title=result["category"], |
| 154 | + subtitle=( |
| 155 | + f"{amount_fmt} {result['fromplural']} ({result['fromabbrev']}) = " |
| 156 | + f"{converted_fmt} {result['toplural']} ({result['toabbrev']})" |
| 157 | + f" [Enter copies value]" |
| 158 | + ), |
| 159 | + icon=f"assets/{result['category']}.ico", |
| 160 | + method=self.copy_to_clipboard, |
| 161 | + parameters=[copy_value], |
| 162 | + ) |
| 163 | + |
| 164 | + def copy_to_clipboard(self, value: str): |
| 165 | + """Copy converted value to Windows clipboard via clip.exe.""" |
| 166 | + subprocess.run(["clip"], input=value.encode("utf-16-le"), check=True) |
0 commit comments