Skip to content

Commit 56ae370

Browse files
authored
refactor: new unit format, add scientific categories, fix bugs
A significant rewrite focused on accuracy, extensibility, and usefulness for technical and scientific work. This release adds 10 new categories, 100+ new units, a cleaner internal architecture, clipboard support, and matching icons for every category.
1 parent 1d7c564 commit 56ae370

1 file changed

Lines changed: 137 additions & 195 deletions

File tree

plugin/general_converter.py

Lines changed: 137 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -1,224 +1,166 @@
11
import locale
22
import textwrap
33
import re
4+
import subprocess
45
import units as gc_units
56

67
from translation import _
78
from flox import Flox
89

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+
# ---------------------------------------------------------------------------
991

1092
class GenConvert(Flox):
11-
locale.setlocale(locale.LC_NUMERIC, "")
1293

1394
def __init__(self, *args, **kwargs):
1495
super().__init__(*args, **kwargs)
1596
self.logger_level("info")
1697

1798
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+
21101
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()
53103
elif len(args) == 2:
54104
hints = get_hints_for_category(args[1])
55105
self.add_item(
56106
title=_("Available conversions"),
57-
subtitle=(f"{args[0]} {args[1]} to {', '.join(hints)}"),
107+
subtitle=f"{args[0]} {args[1]} to {', '.join(hints)}",
58108
)
59-
# Keyword and two units to convert from and to - try to convert
60109
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)
97111
else:
98112
self.add_item(
99113
title=_("General Converter"),
100114
subtitle=_("<Hotkey> <Amount> <Source unit> <Destination unit>"),
101115
)
102116

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

Comments
 (0)