Compare commits

...

3 Commits

@ -0,0 +1,29 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pykban"
version = "0.0.1"
authors = [
{ name="Alex Selimov", email="alex@alexselimov.com"}
]
description = "A simple TUI implementation of a kanban board"
readme = "README.md"
requires-python = ">=3.8"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v2 (GPLv2)",
"Operating System :: Unix",
"Development Status :: 3 - Alpha",
"Environment :: Console"
]
[project.urls]
Homepage = "https://alexselimov.com/git/aselimov/pykanban"
Issues = "https://github.com/aselimov/PyKanban/issues"
[project.scripts]
pykban = "pykban.tui:run_tui"

@ -1,125 +0,0 @@
""" This module contains classes and functions to contain the kanban board information """
import numpy as np
import yaml
class Task:
""" This class represents each task,
"""
def __init__(self, summary, score, description):
""" Initialize the task class
"""
# Each task has the following properties
self.summary = summary # Summary of the task
self.score = score # Score for ticket
self.description = description # Description of ticket
class Board:
def __init__(self, file = None):
""" Initialize the Board class, this class has three important class variables.
These are:
self.sprint | str - name of the current sprint
self.columns | list(str) - columns in kanban board
self.tasks | list(list()) - tasks in each column
"""
self.sprint = None
self.columns = list()
self.tasks = list()
self.file = file
if file:
self.read_yaml(file)
def read_yaml(self, file):
""" Read the yaml file in and set up the data
Arguments:
file - yaml file to read in
"""
# Read in the data
with open(file, 'r') as f:
data = yaml.safe_load(f)
# Assign the data to board variables
self.columns = data['columns']
self.tasks = [[] for col in self.columns]
for task in data['tasks']:
self.tasks[self.columns.index(task['column'])].append(
Task(task['summary'], task['score'], task['description']))
def write_yaml(self, file):
""" Write the yaml file
Arguments:
file - yaml file to write to
"""
# Set up data to write out
data = dict()
data['columns'] = self.columns
data['tasks'] = list()
for col,task_list in zip(self.columns, self.tasks):
for task in task_list:
data['tasks'].append({'column':col, 'summary':task.summary, 'score':task.score,
'description':task.description})
with open(file, 'w') as f:
yaml.dump(data, f)
def move_task(self, col_index, task_index, direction):
""" This class method moves tasks between columns by incrementing/decrementing the column
index
Arguments:
col_index - index of the column we are in
task_index - index of the task we are changing in the column
direction - direction to move the task
Returns:
moved - True if a task was moved else false
"""
task = self.tasks[col_index][task_index]
if col_index+direction >= 0 and col_index+direction < len(self.columns):
self.tasks[col_index+direction].append(task)
del self.tasks[col_index][task_index]
return True
else:
return False
def print_board_items(self):
for i, col in enumerate(self.columns):
print(col)
print(self.tasks[i])
def get_columns(self):
""" Return columns"""
return self.columns
def get_tasks(self):
""" Return tasks"""
return self.tasks
def get_task(self, icol, itask):
""" Return a task based on column and task index"""
return self.tasks[icol][itask]
def update_task( self, icol, itask, task):
""" Update the task based on text """
self.tasks[icol][itask] = task
def add_task( self, icol, task):
"""Add a task to icol"""
self.tasks[icol].append(task)
def del_task(self, icol, itask):
del self.tasks[icol][itask]

