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()
\$\endgroup\$

2 Answers 2

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.

\$\endgroup\$
4
\$\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

\$\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 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
    Commented Jul 14 at 13:38

Not the answer you're looking for? Browse other questions tagged or ask your own question.