creates new project
This commit is contained in:
parent
184a31ca3e
commit
516920667a
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/dev/
|
||||
__pycache__/
|
||||
/hourtrack.ini
|
13
LICENSE
Normal file
13
LICENSE
Normal file
@ -0,0 +1,13 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2025 falsycat <me@falsy.cat>
|
||||
|
||||
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.
|
19
hourtrack/__main__.py
Normal file
19
hourtrack/__main__.py
Normal file
@ -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
|
||||
)
|
505
hourtrack/compo/GanttView.py
Normal file
505
hourtrack/compo/GanttView.py
Normal file
@ -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)
|
159
hourtrack/compo/TicketEdit.py
Normal file
159
hourtrack/compo/TicketEdit.py
Normal file
@ -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)
|
214
hourtrack/compo/TriageView.py
Normal file
214
hourtrack/compo/TriageView.py
Normal file
@ -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)
|
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
imgui_bundle
|
Loading…
x
Reference in New Issue
Block a user