@ -0,0 +1,156 @@
""" This module contains classes and functions to contain the kanban board information """
import sys
import numpy as np
import yaml
import os
import glob
class Task:
"""This class represents each task,"""
def __init__(self, summary, score, description):
"""Initialize the task class"""
# Each task has the following properties
self.summary = summary # Summary of the task
self.score = score # Score for ticket
self.description = description # Description of ticket
class Board:
def __init__(self, file=None):
"""Initialize the Board class, this class has three important class variables.
These are: self.sprint | str - name of the current sprint
self.columns | list(str) - columns in kanban board
self.tasks | list(list()) - tasks in each column
"""
self.sprint = None
self.columns = list()
self.tasks = list()
self.file = file
if file:
self.read_yaml(file)
def read_yaml(self, file):
"""Read the yaml file in and set up the data
Arguments:
file - yaml file to read in
"""
# Read in the data
with open(file, "r") as f:
data = yaml.safe_load(f)
# Assign the data to board variables
self.columns = data["columns"]
self.tasks = [[] for col in self.columns]
for task in data["tasks"]:
self.tasks[self.columns.index(task["column"])].append(
Task(task["summary"], task["score"], task["description"])
)
def write_yaml(self, file):
"""Write the yaml file
Arguments:
file - yaml file to write to
"""
# Set up data to write out
data = dict()
data["columns"] = self.columns
data["tasks"] = list()
for col, task_list in zip(self.columns, self.tasks):
for task in task_list:
data["tasks"].append(
{
"column": col,
"summary": task.summary,
"score": task.score,
"description": task.description,
}
)
with open(file, "w") as f:
yaml.dump(data, f)
def move_task(self, col_index, task_index, direction):
"""This class method moves tasks between columns by incrementing/decrementing the column
index
Arguments:
col_index - index of the column we are in
task_index - index of the task we are changing in the column
direction - direction to move the task
Returns:
moved - True if a task was moved else false
"""
task = self.tasks[col_index][task_index]
if col_index + direction >= 0 and col_index + direction < len(self.columns):
self.tasks[col_index + direction].append(task)
del self.tasks[col_index][task_index]
return True
else:
return False
def print_board_items(self):
for i, col in enumerate(self.columns):
print(col)
print(self.tasks[i])
def get_columns(self):
"""Return columns"""
return self.columns
def get_tasks(self):
"""Return tasks"""
return self.tasks
def get_task(self, icol, itask):
"""Return a task based on column and task index"""
return self.tasks[icol][itask]
def update_task(self, icol, itask, task):
"""Update the task based on text"""
self.tasks[icol][itask] = task
def add_task(self, icol, task):
"""Add a task to icol"""
self.tasks[icol].append(task)
def del_task(self, icol, itask):
del self.tasks[icol][itask]
class BoardList:
"""This class is used to process the full list of boards"""
def __init__(self):
self.boards = self.get_boards()
def get_boards(self):
"""This function returns the boards that have been created"""
configpath = os.path.join(
os.environ.get("APPDATA")
or os.environ.get("XDG_CONFIG_HOME")
or os.path.join(os.environ["HOME"], ".config"),
"pykban",
)
boards = list()
for board in glob.glob(os.path.join(configpath, "(*.yaml)")):
with open(board, "r") as f:
data = yaml.safe_load(f)
try:
boards.append((data["name"], board))
except KeyError:
print("Board yaml file is missing the name attribute")
sys.exit()
return boards

