From 516920667a1c86ba1551552f955f086ac345dc19 Mon Sep 17 00:00:00 2001 From: falsycat Date: Sun, 26 Jan 2025 23:42:21 +0900 Subject: [PATCH] creates new project --- .gitignore | 3 + LICENSE | 13 + hourtrack/__main__.py | 19 ++ hourtrack/compo/GanttView.py | 505 ++++++++++++++++++++++++++++++++++ hourtrack/compo/TicketEdit.py | 159 +++++++++++ hourtrack/compo/TriageView.py | 214 ++++++++++++++ requirements.txt | 1 + 7 files changed, 914 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 hourtrack/__main__.py create mode 100644 hourtrack/compo/GanttView.py create mode 100644 hourtrack/compo/TicketEdit.py create mode 100644 hourtrack/compo/TriageView.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a54e929 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dev/ +__pycache__/ +/hourtrack.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f7c094e --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + +Copyright (C) 2025 falsycat + +Everyone is permitted to copy and distribute verbatim or modified +copies of this license document, and changing it is allowed as long +as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/hourtrack/__main__.py b/hourtrack/__main__.py new file mode 100644 index 0000000..d185425 --- /dev/null +++ b/hourtrack/__main__.py @@ -0,0 +1,19 @@ +from imgui_bundle import imgui, imgui_ctx, immapp + +from hourtrack.compo import GanttView, TriageView + +gctx = GanttView.Context(GanttView.SimpleIO()) +tctx = TriageView.Context(TriageView.SimpleIO()) + +def main(): + with imgui_ctx.begin("TicketView"): + pass + with imgui_ctx.begin("GanttView"): + GanttView.compo(gctx) + with imgui_ctx.begin("TriageView"): + TriageView.compo(tctx) + +immapp.run( + window_title="hourtrack", + gui_function=main +) diff --git a/hourtrack/compo/GanttView.py b/hourtrack/compo/GanttView.py new file mode 100644 index 0000000..ef943c9 --- /dev/null +++ b/hourtrack/compo/GanttView.py @@ -0,0 +1,505 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from dataclasses import dataclass, field +from datetime import date, datetime, timedelta +from imgui_bundle import imgui, imgui_ctx +import random +from typing import Callable + + +DATE_FMT = "%Y-%m-%d" +WEEKDAYS = ["S", "M", "T", "W", "T", "F", "S"] + + +# ---- data types ---- +TicketId = int +Point = float + +@dataclass +class Period: + begin: date + end : date + +@dataclass +class Hours: + estimation: Point = 1 + actual : Point = .5 + +@dataclass +class Ticket: + id : TicketId = 0 + summary: str = "this is summary" + hours : list[Hours] = field(default_factory=list) + + calculated: bool = False + selected : bool = False + + +# ---- GUI volatile data (selections, text user modifying, or etc...) ---- +@dataclass +class VolatileData: + editing_period_begin: str = "" + editing_period_end : str = "" + + editing_cell : None|tuple[TicketId, date] = None + editing_cell_text: str = "" + + revision: None|str = None + + +# ---- this I/F allows widget to store or load permanent data +class IO(ABC): + @abstractmethod + def get_period(self) -> Period: + pass + + @abstractmethod + def set_period(self, p: Period) -> None: + pass + + @abstractmethod + def get_tickets(self) -> list[Ticket]: + pass + + @abstractmethod + def set_hours(self, t: list[TicketId], p: Period, act: None|Point, est: None|Point) -> None: + pass + + @abstractmethod + def get_today(self) -> date: + pass + + @abstractmethod + def sort(self) -> None: + pass + + @abstractmethod + def select_ticket(self, t: TicketId) -> None: + pass + + @abstractmethod + def get_revisions(self) -> list[str]: + pass + + @abstractmethod + def load_revision(self, name: None|str) -> None: + pass + + @abstractmethod + def delete_revision(self, name: str) -> None: + pass + + @abstractmethod + def save_as_new_revision(self, name: str) -> bool: + pass + +class SimpleIO(IO): + _period : Period + _tickets: list[Ticket] + + def __init__(self): + self._period = Period(date(2025, 1, 1), date(2025, 2, 1)) + self._tickets = [ + Ticket(i, "hello", [ + *([Hours(0, 0)]*random.randint(1,30)), + *([Hours(1, 2)]*random.randint(2,8)), + *([Hours(0, 0)]*100), + ]) for i in range(70) + ] + + def get_period(self) -> Period: + return self._period + + def set_period(self, p: Period) -> None: + self._period = p + + def get_tickets(self) -> list[Ticket]: + return self._tickets + + def get_today(self) -> date: + return datetime.now().date() + + def set_hours(self, t: list[TicketId], p: Period, act: None|float, est: None|float) -> None: + pass + + def sort(self) -> None: + pass + + def select_ticket(self, t: TicketId) -> None: + pass + + def get_revisions(self) -> list[str]: + return ["first rev", "second rev"] + + def load_revision(self, name: None|str) -> None: + pass + + def delete_revision(self, name: str) -> None: + pass + + def save_as_new_revision(self, name: str) -> bool: + return False + + +# ---- component definition +@dataclass +class Context: + io : IO + vola: VolatileData = field(default_factory=lambda: VolatileData()) + +def compo(ctx: Context) -> None: + io = ctx.io + vola = ctx.vola + em = imgui.get_font_size() + + # view config / period specs + imgui.align_text_to_frame_padding() + with imgui_ctx.push_item_width(6*em): + period = io.get_period() + changed_begin, vola.editing_period_begin = _compo_editable_text( + "begin date", + period.begin.strftime(DATE_FMT), + vola.editing_period_begin, + _check_if_str_is_date, + ) + + imgui.same_line() + imgui.text("~") + imgui.same_line() + + period = io.get_period() + changed_end, vola.editing_period_end = _compo_editable_text( + "end date", + period.end.strftime(DATE_FMT), + vola.editing_period_end, + _check_if_str_is_date, + ) + + if changed_begin or changed_end: + io.set_period(Period( + _str2date(vola.editing_period_begin) if vola.editing_period_begin else period.begin, + _str2date(vola.editing_period_end) if vola.editing_period_end else period.end, + )) + + imgui.same_line() + imgui.text("/") + imgui.same_line() + + # revisions + with imgui_ctx.push_item_width(6*em): + revs = io.get_revisions() + imgui.text("revision:") + imgui.same_line() + if imgui.begin_combo("##rev", vola.revision or "(latest)"): + clicked, _ = imgui.selectable("(latest)", False) + if clicked: + vola.revision = None + for name in revs: + clicked, _ = imgui.selectable(name, False) + if clicked: + io.load_revision(name) + vola.revision = name + imgui.end_combo() + + imgui.same_line() + if vola.revision is not None: + if imgui.button("del"): + io.delete_revision(vola.revision) + else: + if imgui.button("new"): + vola.editing_revision_name = "" + imgui.open_popup("NEW_REVISION") + if imgui.begin_popup("NEW_REVISION"): + imgui.set_keyboard_focus_here() + entered, vola.editing_revision_name = imgui.input_text( + "revision name", + vola.editing_revision_name, + imgui.InputTextFlags_.enter_returns_true, + ) + if imgui.is_item_deactivated(): + imgui.close_current_popup() + if entered: + if io.save_as_new_revision(vola.editing_revision_name): + imgui.close_current_popup() + imgui.end_popup() + + + imgui.same_line() + imgui.text("/") + imgui.same_line() + + # tool buttons + if imgui.button("sort"): + io.sort() + + # gantt + today = io.get_today() + days = (period.end - period.begin).days + 1 + past_days = min(days, (period.end - today).days) + if days <= 0: + imgui.text("invalid period") + else: + tickets = io.get_tickets() + days = [period.begin + timedelta(days=i) for i in range(days)] + + daily_estimations = [sum(t.hours[i].estimation for t in tickets) for i in range(len(days))] + daily_actuals = [sum(t.hours[i].actual for t in tickets) for i in range(len(days))] + + daily_estimations_quantiles = _calc_quantiles(daily_estimations) + + table_flags = ( + imgui.TableFlags_.scroll_x | + imgui.TableFlags_.scroll_y | + imgui.TableFlags_.borders | + imgui.TableFlags_.resizable + ) + with imgui_ctx.begin_table("Table", 2+len(days), table_flags): + # column setup + imgui.table_setup_scroll_freeze(2, 6) + imgui.table_setup_column("#") + imgui.table_setup_column( + "summary", imgui.TableColumnFlags_.angled_header) + for d in days: + flags = ( + imgui.TableColumnFlags_.width_fixed | + imgui.TableColumnFlags_.no_resize + ) + if d == today: + imgui.table_setup_column(f"{d.day}", flags, 3*1.2*em) + else: + imgui.table_setup_column(f"{d.day}", flags, 1.2*em) + + # month + imgui.table_next_row(imgui.TableRowFlags_.headers) + imgui.table_next_column() + imgui.table_next_column() + _aligned_text("month ->", 1) + for d in days: + imgui.table_next_column() + if d.day == 1: + _aligned_text(f"{d.month}", .5) + + # days + imgui.table_next_row(imgui.TableRowFlags_.headers) + imgui.table_next_column() + imgui.table_next_column() + _aligned_text("day ->", 1) + for d in days: + imgui.table_next_column() + if d == today: + with imgui_ctx.push_style_color(imgui.Col_.text, imgui.get_color_u32((.5,1,.5,1))): + _aligned_text(f"{d.day}", .5) + else: + _aligned_text(f"{d.day}", .5) + + # weekday + imgui.table_next_row(imgui.TableRowFlags_.headers) + imgui.table_next_column() + imgui.table_next_column() + _aligned_text("weekday ->", 1) + for d in days: + imgui.table_next_column() + w = d.weekday() + if w == 0: + color = imgui.get_color_u32((.8, .6, .6, 1)) + elif w == 6: + color = imgui.get_color_u32((.6, .6, .8, 1)) + else: + color = imgui.get_color_u32((.8, .8, .8, 1)) + + with imgui_ctx.push_style_color(imgui.Col_.text, color): + _aligned_text(f"{WEEKDAYS[w]}", .5) + + # estimation + imgui.table_next_row() + imgui.table_set_column_index(1) + _aligned_text("estimation ->", 1) + for i,d in enumerate(days): + imgui.table_next_column() + col = _get_deviation_color( + _calc_deviation_coe4(daily_estimations[i], *daily_estimations_quantiles)) + with imgui_ctx.push_style_color(imgui.Col_.text, col): + _aligned_text(f"{daily_estimations[i]}", 1) + + # actual + imgui.table_next_row() + imgui.table_set_column_index(1) + _aligned_text("actual ->", 1) + for i,d in enumerate(days): + imgui.table_next_column() + a = daily_actuals[i] + e = daily_estimations[i] + col = _get_deviation_color(_calc_deviation_coe1(a, e)) + with imgui_ctx.push_style_color(imgui.Col_.text, col): + if d <= today: + _aligned_text(f"{a}", 1) + + # header + imgui.table_next_row(imgui.TableRowFlags_.headers) + imgui.table_next_column() + _aligned_text("#", .5) + imgui.table_next_column() + imgui.text("summary") + + # tickets + for t in tickets: + imgui.table_next_row() + with imgui_ctx.push_id(t.id): + imgui.table_next_column() + _aligned_text(f"#{t.id}", 1) + + imgui.table_next_column() + if imgui.text_link(f"{t.summary}##summary"): + io.select_ticket(t.id) + + with imgui_ctx.push_style_var(imgui.StyleVar_.cell_padding, (0, 0)): + for i,d in enumerate(days): + with imgui_ctx.push_id(f"@{_date2str(d)}"): + hours = t.hours[i] + + imgui.table_next_column() + imgui.table_set_bg_color( + imgui.TableBgTarget_.cell_bg, + imgui.get_color_u32(_get_cell_color(d, today)) + ) + + if vola.editing_cell == (t.id, d): + imgui.set_keyboard_focus_here() + imgui.set_next_item_width(-1) + with imgui_ctx.push_style_var(imgui.StyleVar_.frame_rounding, 0): + with imgui_ctx.push_style_var(imgui.StyleVar_.frame_padding, (0, 0)): + entered, vola.editing_cell_text = imgui.input_text( + "##input", vola.editing_cell_text, + imgui.InputTextFlags_.enter_returns_true + ) + imgui.set_item_default_focus() + if entered or imgui.is_item_deactivated(): + vola.editing_cell = None + + error = False + try: + est, act = _parse_hours(vola.editing_cell_text) + except ValueError: + error = True + if not error: + io.set_hours([t.id], Period(d, d), est, act) + else: + edit = False + if d > today: + edit = _compo_cell_selectable("##cell", False) + if hours.estimation > 0: + _aligned_text(f"/{hours.estimation}", 1) + + elif d < today: + edit = _compo_cell_selectable("##cell", False) + if (hours.estimation > 0 or hours.actual > 0): + _aligned_text(f"{hours.actual}/", 1) + + elif d == today: + edit = _compo_cell_selectable("##cell", False) + + _aligned_text(f"{hours.actual}", 1., w=1.2*em) + imgui.same_line(0, 0) + _aligned_text("/", .5, w=1.2*em) + imgui.same_line(0, 0) + _aligned_text(f"{hours.estimation}", 1., w=1.2*em) + + if edit and not t.calculated: + vola.editing_cell = (t.id, d) + vola.editing_cell_text = f"{hours.actual}/{hours.estimation}" + io.select_ticket(t.id) + + +def _compo_editable_text(id: str, text: str, editing: str, checker: Callable[[str], None|str]) -> tuple[bool, str]: + with imgui_ctx.push_id(id): + submitted = False + + if imgui.text_link(text): + imgui.open_popup("input") + editing = text + + if imgui.begin_popup("input"): + imgui.set_keyboard_focus_here() + entered, editing = imgui.input_text( + id, editing, imgui.InputTextFlags_.enter_returns_true) + if err := checker(editing): + imgui.text(err) + elif entered: + submitted = True + imgui.close_current_popup() + imgui.end_popup() + return submitted, editing + +def _aligned_text(text: str, pos: float, w: float = None, func = imgui.text): + w = w or imgui.get_content_region_avail().x + tw = imgui.calc_text_size(text).x + ox = imgui.get_cursor_pos_x() + imgui.set_cursor_pos_x(ox + (w-tw)*pos) + return func(text) + +def _compo_cell_selectable(name: str, selected: bool, flags: int = 0) -> bool: + pos = imgui.get_cursor_pos() + imgui.selectable(name, selected, flags) + imgui.set_cursor_pos(pos) + return imgui.is_item_clicked() + +def _str2date(v: str) -> date: + return datetime.strptime(v, DATE_FMT).date() + +def _date2str(v: str) -> date: + return v.strftime(DATE_FMT) + +def _check_if_str_is_date(v: str) -> str: + try: + _str2date(v) + return None + except ValueError as e: + return "invalid format (example: 2002-06-30)" + +def _get_cell_color(d: date, today: date) -> tuple[float, float, float, float]: + if d > today: + return (0, 0, 0, 0) + elif d < today: + return (.6, .6, .6, .05) + else: + return (.3, .9, .3, .2) + +def _get_deviation_color(coe: float) -> tuple[float, float, float, float]: + if coe > .8: + return (.8, .2, .2, 1) + elif coe < -.8: + return (.2, .8, .2, 1) + else: + return (.8, .8, .8, 1) + +def _calc_deviation_coe1(v: float, avg: float) -> float: + return _calc_deviation_coe4(v, avg*.8, avg, avg*1.2) + +def _calc_deviation_coe4(v: float, low: float, avg: float, high: float) -> float: + if v < avg: + return (v - avg) / (avg - low) if avg != low else 0 + else: + return (v - avg) / (high - avg) if avg != high else 0 + +def _calc_quantiles(data: list[float]) -> tuple[float, float, float]: + return ( + min(data), sum(data)/len(data), max(data), + ) + +def _parse_hours(v: str) -> tuple[float, float]: + if v == "": + return 0, 0 + + terms = v.split("/") + n = len(terms) + if n == 2: + return _parse_point(terms[0]), _parse_point(terms[1]) + elif n == 1: + return _parse_point(terms[0]), None + else: + raise ValueError() + +def _parse_point(v: str) -> None|Point: + if v == "": + return None + return Point(v) diff --git a/hourtrack/compo/TicketEdit.py b/hourtrack/compo/TicketEdit.py new file mode 100644 index 0000000..d5150b4 --- /dev/null +++ b/hourtrack/compo/TicketEdit.py @@ -0,0 +1,159 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from dataclasses import dataclass, field +from imgui_bundle import imgui, imgui_ctx +from typing import Any, List, Set, Tuple +import webbrowser + +Field = Tuple[str, str] + +@dataclass +class Item: + id : int = 0 + summary: str = "this is summary" + detail : str = "helloworld" + fields : List[Field] = field(default_factory=list) + feats : Set[str] = field(default_factory=set) + + def __post_init__(self): + self.fields = [ + ("begin", "2024-01-24"), + ("end", "2025-02-12"), + ] + self.feats = {"subtickets", "gantt"} + + +class IO(ABC): + @abstractmethod + def get_item(self) -> Item: + pass + + @abstractmethod + def set_summary(self, v: str) -> None: + pass + + @abstractmethod + def set_detail(self, v: str) -> None: + pass + + @abstractmethod + def set_field(self, idx: int, field: Field) -> None: + pass + + @abstractmethod + def delete_field(self, idx: int) -> None: + pass + + @abstractmethod + def reorder_field(self, before: int, after: int) -> None: + pass + + +def compo(io: IO, item: Item = None) -> None: + em = imgui.get_font_size() + if item is None: + item = io.get_item() + + with imgui_ctx.push_id(f"TicketEdit#{item.id}"): + if imgui.collapsing_header("basic info", imgui.TreeNodeFlags_.default_open.value): + changed, new_summary = imgui.input_text("summary", item.summary) + if changed: + io.set_summary(new_summary) + + changed, new_detail = imgui.input_text_multiline("detail", item.detail) + if changed: + io.set_detail(new_detail) + + if imgui.collapsing_header("fields", imgui.TreeNodeFlags_.default_open.value): + count = len(item.fields) + drag_src = imgui.get_drag_drop_payload_py_id() + if drag_src is not None and drag_src.type == "TicketEdit_Fields": + drag_src = drag_src.data_id + + for idx in range(0, count+1): + field = item.fields[idx] if idx < count else ("", "") + + with imgui_ctx.push_id(idx): + with imgui_ctx.begin_group(): + w = imgui.get_content_region_avail().x + + value_changed = False + name_changed = False + + imgui.begin_disabled(idx == count) + imgui.text_link(":::") + if imgui.begin_drag_drop_source(): + imgui.set_drag_drop_payload_py_id("TicketEdit_Fields", idx, imgui.Cond_.once.value); + imgui.text(f"{field[0]}") + imgui.end_drag_drop_source() + if imgui.is_item_hovered(): + imgui.set_tooltip("right click to delete") + if imgui.is_mouse_released(1): + io.delete_field(idx) + imgui.end_disabled() + + imgui.same_line() + + with imgui_ctx.push_item_width(6*em): + name_changed, new_name = imgui.input_text_with_hint("##name", "name", field[0]) + + imgui.same_line() + + with imgui_ctx.push_item_width(w - 10*em): + value_changed, new_value = imgui.input_text_with_hint("##value", "value...", field[1]) + + if name_changed or value_changed: + io.set_field(idx, (new_name, new_value)) + + if idx < count and imgui.begin_drag_drop_target(): + payload = imgui.accept_drag_drop_payload_py_id("TicketEdit_Fields") + if payload is not None: + io.reorder_field(payload.data_id, idx) + imgui.end_drag_drop_target() + + if imgui.collapsing_header("features", imgui.TreeNodeFlags_.default_open.value): + pass + + +def win(io: IO) -> None: + item = io.get_item() + + with imgui_ctx.begin(f"#{item.id}: {item.summary}###TicketEdit#{item.id}"): + compo(io, item) + + +class SimpleIO(IO): + _item: Item = Item() + + def get_item(self) -> Item: + return deepcopy(self._item) + + def set_summary(self, v: str) -> None: + self._item.summary = v + + def set_detail(self, v: str) -> None: + self._item.detail = v + + def set_field(self, idx: int, field: Field) -> None: + if 0 <= idx and idx < len(self._item.fields): + self._item.fields[idx] = field + else: + self._item.fields.append(field) + + def delete_field(self, idx: int) -> None: + del self._item.fields[idx] + + def reorder_field(self, before: int, after: int) -> None: + n = len(self._item.fields) + if n == 0: + return + + before = min(max(0, before), n-1) + after = min(max(0, after ), n-1) + + if before == after: + return + + temp = self._item.fields[before] + del self._item.fields[before] + self._item.fields.insert(after, temp) diff --git a/hourtrack/compo/TriageView.py b/hourtrack/compo/TriageView.py new file mode 100644 index 0000000..045ccd7 --- /dev/null +++ b/hourtrack/compo/TriageView.py @@ -0,0 +1,214 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from imgui_bundle import imgui, imgui_ctx + + +PRIORITY_LABELS = ["A", "B", "C", "D"] +DND_PAYLOAD_TYPE = "TICKET" + + +# ---- data types ---- +TicketId = int + +@dataclass +class Ticket: + id : TicketId = 0 + summary : str = "this is tooooo too long summary" + selected: bool = False + + +# ---- volatile ---- +@dataclass +class VolatileData: + selected: TicketId = 0 + + editing_new_summaries: list[str] = field(default_factory=lambda: ["", "", "", ""]) + + +# ---- I/O ---- +class IO(ABC): + @abstractmethod + def get_tickets(self) -> list[list[Ticket]]: + pass + + @abstractmethod + def move_ticket(self, t: TicketId, priority: int, index: int) -> bool: + pass + + @abstractmethod + def select_ticket(self, t: TicketId) -> None: + pass + + @abstractmethod + def add_ticket(self, priority: int, summary: str) -> TicketId: + pass + + +class SimpleIO(IO): + _items = [ + [Ticket(0), Ticket(1), Ticket(2)], + [Ticket(3)], + [Ticket(4), Ticket(5)], + [], + ] + _next_id = 100 + + def get_tickets(self) -> list[list[Ticket]]: + return self._items + + def move_ticket(self, t: TicketId, priority: int, index: int) -> bool: + for pi,pl in enumerate(self._items): + i = next((i for i,v in enumerate(pl) if v.id == t), None) + if i is not None: + self._items[priority].insert(index, pl[i]) + if priority == pi and i > index: + i += 1 + del pl[i] + return True + return False + + def select_ticket(self, tid: TicketId) -> None: + for pl in self._items: + for t in pl: + t.selected = (tid == t.id) + + def add_ticket(self, priority: int, summary: str) -> TicketId: + id = self._next_id + self._next_id += 1 + self._items[priority].append(Ticket(id, summary)) + return id + + +# ---- component definition ---- +@dataclass +class Context: + io : IO + vola: VolatileData = field(default_factory=lambda: VolatileData()) + +def compo(ctx: Context) -> None: + io = ctx.io + vola = ctx.vola + tickets = io.get_tickets() + style = imgui.get_style() + + selected = None + dragging = imgui.get_drag_drop_payload_py_id() + if dragging is not None: + dragging = dragging.data_id if dragging.type == DND_PAYLOAD_TYPE else None + + ln = len(PRIORITY_LABELS) + lw = (imgui.get_content_region_avail().x - style.frame_padding.x * (ln+1)) / ln + for pi,pl in enumerate(PRIORITY_LABELS): + with imgui_ctx.begin_child(pl, (lw, 0), imgui.ChildFlags_.borders): + imgui.text(pl) + + basepos = imgui.get_cursor_screen_pos() + lh = imgui.get_content_region_avail().y - imgui.get_frame_height_with_spacing() + with imgui_ctx.begin_child("list", (0, lh), imgui.ChildFlags_.borders): + seps = [] + for ticket in tickets[pi]: + seps.append(imgui.get_cursor_pos_y()) + + with imgui_ctx.push_id(ticket.id): + _compo_ticket( + ticket, + selected=ticket.selected, + gray=(dragging == ticket.id), + ) + if ticket.selected: + selected = ticket.id + if vola.selected != ticket.id: + imgui.set_scroll_here_x() + imgui.set_scroll_here_y() + + if imgui.is_item_clicked(): + io.select_ticket(ticket.id) + selected = ticket.id + + flags = imgui.DragDropFlags_.source_allow_null_id + if imgui.begin_drag_drop_source(flags): + imgui.set_drag_drop_payload_py_id(DND_PAYLOAD_TYPE, ticket.id) + _compo_ticket_detail(ticket) + imgui.end_drag_drop_source() + seps.append(imgui.get_cursor_pos_y()) + + if imgui.begin_drag_drop_target(): + my = imgui.get_mouse_pos().y - basepos.y + if dragging is not None: + # find the nearest pos to insert + idx, pos = min(enumerate(seps), key=lambda d: abs(d[1]-my)) + + # draws line showing pos to insert + y = basepos.y + pos - style.item_spacing.y/2 + imgui.get_window_draw_list().add_line( + (imgui.get_item_rect_min().x, y), + (imgui.get_item_rect_max().x, y), + imgui.get_color_u32((1,1,1,1)), + ) + + # completes the moving + if imgui.accept_drag_drop_payload_py_id(DND_PAYLOAD_TYPE) is not None: + io.move_ticket(dragging, pi, idx) + imgui.end_drag_drop_target() + + # new ticket creation + imgui.set_next_item_width(-1) + entered, vola.editing_new_summaries[pi] = imgui.input_text_with_hint( + "##new_summary", + "new ticket summary...", + vola.editing_new_summaries[pi], + imgui.InputTextFlags_.enter_returns_true, + ) + if entered: + io.add_ticket(pi, vola.editing_new_summaries[pi]) + vola.editing_new_summaries[pi] = "" + imgui.same_line() + + vola.selected = selected + +def _compo_ticket(t: Ticket, selected: bool = False, gray: bool = False) -> None: + style = imgui.get_style() + ltop = imgui.get_cursor_screen_pos() + avail = imgui.get_content_region_avail() + dlist = imgui.get_window_draw_list() + + with imgui_ctx.begin_group(): + imgui.dummy((avail.x, 0)) + imgui.dummy((0, 0)) + imgui.same_line() + imgui.text_disabled(f"#{t.id}") + imgui.same_line() + imgui.text_wrapped(t.summary) + imgui.same_line() + imgui.dummy((0, 0)) + imgui.dummy((avail.x, 0)) + + # tooltip + if imgui.is_item_hovered(): + imgui.set_mouse_cursor(imgui.MouseCursor_.hand) + with imgui_ctx.begin_tooltip(): + _compo_ticket_detail(t) + + # border + dlist.add_rect( + imgui.get_item_rect_min(), + imgui.get_item_rect_max(), + imgui.get_color_u32((1,1,1,1)), + style.frame_rounding, + ) + + # background + bg = None + if gray : bg = (.0,.0,.0,.4) + if selected: bg = (.4,.4,.8,.4) + if bg is not None: + dlist.add_rect_filled( + imgui.get_item_rect_min(), + imgui.get_item_rect_max(), + imgui.get_color_u32(bg), + style.frame_rounding, + ) + +def _compo_ticket_detail(t: Ticket): + imgui.text_disabled(f"#{t.id}") + imgui.text(t.summary) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e7f5e93 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +imgui_bundle