I am very new to Python (yes, I know it's been around a long time). This is my first working just-for-fun project to see if I could do something in Python. I know the UI layout is rather ugly and there is probably a more efficient way to write the code. This is not built for mobile applications because I am not that far into learning Python yet.
import tkinter as tk
from tkinter import ttk
class CostSharingApp:
def __init__(self, root):
self.root = root
self.root.title("Cost Sharing Application")
self.root.geometry("400x600")
self.root.configure(bg="#f0f8ff")
# Main container with scrollbar
self.canvas = tk.Canvas(root, bg="#e0f7fa", highlightthickness=0)
self.scrollbar = ttk.Scrollbar(root, orient="vertical", command=self.canvas.yview)
self.scrollable_frame = ttk.Frame(self.canvas, padding=(0, 0, 10, 10))
self.scrollable_frame.bind(
"<Configure>",
lambda e: self.canvas.configure(
scrollregion=self.canvas.bbox("all")
)
)
self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
self.canvas.configure(yscrollcommand=self.scrollbar.set)
self.canvas.pack(side="left", fill="both", expand=True)
self.scrollbar.pack(side="right", fill="y")
# Input field for total cost
self.label_total_cost = tk.Label(self.scrollable_frame, text="Total Cost (USD):", bg="#e0f7fa")
self.label_total_cost.pack(pady=(20, 5))
self.entry_total_cost = tk.Entry(self.scrollable_frame)
self.entry_total_cost.pack(pady=5)
# Dropdown for number of people
self.label_num_people = tk.Label(self.scrollable_frame, text="Number of People:", bg="#e0f7fa")
self.label_num_people.pack(pady=(10, 5))
self.num_people = ttk.Combobox(self.scrollable_frame, values=[1, 2, 3, 4, 5, 6])
self.num_people.current(0)
self.num_people.pack(pady=5)
# Divide Evenly button
self.btn_divide_evenly = tk.Button(self.scrollable_frame, text="Divide Evenly", bg="#4caf50", fg="white", command=self.divide_evenly)
self.btn_divide_evenly.pack(pady=10)
# Enter Manually button
self.btn_enter_manually = tk.Button(self.scrollable_frame, text="Enter Manually", bg="#4caf50", fg="white", command=self.enter_manually)
self.btn_enter_manually.pack(pady=10)
# Output field for total due
self.label_total_due = tk.Label(self.scrollable_frame, text="Total Due (USD per person):", bg="#e0f7fa")
self.label_total_due.pack(pady=(10, 5))
self.entry_total_due = tk.Entry(self.scrollable_frame, state="readonly")
self.entry_total_due.pack(pady=5)
# Container for manual entries
self.manual_entries_container = tk.Frame(self.scrollable_frame, bg="#e0f7fa")
self.manual_entries_container.pack(pady=(10, 20))
# Submit button
self.btn_submit = tk.Button(self.scrollable_frame, text="Submit", bg="#4caf50", fg="white", command=self.submit)
self.btn_submit.pack(pady=(10, 20))
# Result display
self.result_container = tk.Frame(self.scrollable_frame, bg="#e0f7fa")
self.result_container.pack(pady=10)
def divide_evenly(self):
total_cost = float(self.entry_total_cost.get())
num_people = int(self.num_people.get())
total_due = round(total_cost / num_people, 2)
self.entry_total_due.config(state="normal")
self.entry_total_due.delete(0, tk.END)
self.entry_total_due.insert(0, f"${total_due}")
self.entry_total_due.config(state="readonly")
def enter_manually(self):
for widget in self.manual_entries_container.winfo_children():
widget.destroy()
num_people = int(self.num_people.get())
self.manual_entries = []
for _ in range(num_people):
frame = tk.Frame(self.manual_entries_container, bg="#e0f7fa")
frame.pack(pady=5)
name_entry = tk.Entry(frame, width=25)
name_entry.pack(side="left", padx=(0, 10))
amount_entry = tk.Entry(frame, width=10)
amount_entry.pack(side="left")
self.manual_entries.append((name_entry, amount_entry))
def submit(self):
for widget in self.result_container.winfo_children():
widget.destroy()
for name_entry, amount_entry in self.manual_entries:
name = name_entry.get()
amount = amount_entry.get()
if name and amount:
label = tk.Label(self.result_container, text=f"{name}: ${amount}", bg="#e0f7fa")
label.pack(anchor="w")
if __name__ == "__main__":
root = tk.Tk()
app = CostSharingApp(root)
root.mainloop()
2 Answers 2
Here I'll talk mostly about the UI and not a lot about Python in particular.
Here it is:
The colours are... bad. There's a weird mismatched background between teal and grey. There's a more abstract conversation to be had about the place of art in user interfaces, but (less so on the web, much moreso on the desktop) a well-designed user interface shouldn't actually choose any colours at all. Instead desktop applications should respect the desktop theme chosen by the user. If the user wants watermelon buttons with a chartreuse background and pylon-orange text, they can! The power of themes should rest with the user. There's plenty of content on various ways to pursue this goal.
Otherwise, the user interface is sort of disconnected and buggy. Pressing Divide Evenly doesn't hide the manual division rows. The division rows don't have any kind of header indicating what the columns mean. If you change the Number of People, the number of rows doesn't update. And why is there a separate report-label at the bottom at all?
Instead, why not:
- Delete Divide Evenly, Enter Manually, the report label on the bottom, and Total Due per Person
- Always display the rows, and always keep the number of rows in sync with the Number of People
- Have a checkbox Divide Evenly. If Divide Evenly is enabled, the amounts in the rows are in read-only mode and auto-update to the divided amount. If Divide Evenly is disabled, allow the user to type in all but the last row, and auto-populate a read-only last row with the remaining amount.
An example could look like this:
import functools
import locale
import tkinter as tk
import typing
if typing.TYPE_CHECKING:
class ConvDict(typing.TypedDict):
int_curr_symbol: str
frac_digits: int
class PersonRow:
HEADER_OFFSET = 2
PLACEHOLDER_NAME = 'First name'
def __init__(
self, root: tk.Tk, y: int, amount_fmt: str,
on_change: typing.Callable[[], None],
on_remove: None | typing.Callable[[typing.Self], None] = None,
) -> None:
self.y = y
self.on_change = on_change
self.var_person = tk.StringVar(root, value=self.PLACEHOLDER_NAME)
self.entry_person = tk.Entry(root, textvariable=self.var_person)
self.var_amount = tk.DoubleVar(root)
self.spin_amount = tk.Spinbox(
root, textvariable=self.var_amount, from_=0, increment=5.00,
to=1e9, # will be immediately replaced
format=amount_fmt,
state='readonly' if on_remove is None else 'normal',
)
self.var_distribute = tk.BooleanVar(root, value=on_remove is None)
self.check_distribute = tk.Checkbutton(
root, variable=self.var_distribute,
state='disabled' if on_remove is None else 'normal',
)
if on_remove is not None:
self.button_remove = tk.Button(
root, text='-', command=functools.partial(on_remove, self),
)
self.grid_configure()
self.var_amount.trace_variable(mode='w', callback=self.amount_changed)
self.var_distribute.trace_variable(mode='w', callback=self.distribute_changed)
def widgets(self) -> typing.Iterator[tk.Widget]:
yield from (self.entry_person, self.spin_amount, self.check_distribute)
if hasattr(self, 'button_remove'):
yield self.button_remove
def amount_changed(self, name: str, index: str, mode: str) -> None:
self.on_change()
def distribute_changed(self, name: str, index: str, mode: str) -> None:
self.spin_amount.configure(
state='readonly' if self.var_distribute.get() else 'normal',
)
self.on_change()
def destroy(self) -> None:
for widget in self.widgets():
widget.destroy()
def shift_down(self) -> None:
self.y += 1
self.grid_configure()
def shift_up(self) -> None:
self.y -= 1
self.grid_configure()
def grid_configure(self) -> None:
for x, widget in enumerate(self.widgets()):
widget.grid_configure(row=self.HEADER_OFFSET + self.y, column=x)
class CostSharingGUI:
def __init__(
self,
root: tk.Tk,
localeconv: 'ConvDict',
default_cost: float = 1_000.00,
default_num_people: int = 3,
) -> None:
self.root = root
self.root.title('Cost Sharing')
self.root.geometry('800x400')
for y in range(2):
self.root.grid_rowconfigure(index=y, pad=10)
for x in range(4):
self.root.grid_columnconfigure(index=x, pad=5)
self.currency_fmt = f'%.{localeconv["frac_digits"]}f'
self.var_total_cost = tk.DoubleVar(self.root, name='var_total_cost', value=default_cost)
self.make_headers(curr_symbol=localeconv["int_curr_symbol"].strip())
self.make_summary()
self.rows = [
PersonRow(
root=root, y=default_num_people - 1, amount_fmt=self.currency_fmt,
on_change=self.distribute,
),
*(
PersonRow(
root=root, y=y, amount_fmt=self.currency_fmt, on_remove=self.on_remove,
on_change=self.distribute,
)
for y in range(default_num_people - 1)
),
]
self.var_total_cost.trace_variable(mode='w', callback=self.on_total_changed)
self.distribute()
def make_headers(self, curr_symbol: str) -> None:
tk.Label(
self.root, name='label_heading_person', text='Person',
).grid_configure(row=0, column=0)
tk.Label(
self.root, name='label_heading_amount', text=f'Amount ({curr_symbol})',
).grid_configure(row=0, column=1)
tk.Label(
self.root, name='label_heading_distribute', text='Distribute',
).grid_configure(row=0, column=2)
tk.Label(
self.root, name='label_heading_add_remove', text='Add/Remove',
).grid_configure(row=0, column=3)
tk.Label(
self.root, name='label_everyone',
text='(All)',
).grid_configure(row=1, column=0)
def make_summary(self) -> None:
tk.Spinbox(
self.root, name='spin_total_cost', textvariable=self.var_total_cost,
# If no 'to' is specified, it defaults to 0 and the widget is very broken
from_=0.00, to=1e9, increment=5.00, format=self.currency_fmt,
).grid_configure(row=1, column=1)
tk.Button(
self.root, name='button_distribute_all', text='All', command=self.distribute_all,
).grid_configure(row=1, column=2)
tk.Button(
self.root, text='+', command=self.on_add,
).grid_configure(row=1, column=3)
def on_add(self) -> None:
for row in self.rows:
row.shift_down()
self.rows.insert(
0, PersonRow(
root=self.root, y=0, amount_fmt=self.currency_fmt, on_remove=self.on_remove,
on_change=self.distribute,
),
)
def on_remove(self, row: PersonRow) -> None:
y_gap = row.y
row.destroy()
del self.rows[y_gap]
for row in self.rows[y_gap:]:
row.shift_up()
def on_total_changed(self, name: str, index: str, mode: str) -> None:
self.distribute()
def distribute_all(self) -> None:
for row in self.rows:
row.var_distribute.set(True)
def distribute(self) -> None:
n_distributed = sum(
1 for row in self.rows if row.var_distribute.get()
)
allocated = sum(
row.var_amount.get() for row in self.rows if not row.var_distribute.get()
)
nonallocated = self.var_total_cost.get() - allocated
each = nonallocated/n_distributed
for row in self.rows:
if row.var_distribute.get():
row.var_amount.set(each)
def main() -> None:
locale.setlocale(category=locale.LC_ALL, locale='en_US.UTF-8')
root = tk.Tk()
gui = CostSharingGUI(root=root, localeconv=locale.localeconv())
tk.mainloop()
if __name__ == '__main__':
main()
-
\$\begingroup\$ I'm not at all familiar with Tk, but isn't there anything similar to a GridView or ListView that automatically aligns the rows for you? \$\endgroup\$Green 绿色– Green 绿色2024年07月14日 13:12:17 +00:00Commented Jul 14, 2024 at 13:12
-
1\$\begingroup\$ Tk is very rudimentary. For a read-only widget, there's TreeView; but otherwise the best you can do is something like this with a grid layout. \$\endgroup\$Reinderien– Reinderien2024年07月14日 13:38:45 +00:00Commented Jul 14, 2024 at 13:38
Use more special-purpose functions
You put quite a lot stuff into a single function (e.g., __init__
). You might want to segment your UI into multiple functions or classes. For example:
def _create_amount_entry_frame(parent_cnt):
frame = tk.Frame(parent_cnt, bg="#e0f7fa")
frame.pack(pady=5)
name_entry = tk.Entry(frame, width=25)
name_entry.pack(side="left", padx=(0, 10))
amount_entry = tk.Entry(frame, width=10)
amount_entry.pack(side="left")
return frame, name_entry, amount_entry
could be its own function.
Separation of business and UI logic
You mix business logic with UI logic in divide_evenly
. When tackling problems with more complex business logic, you might want to decouple the business logic from the UI-related code by using a model class. In this case, the business logic is
def compute_total_due(total_cost, num_people):
return round(total_cost / num_people, 2)
In this regard, you could read about the Model-View-Controller or Model-View-ViewModel patterns. Of course, for an application this small, it could also count as over-engineering.