@ -19,6 +19,14 @@ EditColScreen {
layout: vertical;
background: #000000 25%;
}
SelectBoardScreen{
align: center middle;
overflow-x: hidden;
layout: vertical;
background: #000000 25%;
}
.column {
width: 1fr;
height: 100%;

@ -3,24 +3,27 @@ from textual.widgets import Static, Label, ListItem, ListView, TextArea, Input
from textual.containers import Horizontal, Vertical
from textual.screen import Screen
from textual.binding import Binding
from board import Board, Task
from .board import Board, Task, BoardList
class TaskList(ListView):
"""
Inherited widget from Listview to use as the kanban board columns
Inherited widget from Listview to use as the kanban board columns
"""
# Keybinds
BINDINGS = [
Binding("k", "cursor_up", "Cursor Up", show=False, priority=True),
Binding("j", "cursor_down", "Cursor Down", show=False, priority=True),
]
class EditTaskScreen(Screen):
"""
This is a screen used to edit the name of a task
This is a screen used to edit the name of a task
"""
CSS="""
CSS = """
Label{
width:50%;
background: #282828;
@ -47,10 +50,11 @@ class EditTaskScreen(Screen):
}
"""
BINDINGS = [
Binding('ctrl+s', 'save', 'Save Changes', priority=True),
Binding('escape', 'exit', 'Exit Without Changes', priority=True),
Binding("ctrl+s", "save", "Save Changes", priority=True),
Binding("escape", "exit", "Exit Without Changes", priority=True),
]
def __init__(self,text):
def __init__(self, text):
"""
Initialize the screen
"""
@ -61,19 +65,18 @@ class EditTaskScreen(Screen):
"""
Compose the widgets on the screen, this screen doesn't need dynamic layout changes
"""
yield Label('Task Name:')
yield Label("Task Name:")
yield Input(value=self.text.summary)
yield Label('Score:')
yield Label("Score:")
if self.text.score:
yield Input(value=self.text.score)
else:
yield Input(value="")
yield Label('Description:')
yield Label("Description:")
if self.text.description:
yield TextArea(self.text.description, language='markdown')
yield TextArea(self.text.description, language="markdown")
else:
yield TextArea(language='markdown')
yield TextArea(language="markdown")
def action_save(self):
query = self.query(selector=Input)
@ -86,11 +89,13 @@ class EditTaskScreen(Screen):
def action_exit(self):
self.dismiss(None)
class EditColScreen(Screen):
"""
This is a screen used to edit the name of a task
This is a screen used to edit the name of a task
"""
CSS="""
CSS = """
Label{
width:50%;
background: #282828;
@ -104,10 +109,11 @@ class EditColScreen(Screen):
}
"""
BINDINGS = [
Binding('ctrl+s', 'save', 'Save Changes', priority=True),
Binding('enter', 'save', 'Save Changes', priority=True),
Binding("ctrl+s", "save", "Save Changes", priority=True),
Binding("enter", "save", "Save Changes", priority=True),
]
def __init__(self,text):
def __init__(self, text):
"""
Initialize the screen
"""
@ -118,67 +124,166 @@ class EditColScreen(Screen):
"""
Compose the widgets on the screen, this screen doesn't need dynamic layout changes
"""
yield Label('Column Name:')
yield Label("Column Name:")
yield Input(value=self.text)
def action_save(self):
query = self.query(selector=Input)
self.dismiss(query.nodes[0].value)
class KanbanForm(App):
CSS_PATH = 'layout.tcss'
class SelectBoardScreen(Screen):
"""This is a screen used to select a board"""
BINDINGS = [
Binding("a", "new_task", "Add New Task", show=False, ),
Binding("l", "fnext", "Focus Next", show=False, ),
Binding("h", "fprev", "Focus Prev", show=False, ),
Binding("enter", "pick_option", "Save Changes", priority=True),
Binding("q", "exit", "Exit"),
]
CSS = """
$bg: #282828;
Label{
width:50%;
background: #282828;
padding: 0;
}
TaskList{
width:50%;
background: #282828;
padding: 0 0;
border: #ebdbb9;
}
ListView{
width:50%;
background: #282828;
}
ListItem{
border: solid #ebdbb2 100%;
background: $bg;
}
ListView > ListItem.--highlight {
background: $bg;
}
ListView:focus > ListItem.--highlight {
background: #458588;
}
Label:focus{
background: #458588;
}
"""
def __init__(self, logger):
"""
Initialize the screen
"""
super().__init__()
self.board_list = BoardList()
self.logger = logger
def compose(self):
"""
Compose the widgets on the screen, this screen doesn't need dynamic layout changes
"""
yield Label("Select a board:")
yield TaskList(
*[ListItem(Label(board)) for board in self.board_list.get_boards()],
ListItem(Label("Add a new board")),
)
def action_pick_option(self):
"""Pick a board from the ListItem"""
self.focused.highlighted_child
class MainBoardScreen(Screen):
CSS_PATH = "layout.tcss"
BINDINGS = [
Binding(
"a",
"new_task",
"Add New Task",
show=False,
),
Binding(
"l",
"fnext",
"Focus Next",
show=False,
),
Binding(
"h",
"fprev",
"Focus Prev",
show=False,
),
Binding("L", "move_up", "Focus Next", show=False),
Binding("H", "move_down", "Focus Prev", show=False),
Binding("e", "edit_task", "Edit Task", show=False,),
Binding("r", "edit_column", "Edit Column Name", show=False,),
Binding("d", "delete_task", "Delete Task", show=False,),
Binding('q', 'exit', "Exit")
]
Binding(
"e",
"edit_task",
"Edit Task",
show=False,
),
Binding(
"r",
"edit_column",
"Edit Column Name",
show=False,
),
Binding(
"d",
"delete_task",
"Delete Task",
show=False,
),
Binding("q", "exit", "Exit"),
]
def __init__(self):
"""Initialize the Kanban Form App"""
super().__init__()
def compose(self):
"""
Initialization function for form
"""
# Initialize our board class
self.board = Board(file = '.board.yaml')
self.board = Board(file=".board.yaml")
self.cols = list()
self.col_widgets = list()
with Horizontal():
for i,col in enumerate(self.board.get_columns()):
if i < len(self.board.get_columns())-1:
col_class = 'column'
for i, col in enumerate(self.board.get_columns()):
if i < len(self.board.get_columns()) - 1:
col_class = "column"
else:
col_class = 'last-column'
col_class = "last-column"
with Vertical(classes=col_class):
if i == 0:
yield Static(col, classes='header-focused')
yield Static(col, classes="header-focused")
else:
yield Static(col, classes='header')
yield Static(col, classes="header")
yield TaskList(
*[ListItem(Label(task.summary)) for task in self.board.get_tasks()[i]])
*[
ListItem(Label(task.summary))
for task in self.board.get_tasks()[i]
]
)
def action_fnext(self):
""" Focus next column"""
"""Focus next column"""
query = self.query(selector=Static)
query = [node for node in query.nodes if str(node) == 'Static()']
query = [node for node in query.nodes if str(node) == "Static()"]
icol, _ = self.get_col_task()
query[icol].classes="header"
query[icol].classes = "header"
self.children[0].focus_next()
try:
query[icol+1].classes="header-focused"
query[icol + 1].classes = "header-focused"
except IndexError:
query[0].classes="header-focused"
query[0].classes = "header-focused"
def action_move_up(self):
icol, itask = self.get_col_task()
@ -187,23 +292,22 @@ class KanbanForm(App):
if moved:
query = self.query(selector=TaskList)
self.focused.highlighted_child.remove()
query.nodes[icol+1].append(ListItem(Label(text)))
query.nodes[icol + 1].append(ListItem(Label(text)))
self.focused.action_cursor_down()
self.action_fnext()
self.focused.action_cursor_down()
def action_fprev(self):
""" Focus previous column """
"""Focus previous column"""
query = self.query(selector=Static)
query = [node for node in query.nodes if str(node) == 'Static()']
query = [node for node in query.nodes if str(node) == "Static()"]
icol, _ = self.get_col_task()
query[icol].classes="header"
query[icol].classes = "header"
self.children[0].focus_previous()
try:
query[icol-1].classes="header-focused"
query[icol - 1].classes = "header-focused"
except IndexError:
query[-1].classes="header-focused"
query[-1].classes = "header-focused"
def action_move_down(self):
icol, itask = self.get_col_task()
@ -212,7 +316,7 @@ class KanbanForm(App):
if moved:
query = self.query(selector=TaskList)
self.focused.highlighted_child.remove()
query.nodes[icol-1].append(ListItem(Label(text)))
query.nodes[icol - 1].append(ListItem(Label(text)))
self.focused.action_cursor_down()
self.action_fprev()
self.focused.action_cursor_down()
@ -221,9 +325,9 @@ class KanbanForm(App):
icol, itask = self.get_col_task()
task = self.board.get_task(icol, itask)
self.push_screen(EditTaskScreen(task), self.update_task)
def action_new_task(self):
self.push_screen(EditTaskScreen(Task(None,None,None)), self.new_task)
self.push_screen(EditTaskScreen(Task(None, None, None)), self.new_task)
def action_edit_column(self):
icol, itask = self.get_col_task()
@ -233,25 +337,23 @@ class KanbanForm(App):
def action_delete_task(self):
icol, itask = self.get_col_task()
self.focused.highlighted_child.remove()
self.board.del_task(icol,itask)
self.board.del_task(icol, itask)
def update_col(self, text):
""" Update the column
"""
"""Update the column"""
icol, itask = self.get_col_task()
query = self.query(selector=Static)
query = [node for node in query.nodes if str(node) == 'Static()']
query = [node for node in query.nodes if str(node) == "Static()"]
query[icol].update(text)
self.board.get_columns()[icol] = text
def action_exit(self):
""" Exit the application """
self.board.write_yaml(file='.board.yaml')
"""Exit the application"""
self.board.write_yaml(file=".board.yaml")
self.exit()
def get_col_task(self):
"""
"""
This function gets the relevant column and task from the Board object for the current
selected item in the tui.
"""
@ -267,15 +369,15 @@ class KanbanForm(App):
to_move = focused_col.highlighted_child
task_index = None
for i, child in enumerate(focused_col.children):
if to_move == child:
if to_move == child:
task_index = i
return col_index, task_index
def update_task(self, task):
""" This function gets the text inputted in the edit screen and updates the underlying
task and the board class
"""This function gets the text inputted in the edit screen and updates the underlying
task and the board class
"""
if task:
icol, itask = self.get_col_task()
@ -283,20 +385,33 @@ class KanbanForm(App):
self.board.update_task(icol, itask, task)
def new_task(self, task):
""" This function adds a new task to our board
"""
"""This function adds a new task to our board"""
if task:
icol,_ = self.get_col_task()
icol, _ = self.get_col_task()
self.focused.mount(ListItem(Label(task.summary)))
self.board.add_task(icol, task)
self.focused.action_cursor_down()
class KanbanForm(App):
"""Main Kanban app"""
CSS_PATH = "layout.tcss"
SCREENS = {"main": SelectBoardScreen()}
def on_mount(self):
self.push_screen("main")
# def on_key(self):
# with open('log','a') as f:
# f.write("{}".format(self.children[0].focus_next))
if __name__ == "__main__":
def run_tui():
kb = KanbanForm()
kb.run()
if __name__ == "__main__":
run_tui()