5
\$\begingroup\$

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()
rzickler
1311 gold badge1 silver badge5 bronze badges
asked Jul 11, 2024 at 22:52
\$\endgroup\$

2 Answers 2

5
\$\begingroup\$

Here I'll talk mostly about the UI and not a lot about Python in particular.

Here it is:

screenshot

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()

screenshot of proposal

answered Jul 12, 2024 at 22:22
\$\endgroup\$
2
  • \$\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\$ Commented 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\$ Commented Jul 14, 2024 at 13:38
5
\$\begingroup\$

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.

answered Jul 12, 2024 at 3:48
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.