├── notesh
├── __init__.py
├── drawables
│ ├── __init__.py
│ ├── sticknote.py
│ ├── box.py
│ └── drawable.py
├── widgets
│ ├── __init__.py
│ ├── focusable_footer.py
│ ├── sidebar_left.py
│ ├── multiline_input.py
│ ├── sidebar.py
│ └── color_picker.py
├── command_line.py
├── default_bindings.toml
├── utils.py
├── play_area.py
├── main.css
└── main.py
├── documentation
├── .gitkeep
├── Layers.gif
├── Resizing.gif
├── CreateNote.gif
├── HoverOver.gif
├── NewDrawable.png
├── NoteshApp.png
├── DynamicResize.gif
├── HoptexNotesh.gif
└── ChangeBackgroundColor.gif
├── .python-version
├── requirements.txt
├── setup.cfg
├── .gitignore
├── .flake8
├── Notesh.desktop
├── pyproject.toml
├── setup.py
├── LICENSE
└── README.md
/notesh/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/documentation/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.12.2
2 |
--------------------------------------------------------------------------------
/notesh/drawables/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/notesh/widgets/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | textual==5.0.1
2 | tomli==2.0.1
3 | hoptex==0.2.0
4 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | license_files = LICENSE
3 | license = MIT
4 |
--------------------------------------------------------------------------------
/documentation/Layers.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/HEAD/documentation/Layers.gif
--------------------------------------------------------------------------------
/documentation/Resizing.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/HEAD/documentation/Resizing.gif
--------------------------------------------------------------------------------
/documentation/CreateNote.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/HEAD/documentation/CreateNote.gif
--------------------------------------------------------------------------------
/documentation/HoverOver.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/HEAD/documentation/HoverOver.gif
--------------------------------------------------------------------------------
/documentation/NewDrawable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/HEAD/documentation/NewDrawable.png
--------------------------------------------------------------------------------
/documentation/NoteshApp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/HEAD/documentation/NoteshApp.png
--------------------------------------------------------------------------------
/documentation/DynamicResize.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/HEAD/documentation/DynamicResize.gif
--------------------------------------------------------------------------------
/documentation/HoptexNotesh.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/HEAD/documentation/HoptexNotesh.gif
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | venv/
2 | __pycache__
3 | notes.json
4 | notes*.json
5 | dist/
6 | build/
7 | *.egg-info
8 |
9 | test
10 |
--------------------------------------------------------------------------------
/documentation/ChangeBackgroundColor.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/HEAD/documentation/ChangeBackgroundColor.gif
--------------------------------------------------------------------------------
/notesh/widgets/focusable_footer.py:
--------------------------------------------------------------------------------
1 | from textual.widgets import Footer
2 |
3 |
4 | class FocusableFooter(Footer):
5 | can_focus = True
6 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ; ignore = F403, F401
3 | ignore = F403, F401, F841, F811, F541
4 | exclude = .git,__pycache__,old,build,dist,venv
5 | max-line-length = 88
6 | max-complexity = 18
7 |
--------------------------------------------------------------------------------
/Notesh.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Version=1.0
3 | Type=Application
4 | Name=Notesh
5 | Comment=Sticky notes App in your Terminal!
6 | Exec=python3 -m notesh.main
7 | Icon=edit
8 | Terminal=true
9 | StartupNotify=true
10 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | line-length = 120
3 | include = '\.pyi?$'
4 | exclude = '''
5 | /(
6 | | \.git
7 | | venv
8 | | build
9 | | dist
10 | )/
11 | '''
12 |
13 | [tool.pyright]
14 | typeCheckingMode = "strict"
15 |
--------------------------------------------------------------------------------
/notesh/command_line.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from __future__ import annotations
3 |
4 | from notesh.main import NoteApp
5 |
6 |
7 | def run():
8 | import argparse
9 |
10 | parser = argparse.ArgumentParser(description="Run Sticky Notes in your Terminal!")
11 | parser.add_argument(
12 | "-f",
13 | "--file",
14 | default=NoteApp.DEFAULT_FILE,
15 | help=f"Notes file to use. Defaults to $NOTESH_FILE or $XDG_DATA_HOME/notesh/notes.json (currently: {NoteApp.DEFAULT_FILE!r})",
16 | required=False,
17 | )
18 | argsx = parser.parse_args()
19 | NoteApp(file=argsx.file).run()
20 |
21 |
22 | if __name__ == "__main__":
23 | run()
24 |
--------------------------------------------------------------------------------
/notesh/default_bindings.toml:
--------------------------------------------------------------------------------
1 | [default]
2 | quit = ["ctrl+q", "Quit"]
3 | toggle_sidebar_left = ["ctrl+e", "Sidebar Left"]
4 | add_note = ["ctrl+a", "Create Stick Note"]
5 | add_box = ["ctrl+x", "Create Box"]
6 | save_notes = ["ctrl+s", "Save Notes"]
7 | unfocus = ["escape", "Unfocus"]
8 | "app.toggle_dark" = ["ctrl+t", "Dark/Light"]
9 |
10 | [moving_drawables]
11 | left = "h"
12 | right = "l"
13 | up = "k"
14 | down = "j"
15 | left_5 = "H"
16 | right_5 = "L"
17 | up_5 = "K"
18 | down_5 = "J"
19 |
20 | [normal_insert]
21 | focus_next = "ctrl+i,ctrl+j"
22 | focus_previous = "ctrl+o,ctrl+k"
23 | unfocus = "escape"
24 |
25 | [normal]
26 | edit = "i"
27 | delete = "Q"
28 | add_note = "o"
29 | add_box = "O"
30 |
31 | [resize_drawable]
32 | h_plus = "greater_than_sign"
33 | h_minus = "less_than_sign"
34 | v_plus = "plus"
35 | v_minus = "minus"
36 |
37 | [bring_drawable]
38 | forward = "ctrl+f"
39 | backward = "ctrl+b"
40 |
41 | [hoptex]
42 | focus = "ctrl+n"
43 | quit = "escape,ctrl+c"
44 | unfocus = "escape,ctrl+c"
45 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from setuptools import setup
3 |
4 | # Read the long description from README.md
5 | this_directory = Path(__file__).parent
6 | long_description = (this_directory / "README.md").read_text()
7 |
8 | setup(
9 | name="Notesh",
10 | version="0.9.0",
11 | description="NoteSH: A fully functional sticky notes App in your Terminal!",
12 | long_description=long_description,
13 | long_description_content_type="text/markdown",
14 | url="http://github.com/Cvaniak/Notesh",
15 | author="Cvaniak",
16 | author_email="igna.cwaniak@gmail.com",
17 | packages=["notesh", "notesh.drawables", "notesh.widgets"],
18 | python_requires=">=3.8, <4",
19 | install_requires=["textual==5.0.1", "tomli==2.0.1", "hoptex==0.2.0"],
20 | entry_points={"console_scripts": ["notesh=notesh.command_line:run"]},
21 | package_data={
22 | "notesh": ["*.css", "notesh/*.css", "default_bindings.toml"]
23 | },
24 | data_files=[("share/applications", ["Notesh.desktop"])],
25 | include_package_data=True,
26 | zip_safe=False,
27 | )
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Cvaniak
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/notesh/widgets/sidebar_left.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Optional, OrderedDict
4 |
5 | from textual.app import ComposeResult
6 | from textual.color import Color
7 | from textual.containers import Vertical
8 | from textual.widget import Widget
9 | from textual.widgets import Static
10 |
11 | from notesh.drawables.drawable import Drawable
12 | from notesh.play_area import PlayArea
13 | from notesh.widgets.color_picker import ColorPicker
14 |
15 |
16 | class SidebarLeft(Vertical):
17 | can_focus_children: bool = False
18 |
19 | def __init__(
20 | self,
21 | *children: Widget,
22 | name: str | None = None,
23 | id: str | None = None,
24 | classes: str | None = None,
25 | ) -> None:
26 | super().__init__(*children, name=name, id=id, classes=classes)
27 | self.drawable: Optional[Drawable] = None
28 |
29 | self.play_area = None
30 |
31 | title = Static("Here more settings soon!", id="sidebar-title")
32 | body_color_picker = ColorPicker(type="body", title="Body Color Picker")
33 | border_color_picker = ColorPicker(type="border", title="Border Color Picker")
34 |
35 | self.widget_list: OrderedDict[str, Widget] = OrderedDict(
36 | {
37 | "title": title,
38 | "body_color_picker": body_color_picker,
39 | "border_color_picker": border_color_picker,
40 | }
41 | )
42 |
43 | def compose(self) -> ComposeResult:
44 | self.current_layout = Vertical(*self.widget_list.values())
45 | yield self.current_layout
46 |
47 | def toggle_focus(self):
48 | if self.has_class("-hidden"):
49 | self.set_focus(True)
50 | return True
51 | else:
52 | self.set_focus(False)
53 | return False
54 |
55 | def set_focus(self, focus: bool):
56 | if focus is True:
57 | self.remove_class("-hidden")
58 | self.can_focus_children = True
59 | else:
60 | self.add_class("-hidden")
61 | self.can_focus_children = False
62 |
63 | def set_play_area(self, play_area: PlayArea):
64 | self.play_area = play_area
65 | self.play_area.sidebar_layout(self.widget_list)
66 |
67 | def change_play_area_color(self, color: Color | str, part_type: str) -> None:
68 | if self.play_area:
69 | self.play_area.change_color(color, part_type=part_type)
70 |
71 | def on_color_picker_change(self, message: ColorPicker.Change):
72 | self.change_play_area_color(message.color, message.type)
73 |
--------------------------------------------------------------------------------
/notesh/widgets/multiline_input.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from textual.app import ComposeResult
4 | from textual.binding import Binding
5 | from textual.containers import Vertical
6 | from textual.message import Message
7 | from textual.widgets import Input
8 |
9 | from notesh.utils import generate_short_uuid
10 |
11 |
12 | class MultilineInput(Input):
13 | BINDINGS = Input.BINDINGS
14 | BINDINGS.extend(
15 | [
16 | Binding("up", "cursor_up", "cursor up", show=False),
17 | Binding("down", "cursor_down", "cursor down", show=False),
18 | ]
19 | )
20 |
21 | def action_cursor_up(self) -> None:
22 | self.post_message(self.Arrow(self, "up"))
23 |
24 | def action_cursor_down(self) -> None:
25 | self.post_message(self.Arrow(self, "down"))
26 |
27 | def action_delete_left(self) -> None:
28 | super().action_delete_left()
29 | if not self.value:
30 | self.post_message(self.Backspace(self))
31 |
32 | class Backspace(Message):
33 | def __init__(self, multiline_input: MultilineInput) -> None:
34 | super().__init__()
35 | self.multiline_input = multiline_input
36 |
37 | class Arrow(Message):
38 | def __init__(self, multiline_input: MultilineInput, top_down: str) -> None:
39 | super().__init__()
40 | self.multiline_input = multiline_input
41 | self.top_down = top_down
42 |
43 |
44 | class MultilineArray(Vertical):
45 | lines = [MultilineInput("", id="sidebar-input-0")]
46 |
47 | def compose(self) -> ComposeResult:
48 | for line in self.lines:
49 | yield line
50 |
51 | def on_input_submitted(self, event: Input.Submitted):
52 | idx = self.lines.index(event.input)
53 | new_input = MultilineInput("", id=f"sidebar-input-{generate_short_uuid()}")
54 | self.lines.insert(idx + 1, new_input)
55 | self.mount(new_input, after=event.input)
56 | self.screen.set_focus(new_input)
57 |
58 | def on_multiline_input_backspace(self, event: MultilineInput.Backspace):
59 | if event.multiline_input not in self.lines:
60 | return
61 | idx = self.lines.index(event.multiline_input)
62 | if len(self.lines) >= 2:
63 | self.lines.remove(event.multiline_input)
64 | event.multiline_input.remove()
65 | if self.lines:
66 | self.screen.set_focus(self.lines[idx - 1])
67 |
68 | def on_multiline_input_arrow(self, event: MultilineInput.Arrow):
69 | idx = self.lines.index(event.multiline_input)
70 | n = len(self.lines)
71 | if event.top_down == "up":
72 | if idx == 0:
73 | return
74 | self.screen.set_focus(self.lines[idx - 1])
75 | else:
76 | if idx + 1 == n:
77 | return
78 | self.screen.set_focus(self.lines[idx + 1])
79 |
80 | def recreate_multiline(self, value: str) -> None:
81 | while self.lines:
82 | self.lines.pop().remove()
83 |
84 | for line in value.split(" \n"):
85 | self.lines.append(MultilineInput(line, id=f"sidebar-input-{len(self.lines)}"))
86 | # self.mount(self.lines[-1])
87 |
88 | async def on_input_changed(self, event: MultilineInput.Changed):
89 | event.stop()
90 | self.post_message(self.Changed(self))
91 |
92 | class Changed(Message):
93 | def __init__(self, sender: MultilineArray) -> None:
94 | super().__init__()
95 | self.input = sender
96 |
--------------------------------------------------------------------------------
/notesh/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 | import os
5 | from pathlib import Path
6 | import sys
7 | import uuid
8 | from functools import partial
9 | from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Union
10 | from textual.app import App
11 |
12 | import tomli
13 | from textual.geometry import Size
14 |
15 | if TYPE_CHECKING:
16 | from textual.containers import Container
17 |
18 | from notesh.drawables.drawable import Drawable
19 |
20 |
21 | def generate_short_uuid() -> str:
22 | return uuid.uuid4().hex[:4]
23 |
24 |
25 | def calculate_size_for_file(file_name: str) -> tuple[Size, Size]:
26 | if not os.path.exists(file_name):
27 | return Size(0, 0), Size(50, 20)
28 |
29 | with open(file_name, "r") as file:
30 | obj = json.load(file)
31 |
32 | keys = [x for x in obj.keys() if x not in ["background", "layers"]]
33 |
34 | mxx, mxy = -sys.maxsize, -sys.maxsize
35 | mnx, mny = sys.maxsize, sys.maxsize
36 | for drawable in sorted(keys, key=lambda x: obj["layers"].index(x)):
37 | mxx = max(mxx, obj[drawable]["pos"][0] + obj[drawable]["size"][0])
38 | mnx = min(mnx, obj[drawable]["pos"][0])
39 | mxy = max(mxy, obj[drawable]["pos"][1] + obj[drawable]["size"][1])
40 | mny = min(mny, obj[drawable]["pos"][1])
41 |
42 | mxx = 50 if mxx == sys.maxsize else max(mxx, 50)
43 | mxy = 20 if mxy == sys.maxsize else max(mxy, 20)
44 | mnx = 0 if mnx == sys.maxsize else mnx
45 | mny = 0 if mny == sys.maxsize else mny
46 |
47 | return Size(mnx, mny), Size(mxx, mxy)
48 |
49 |
50 | def save_drawables(
51 | file_name: str, drawables: list[Drawable], layers: list[str], background: Optional[dict[Any, Any]] = None
52 | ) -> None:
53 | obj: dict[str, Any] = {"layers": []}
54 | layers_set: set[str] = set()
55 | drawable: Drawable
56 | for drawable in drawables:
57 | if drawable.id is None:
58 | continue
59 | obj[drawable.id] = drawable.dump()
60 | layers_set.add(drawable.id)
61 |
62 | obj["layers"].extend([x for x in layers if x in layers_set])
63 | if background is not None:
64 | obj["background"] = background
65 |
66 | Path(file_name).parent.mkdir(parents=True, exist_ok=True)
67 | with open(file_name, "w") as file:
68 | json.dump(obj, file, indent=4)
69 |
70 |
71 | def load_drawables(file_name: str) -> tuple[list[tuple[str, dict[Any, Any]]], Optional[dict[Any, Any]]]:
72 | if not os.path.exists(file_name):
73 | return [], None
74 |
75 | with open(file_name, "r") as file:
76 | obj = json.load(file)
77 |
78 | if not obj:
79 | return [], None
80 |
81 | background = None
82 | if "background" in obj:
83 | background = obj["background"]
84 |
85 | keys = [x for x in obj.keys() if x not in ["background", "layers"]]
86 | return [(name, obj[name]) for name in sorted(keys, key=lambda x: obj["layers"].index(x))], background
87 |
88 |
89 | def load_binding_config_file(file_name: str) -> dict[str, Any]:
90 | try:
91 | if not os.path.exists(file_name):
92 | return {}
93 | with open(file_name, "rb") as f:
94 | conf = tomli.load(f)
95 | return conf
96 | except (FileNotFoundError, PermissionError, tomli.TOMLDecodeError):
97 | return {}
98 |
99 |
100 | def set_bindings(
101 | where: Union[Container, App[None]],
102 | config: dict[str, list[str] | str],
103 | show: bool = False,
104 | func: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None,
105 | ):
106 | for key, value in config.items():
107 | key_binding: str
108 | description: str
109 | if isinstance(value, list):
110 | key_binding, description = value[0], value[1]
111 | elif isinstance(value, str): # type: ignore
112 | key_binding, description = value, ""
113 | else:
114 | continue
115 | where._bindings.bind(key_binding, key, description, show=show, priority=False) # type: ignore
116 | if func is not None:
117 | setattr(where, f"action_{key}", partial(func, direction=key))
118 |
--------------------------------------------------------------------------------
/notesh/widgets/sidebar.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Optional, OrderedDict
4 |
5 | from textual.app import ComposeResult
6 | from textual.color import Color
7 | from textual.containers import Vertical
8 | from textual.message import Message
9 | from textual.widget import Widget
10 | from textual.widgets import Button, Input, Static, TextArea
11 |
12 | from notesh.drawables.drawable import Drawable
13 | from notesh.play_area import PlayArea
14 | from notesh.widgets.color_picker import ColorPicker
15 |
16 |
17 | class DeleteDrawable(Message):
18 | def __init__(self, drawable: Drawable) -> None:
19 | super().__init__()
20 | self.drawable = drawable
21 |
22 |
23 | class Sidebar(Vertical):
24 | can_focus_children: bool = False
25 |
26 | def __init__(
27 | self,
28 | *children: Widget,
29 | name: str | None = None,
30 | id: str | None = None,
31 | classes: str | None = None,
32 | ) -> None:
33 | super().__init__(*children, name=name, id=id, classes=classes)
34 | self.drawable: Optional[Drawable] = None
35 |
36 | # input = Static("Title", id="sidebar-title")
37 | # multiline_input = TextArea.code_editor()
38 |
39 | body_color_picker = ColorPicker(type="body", title="Body Color Picker")
40 |
41 | border_color_picker = ColorPicker(type="border", title="Border Color Picker")
42 | border_type = Button("Change Border", id="border-picker", variant="warning")
43 |
44 | button = Button("Delete Note", id="delete-sticknote", variant="error")
45 |
46 | self.widget_list: OrderedDict[str, Widget] = OrderedDict(
47 | {
48 | # "input": input,
49 | # "multiline_input": multiline_input,
50 | "body_color_picker": body_color_picker,
51 | "border_color_picker": border_color_picker,
52 | "border_picker": border_type,
53 | "delete_button": button,
54 | }
55 | )
56 |
57 | def compose(self) -> ComposeResult:
58 | self.current_layout = Vertical(*self.widget_list.values())
59 | yield self.current_layout
60 |
61 | def change_sidebar(self):
62 | for widget in self.widget_list.values():
63 | widget.add_class("-hidden")
64 |
65 | if self.drawable is not None:
66 | self.drawable.sidebar_layout(self.widget_list)
67 |
68 | async def set_drawable(self, drawable: Optional[Drawable], display_sidebar: bool = False):
69 | self.drawable = drawable
70 | self.change_sidebar()
71 |
72 | if self.drawable is None:
73 | self.set_focus(False)
74 | elif display_sidebar:
75 | self.set_focus(True)
76 | self.refresh()
77 |
78 | # async def on_input_changed(self, event: Input.Changed):
79 | # if self.drawable is not None:
80 | # self.drawable.input_changed(event)
81 |
82 | # async def on_text_area_changed(self, event: TextArea.Changed):
83 | # if self.drawable is not None:
84 | # self.drawable.multiline_array_changed(event)
85 |
86 | def change_drawable_color(self, color: Color | str, part_type: str):
87 | if self.drawable:
88 | self.drawable.change_color(color, part_type=part_type)
89 |
90 | def on_button_pressed(self, event: Button.Pressed):
91 | if not self.drawable:
92 | return
93 | button_id = event.button.id
94 | if button_id == "delete-sticknote":
95 | if not self.drawable:
96 | return
97 | self.post_message(DeleteDrawable(self.drawable))
98 | self.screen.query_one(PlayArea).post_message(DeleteDrawable(self.drawable))
99 | self.refresh()
100 | if button_id == "border-picker":
101 | self.drawable.next_border()
102 |
103 | def on_color_picker_change(self, message: ColorPicker.Change):
104 | self.change_drawable_color(message.color, message.type)
105 |
106 | def get_child(self, index: Optional[int] = None) -> Optional[Widget]:
107 | if index is None:
108 | for child in self.widget_list.values():
109 | if child.has_class("-hidden"):
110 | continue
111 | # Should be change to something pretier
112 | return child
113 | return None
114 | child: Widget = list(self.widget_list.values())[index % len(self.widget_list)]
115 | if child.has_class("-hidden"):
116 | return None
117 | return child
118 |
119 | def toggle_focus(self) -> bool:
120 | if self.has_class("-hidden"):
121 | self.set_focus(True)
122 | return True
123 | else:
124 | self.set_focus(False)
125 | return False
126 |
127 | def set_focus(self, focus: bool):
128 | if focus is True:
129 | self.remove_class("-hidden")
130 | self.can_focus_children = True
131 | else:
132 | self.add_class("-hidden")
133 | self.can_focus_children = False
134 |
--------------------------------------------------------------------------------
/notesh/widgets/color_picker.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from random import randint
4 |
5 | from textual.app import ComposeResult
6 | from textual.color import Color
7 | from textual.containers import Grid, Horizontal, Vertical
8 | from textual.events import MouseScrollDown, MouseScrollUp
9 | from textual.message import Message
10 | from textual.reactive import reactive
11 | from textual.widget import Widget
12 | from textual.widgets import Button, Static
13 |
14 |
15 | class ColorPickerRandom(Button):
16 | ...
17 |
18 |
19 | class ColorPickerDisplay(Static):
20 | ...
21 |
22 |
23 | class ColorPickerChanger(Grid):
24 | value: reactive[int] = reactive(0)
25 |
26 | def __init__(
27 | self,
28 | *children: Widget,
29 | parent: ColorPicker,
30 | color_arg: str = "",
31 | id: str | None = None,
32 | ) -> None:
33 | super().__init__(*children, id=id)
34 | self.pparent = parent
35 | self.argument = color_arg
36 | self.static_widget = Static(" ", classes="-color")
37 | w = {"r": 50, "g": 50, "b": 50}
38 | w.update({self.argument: 210})
39 | self.static_widget.styles.background = Color(**w)
40 | self.value = getattr(self.pparent, self.argument)
41 |
42 | self.button_up = Button("▲", id="up", classes="-up")
43 | self.button_down = Button("▼", id="down", classes="-down")
44 |
45 | def compose(self) -> ComposeResult:
46 | yield self.static_widget
47 | yield self.button_up
48 | yield self.button_down
49 |
50 | def watch_value(self, new_value: int):
51 | self.static_widget.update(f"{self.argument}\n{new_value:<3}")
52 |
53 | def on_button_pressed(self, event: Button.Pressed):
54 | new_value = 0
55 | button_id = event.button.id
56 | if button_id == "up":
57 | new_value = 10
58 | if button_id == "down":
59 | new_value = -10
60 |
61 | _value = getattr(self.pparent, self.argument) + new_value
62 | setattr(self.pparent, self.argument, _value)
63 | self.value = getattr(self.pparent, self.argument)
64 |
65 | async def on_mouse_scroll_down(self, event: MouseScrollDown):
66 | _value = getattr(self.pparent, self.argument) + 1
67 | setattr(self.pparent, self.argument, _value)
68 | self.value = getattr(self.pparent, self.argument)
69 |
70 | async def on_mouse_scroll_up(self, event: MouseScrollUp):
71 | _value = getattr(self.pparent, self.argument) - 1
72 | setattr(self.pparent, self.argument, _value)
73 | self.value = getattr(self.pparent, self.argument)
74 |
75 |
76 | class ColorPicker(Vertical):
77 | r: reactive[int] = reactive(0)
78 | g: reactive[int] = reactive(0)
79 | b: reactive[int] = reactive(0)
80 | hue: reactive[int] = reactive(0)
81 |
82 | def __init__(
83 | self,
84 | *children: Widget,
85 | title: str = "Color Picker",
86 | type: str = "",
87 | name: str | None = None,
88 | id: str | None = None,
89 | classes: str | None = None,
90 | ) -> None:
91 | super().__init__(*children, name=name, id=id, classes=classes)
92 | self.color_display = ColorPickerDisplay("")
93 | self.color_display.styles.background = Color(self.r, self.g, self.b)
94 | self.title = Static(title, id="color-picker-title")
95 | self.type = type
96 | self.color_changers = {
97 | "r": ColorPickerChanger(color_arg="r", parent=self, id="change-r"),
98 | "g": ColorPickerChanger(color_arg="g", parent=self, id="change-g"),
99 | "b": ColorPickerChanger(color_arg="b", parent=self, id="change-b"),
100 | }
101 |
102 | def compose(self) -> ComposeResult:
103 | yield self.title
104 | yield self.color_display
105 | yield Horizontal(*self.color_changers.values(), Button(" ?? ?? ", id="random-color"), id="color-changers")
106 |
107 | def update_colors(self, color: Color):
108 | self.r, self.g, self.b = color.rgb
109 | for c in self.color_changers:
110 | self.color_changers[c].value = getattr(self, c)
111 |
112 | self.color_display.styles.background = color
113 |
114 | async def update_color(self):
115 | color = Color(self.r, self.g, self.b)
116 | self.color_display.styles.background = color
117 | for c in self.color_changers:
118 | self.color_changers[c].value = getattr(self, c)
119 |
120 | self.post_message(self.Change(color, argument=self.type))
121 |
122 | def on_button_pressed(self, event: Button.Pressed):
123 | button_id = event.button.id
124 | if button_id == "random-color":
125 | for i in "rgb":
126 | setattr(self, i, randint(30, 220))
127 |
128 | @staticmethod
129 | def _clamp(value: int) -> int:
130 | return max(0, min(value, 255))
131 |
132 | def validate_r(self, new_value: int) -> int:
133 | return self._clamp(new_value)
134 |
135 | def validate_g(self, new_value: int) -> int:
136 | return self._clamp(new_value)
137 |
138 | def validate_b(self, new_value: int) -> int:
139 | return self._clamp(new_value)
140 |
141 | async def watch_r(self, new_value: int):
142 | await self.update_color()
143 |
144 | async def watch_g(self, new_value: int):
145 | await self.update_color()
146 |
147 | async def watch_b(self, new_value: int):
148 | await self.update_color()
149 |
150 | class Change(Message):
151 | def __init__(self, color: Color | str, argument: str) -> None:
152 | super().__init__()
153 | self.color = color
154 | self.type = argument
155 |
--------------------------------------------------------------------------------
/notesh/drawables/sticknote.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Optional, OrderedDict, Type, TypeVar
4 |
5 | from textual import events
6 | from textual.app import ComposeResult
7 | from textual.color import Color
8 | from textual.containers import Horizontal, Vertical
9 | from textual.geometry import Offset, Size
10 | from textual.widget import Widget
11 | from textual.widgets import Input
12 | from textual.widgets import TextArea
13 |
14 | from notesh.drawables.drawable import Drawable, DrawablePart, DrawablePartStatic, Resizer
15 | from notesh.utils import generate_short_uuid
16 |
17 |
18 | def build_color(any_color: str | Color) -> tuple[Color, Color, Color, Color]:
19 | if isinstance(any_color, str):
20 | color: Color = Color.parse(any_color)
21 | else:
22 | color = any_color
23 |
24 | return (color.lighten(0.13), color, color.darken(0.12), color.darken(0.3))
25 |
26 |
27 | _T = TypeVar("_T")
28 |
29 |
30 | class Note(Drawable):
31 | type: str = "note"
32 |
33 | def __init__(
34 | self,
35 | title: str = "",
36 | body: str = "",
37 | color: str = "#ffaa00",
38 | pos: Offset = Offset(0, 0),
39 | size: Size = Size(20, 14),
40 | parent: Optional[Widget] = None,
41 | id: str | None = None,
42 | ) -> None:
43 | if id is None or id == "":
44 | id = f"note-{generate_short_uuid()}"
45 | self._title = title
46 | self._body = body
47 | super().__init__(id=id, color=color, pos=pos, parent=parent, size=size)
48 |
49 | def init_parts(self) -> None:
50 | self.title = NoteTop(id=f"note-top", parent=self, body=self._title)
51 | self.body = NoteBody(id=f"note-body", parent=self, body=self._body)
52 | self.spacer = Spacer(body="▌", id=f"note-spacer", parent=self)
53 | self.resizer_left = ResizerLeft(body="▌", id=f"note-resizer-left", parent=self)
54 | self.resizer = Resizer(body="◢█", id=f"note-resizer", parent=self)
55 |
56 | def drawable_body(self) -> ComposeResult:
57 | yield Vertical(
58 | Vertical(
59 | self.title,
60 | self.spacer,
61 | id="note-toper",
62 | ),
63 | self.body,
64 | Horizontal(
65 | self.resizer_left,
66 | self.resizer,
67 | id="note-resizer-bar",
68 | ),
69 | )
70 |
71 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None:
72 | if isinstance(new_color, str):
73 | base_color = Color.parse(new_color)
74 | else:
75 | base_color = new_color
76 |
77 | self.color = base_color
78 | self.update_layout(duration)
79 |
80 | def update_layout(self, duration: float = 1.0):
81 | base_color = self.color
82 | if self.is_entered:
83 | base_color = base_color.darken(0.1) if base_color.brightness > 0.9 else base_color.lighten(0.1)
84 |
85 | lighter, default, darker, much_darker = build_color(base_color)
86 |
87 | self.spacer.styles.background = much_darker
88 | self.spacer.styles.color = lighter
89 |
90 | # Hack to update color of the text area
91 | self.title.styles.background = default
92 |
93 | self.title.styles.animate("background", value=default, duration=duration)
94 | self.title.styles.border_top = ("outer", lighter)
95 | self.title.styles.border_left = ("outer", lighter)
96 | self.title.styles.border_right = ("outer", much_darker)
97 |
98 | # Hack to update color of the text area
99 | self.body.styles.background = darker
100 |
101 | self.body.styles.animate("background", value=darker, duration=duration)
102 | self.body.styles.border_right = ("outer", much_darker)
103 | self.body.styles.border_bottom = ("none", much_darker)
104 | self.body.styles.border_left = ("outer", lighter)
105 |
106 | self.resizer_left.styles.background = much_darker
107 | self.resizer_left.styles.color = lighter
108 |
109 | self.resizer.styles.background = much_darker
110 | self.resizer.styles.color = default
111 |
112 | # Hack to update color of the text area
113 | self.body.notify_style_update()
114 | self.title.notify_style_update()
115 |
116 |
117 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None:
118 | # widgets["input"].remove_class("-hidden")
119 | # widgets["multiline_input"].remove_class("-hidden")
120 | widgets["body_color_picker"].remove_class("-hidden")
121 | widgets["delete_button"].remove_class("-hidden")
122 |
123 | # widgets["input"].value = str(self.title.body)
124 | # widgets["multiline_input"].text = str(self.body.body) # TODO: recreate here
125 | widgets["body_color_picker"].update_colors(self.color)
126 |
127 | def dump(self) -> dict[str, Any]:
128 | return {
129 | "title": self.title.text,
130 | "body": self.body.text,
131 | "pos": (self.styles.offset.x.value, self.styles.offset.y.value),
132 | "color": self.color.hex6,
133 | "size": (self.styles.width.value, self.styles.height.value),
134 | "type": self.type,
135 | }
136 |
137 | @classmethod
138 | def load(cls: Type[_T], obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)):
139 | return cls(
140 | id=drawable_id,
141 | title=obj["title"],
142 | body=obj["body"],
143 | color=obj["color"],
144 | pos=Offset(*obj["pos"]) - offset,
145 | size=Size(*obj["size"]),
146 | )
147 |
148 |
149 | class NoteBody(DrawablePart):
150 | async def on_mouse_move(self, event: events.MouseMove) -> None:
151 | await self.pparent.drawable_is_moved(event)
152 |
153 |
154 | class NoteTop(DrawablePart):
155 | async def on_mouse_move(self, event: events.MouseMove) -> None:
156 | await self.pparent.drawable_is_moved(event)
157 |
158 |
159 | class Spacer(DrawablePartStatic):
160 | async def on_mouse_move(self, event: events.MouseMove) -> None:
161 | await self.pparent.drawable_is_moved(event)
162 |
163 |
164 | class ResizerLeft(DrawablePartStatic):
165 | async def on_mouse_move(self, event: events.MouseMove) -> None:
166 | await self.pparent.drawable_is_moved(event)
167 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
📝 NoteSH
3 |
4 |
5 |
6 | Fully functional sticky notes App in your Terminal! Built with Textual, an amazing TUI framework!
7 |
8 |
9 |
10 |
11 |
12 |
13 | ## In last Update
14 |
15 | * [**Hoptex**](https://github.com/Cvaniak/Hoptex) Support (you can focus anything easy now)!
16 | * User default note file
17 |
18 | 
19 |
20 | ## Installation
21 |
22 | Best option to install is using [pipx](https://github.com/pypa/pipx):
23 |
24 | ```bash
25 | pipx install notesh
26 | # but it is still possible to do it with just pip:
27 | pip install notesh
28 | ```
29 |
30 | ## Usage
31 |
32 | To start using just type in your terminal:
33 |
34 | ```bash
35 | notesh
36 | ```
37 |
38 | it will create new file notes.json in current directory.
39 | You can also specify file by using `-f` flag:
40 |
41 | ```bash
42 | notesh -f MyNotes.json
43 | # or full/relative path
44 | notesh -f ~/Documents/MyNotes.json
45 | ```
46 |
47 | ## ➕ Create new Note
48 |
49 | * To create new note just press `Ctrl+A`
50 | * You can change color with buttons but also using scroll
51 | * To edit note just click in its body
52 |
53 | 
54 |
55 | ## 🧅 It supports layers
56 |
57 | * To move note grab it top part and move with mouse
58 |
59 | 
60 |
61 | ## 🗚 You can resize notes
62 |
63 | * To resize grab left bottom corner and move with mouse
64 |
65 | 
66 |
67 | ## 💡 And background is resizable
68 |
69 | * If you make make background to big it will readjust after you reopen App
70 | * You can also click `CTRL-Mouse` to look around whole wall
71 |
72 | 
73 |
74 | ## 💡 Highlight when mouse is over
75 |
76 | 
77 |
78 | ## ➕ New Drawable that support borders change
79 |
80 | 
81 |
82 | ## ⌨️ Vim/Custom key bindings
83 |
84 | You can now do everything using KEYBOARD!
85 | This is first version so if you have any suggestions please write them in existing issue.
86 | Default keybindings are in `default_bindings.toml`
87 | file that is in root of installation.
88 | You can also create second file `user_bindings.toml` where you can overwrite defaults.
89 |
90 | ### What you can do
91 |
92 | * Change focus `focus_next/focus_previous` using `ctrl+i,ctrl+j/ctrl+o,ctrl+k`
93 | * Edit note `edit` using `i`
94 | * When note is focused you can move it with `j/k/l/h`.
95 | Also adding shift moves it more with one click
96 | * Clicking `unfocus` using `escape` returns from edit mode,
97 | and unfocus drawable if not in edit mode.
98 | * Resize note using `+/-` for vertical and `>/<` for horizontal
99 | * Bring 'ctrl+f' Forward and `ctrl+b` Backward Note
100 |
101 | ### Bindings file
102 |
103 |
104 | Default file
105 |
106 | ```toml
107 | # These are default, they also are displayed at the footer
108 | [default]
109 | quit = ["ctrl+q,ctrl+c", "Quit"]
110 | toggle_sidebar_left = ["ctrl+e", "Sidebar Left"]
111 | add_note = ["ctrl+a", "Create Stick Note"]
112 | add_box = ["ctrl+x", "Create Box"]
113 | save_notes = ["ctrl+s", "Save Notes"]
114 | unfocus = ["escape", "Unfocus"]
115 | "app.toggle_dark" = ["ctrl+t", "Dark/Light"]
116 |
117 | [moving_drawables]
118 | # Default movement
119 | left = "h"
120 | right = "l"
121 | up = "k"
122 | down = "j"
123 | # You can add number after _ and it will move note that many times
124 | left_5 = "H"
125 | right_5 = "L"
126 | up_5 = "K"
127 | down_5 = "J"
128 |
129 | [normal_insert]
130 | # there is only `next` and `previous` and the order is not changable yet
131 | focus_next = "ctrl+i,ctrl+j"
132 | focus_previous = "ctrl+o,ctrl+k"
133 | unfocus = "escape"
134 |
135 | [normal]
136 | edit = "i"
137 | delete = "Q"
138 | add_note = "o"
139 | add_box = "O"
140 |
141 | # For special characters like `+` or `<` you need to use names
142 | # You can check the name using textual `textual keys`
143 | [resize_drawable]
144 | h_plus = "greater_than_sign"
145 | h_minus = "less_than_sign"
146 | v_plus = "plus"
147 | v_minus = "minus"
148 |
149 | # It brings at the top or bottom the note
150 | [bring_drawable]
151 | forward = "ctrl+f"
152 | backward = "ctrl+b"
153 |
154 | [hoptex]
155 | focus = "ctrl+n"
156 | quit = "escape,ctrl+c"
157 | unfocus = "escape,ctrl+c"
158 | ```
159 |
160 |
161 |
162 |
163 | ## Change Background Color in Left Sidebar
164 |
165 | By default you can use `ctrl+e` to open Left Sidebar:
166 |
167 | 
168 |
169 | ## NEW FEATURES
170 |
171 | ## TODO
172 |
173 | There are many thigs to add! If you have idea, please create Issue with your suggestions.
174 |
175 | * [ ] Safe saving (now if there are any bugs you may lost your notes)
176 | * [x] Vim Key bindings
177 | * Wait for feedback
178 | * [ ] Duplicate Note
179 | * [ ] Hiding menu (Color Picker etc.)
180 | * [x] TOML config file
181 | * [ ] Left Sidebar (for background and preferences)
182 | * [x] Background color
183 | * [ ] Align tool for text
184 | * [ ] Fixed layers (if needed)
185 | * [ ] Diffrent Drawables:
186 | * [ ] Check List
187 | * [ ] Arrows
188 | * [ ] Help Screen
189 | * [ ] Command Pallet support
190 | * [ ] Menu to choose borders
191 | * [ ] Buttons to add new notes
192 |
193 | and also resolve problems:
194 |
195 | * [ ] Multiline Input (currently textual does not support it and here we have my hacky solution)
196 |
197 | ## Thanks
198 |
199 | Big thanks to [Will McGugan](https://github.com/willmcgugan) and all members and contributors of [Textualize.io](https://textualize.io)!
200 | Go checkout [Textual](https://github.com/Textualize/textual) amazing TUI framework on which this app is based.
201 |
--------------------------------------------------------------------------------
/notesh/drawables/box.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Optional, OrderedDict, Type, TypeVar
4 |
5 | from textual.app import events
6 | from textual.color import Color
7 | from textual.geometry import Offset, Size
8 | from textual.reactive import reactive
9 | from textual.widget import Widget
10 | from textual.widgets import TextArea, Static
11 | from textual.containers import Vertical, Horizontal
12 |
13 | from notesh.drawables.drawable import Body, Drawable, Resizer, DrawablePartStatic
14 | from notesh.widgets.multiline_input import MultilineArray
15 |
16 | BORDERS = [
17 | "outer",
18 | "ascii",
19 | "round",
20 | "solid",
21 | "double",
22 | "dashed",
23 | "heavy",
24 | "hkey",
25 | "vkey",
26 | "none",
27 | ]
28 |
29 | _T = TypeVar("_T")
30 |
31 |
32 | class Box(Drawable):
33 | type: str = "box"
34 | border_index: int = 0
35 | border_type: reactive[str] = reactive(BORDERS[border_index], always_update=True)
36 |
37 | def __init__(
38 | self,
39 | body: str = "",
40 | color: str = "#ffaa00",
41 | pos: Offset = Offset(0, 0),
42 | size: Size = Size(20, 14),
43 | parent: Optional[Widget] = None,
44 | border_color: str = "#ffaa00",
45 | border_type: str = "outer",
46 | id: str | None = None,
47 | ) -> None:
48 | super().__init__(id=id, init_parts=False, color=color, pos=pos, parent=parent, size=size, body=body)
49 |
50 | self.border_color = Color.parse(border_color)
51 | self.border_index = BORDERS.index(border_type)
52 | self.border_type = BORDERS[self.border_index]
53 | self._body = body
54 |
55 | self.init_parts()
56 |
57 | def init_parts(self) -> None:
58 | self.body = Body(self, body=self._body, id="default-body")
59 | self.resizer = Resizer(body=" ", id=f"{self.id}-resizer", parent=self)
60 | self.resizer_left = ResizerBoxLeft(body="", id="box-resizer-left", parent=self)
61 |
62 | def drawable_body(self):
63 | yield Vertical(
64 | self.body,
65 | )
66 | yield HorizontalResizer(self.resizer_left, self.resizer, id=f"box-resizer")
67 |
68 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None:
69 | if isinstance(new_color, str):
70 | base_color = Color.parse(new_color)
71 | else:
72 | base_color = new_color
73 |
74 | if part_type == "" or part_type == "body":
75 | self.color = base_color
76 | else:
77 | self.border_color = base_color
78 | self.update_layout(duration)
79 |
80 | def update_layout(self, duration: float = 1.0):
81 | base_color = self.color
82 | border_color = self.border_color
83 | if self.is_entered:
84 | base_color = base_color.darken(0.1) if base_color.brightness > 0.9 else base_color.lighten(0.1)
85 | border_color = border_color.darken(0.1) if border_color.brightness > 0.9 else border_color.lighten(0.1)
86 |
87 | # Hack to update color of the text area
88 | self.body.styles.background = base_color
89 | self.resizer.styles.background = base_color
90 | self.resizer_left.styles.background = base_color
91 |
92 | self.body.styles.animate("background", value=base_color, duration=duration)
93 | self.resizer.styles.animate("background", value=base_color, duration=duration)
94 | self.resizer_left.styles.animate("background", value=base_color, duration=duration)
95 |
96 |
97 | self.body.styles.border = (self.border_type, border_color.darken(0.1))
98 | self.body.styles.border_left = (self.border_type, border_color.lighten(0.1))
99 | self.body.styles.border_top = (self.border_type, border_color.lighten(0.1))
100 | self.resizer.styles.animate("background", value=border_color.darken(0.1), duration=duration)
101 |
102 | self.resizer_left.styles.border = ("none", border_color)
103 | self.resizer_left.styles.border_left = (self.border_type, border_color.lighten(0.1))
104 | self.resizer_left.styles.border_bottom = (self.border_type, border_color.darken(0.1))
105 |
106 | # Hack to update color of the text area
107 | self.body.notify_style_update()
108 | self.resizer.notify_style_update()
109 | self.resizer_left.notify_style_update()
110 |
111 | def next_border(self):
112 | self.border_index = (self.border_index + 1) % len(BORDERS)
113 | self.border_type = BORDERS[self.border_index]
114 | self.update_layout(duration=1.0)
115 |
116 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None:
117 | # widgets["multiline_input"].remove_class("-hidden")
118 | widgets["body_color_picker"].remove_class("-hidden")
119 | widgets["border_picker"].remove_class("-hidden")
120 | widgets["border_color_picker"].remove_class("-hidden")
121 | widgets["delete_button"].remove_class("-hidden")
122 |
123 | # widgets["multiline_input"].text = str(self.body.body) # TODO: recreate here
124 | widgets["body_color_picker"].update_colors(self.color)
125 | widgets["border_color_picker"].update_colors(self.border_color)
126 |
127 | def dump(self) -> dict[str, Any]:
128 | return {
129 | "body": self.body.text,
130 | "pos": (self.styles.offset.x.value, self.styles.offset.y.value),
131 | "color": self.color.hex6,
132 | "border_color": self.border_color.hex6,
133 | "border_type": self.border_type,
134 | "size": (self.styles.width.value, self.styles.height.value),
135 | "type": self.type,
136 | }
137 |
138 | @classmethod
139 | def load(cls: Type[_T], obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)):
140 | return cls(
141 | id=drawable_id,
142 | body=obj["body"],
143 | color=obj["color"],
144 | pos=Offset(*obj["pos"]) - offset,
145 | size=Size(*obj["size"]),
146 | border_color=obj["border_color"],
147 | border_type=obj["border_type"],
148 | )
149 |
150 |
151 | class ResizerBoxLeft(DrawablePartStatic):
152 | async def on_mouse_move(self, event: events.MouseMove) -> None:
153 | await self.pparent.drawable_is_moved(event)
154 |
155 |
156 | class HorizontalResizer(Horizontal):
157 | def __init__(
158 | self,
159 | *children: Widget,
160 | id: str | None = None,
161 | classes: str | None = None,
162 | ) -> None:
163 | super().__init__(*children, id=id, classes=classes)
164 | self.styles.layer = f"{id}"
165 |
--------------------------------------------------------------------------------
/notesh/play_area.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Optional, OrderedDict, cast
4 | from textual.app import ComposeResult
5 | from textual.color import Color
6 |
7 | from textual.containers import Container
8 | from textual.events import Click, MouseDown, MouseMove, MouseUp
9 | from textual.geometry import Offset, Size
10 | from textual.message import Message
11 | from textual.reactive import reactive
12 | from textual.widget import Widget
13 |
14 | from notesh.drawables.box import Box
15 | from notesh.drawables.drawable import Drawable
16 | from notesh.drawables.sticknote import Note
17 | from notesh.utils import calculate_size_for_file, load_drawables
18 |
19 | CHUNK_SIZE = Offset(20, 5)
20 |
21 |
22 | class PlayArea(Container):
23 | can_focus: bool = True
24 | drawables: list[Drawable] = []
25 | is_draggin = False
26 | focused_drawable: Optional[Drawable] = None
27 | background_type: reactive[str] = reactive("plain")
28 |
29 | def __init__(
30 | self,
31 | *children: Widget,
32 | file: str = "test",
33 | name: str | None = None,
34 | id: str | None = None,
35 | classes: str | None = None,
36 | screen_size: Size = Size(100, 100),
37 | color: str = "#444444",
38 | border_color: str = "#ffaa00",
39 | ) -> None:
40 | super().__init__(*children, name=name, id=id, classes=classes)
41 | self.min_size, self.max_size = calculate_size_for_file(file)
42 | calculated_width, calculated_height = self._calculate_size(self.min_size, self.max_size)
43 | self.file = file
44 | self.styles.width, self.styles.height = calculated_width, calculated_height
45 | self.offset += self._calculate_additional_offset(screen_size, Size(calculated_width, calculated_height))
46 | self.color = Color.parse(color)
47 | self.border_color = Color.parse(border_color)
48 |
49 | def on_mount(self):
50 | self.clear_drawables()
51 | drawables, background = load_drawables(self.file)
52 | for name, drawable_obj in drawables:
53 | self.add_parsed_drawable(drawable_obj, name, Offset(self.min_size.width, self.min_size.height))
54 | self.load(background)
55 |
56 | def compose(self) -> ComposeResult:
57 | self.change_color(self.color, duration=0.0)
58 | yield from ()
59 |
60 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None:
61 | if isinstance(new_color, str):
62 | base_color = Color.parse(new_color)
63 | else:
64 | base_color = new_color
65 |
66 | if part_type == "" or part_type == "body":
67 | self.color = base_color
68 | else:
69 | self.border_color = base_color
70 | self.update_layout(duration)
71 |
72 | def update_layout(self, duration: float = 1.0):
73 | base_color = self.color
74 | border_color = self.border_color
75 |
76 | self.styles.animate("background", value=base_color, duration=duration)
77 | self.styles.border = ("outer", border_color)
78 |
79 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None:
80 | widgets["body_color_picker"].remove_class("-hidden")
81 | widgets["border_color_picker"].remove_class("-hidden")
82 |
83 | widgets["body_color_picker"].update_colors(self.color)
84 | widgets["border_color_picker"].update_colors(self.border_color)
85 |
86 | def add_new_drawable(self, drawable_type: str) -> Drawable:
87 | d = {"note": Note, "box": Box}
88 | drawable = cast(Drawable, d.get(drawable_type, Drawable)())
89 | self._mount_drawable(drawable)
90 |
91 | return drawable
92 |
93 | def add_parsed_drawable(self, obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)) -> None:
94 | drawable_type = obj["type"]
95 | d = {"note": Note, "box": Box}
96 | drawable = cast(Drawable, d.get(drawable_type, Drawable).load(obj, drawable_id, offset))
97 | self._mount_drawable(drawable)
98 |
99 | def clear_drawables(self) -> None:
100 | while self.drawables:
101 | self.drawables.pop().remove()
102 |
103 | def delete_drawable(self, drawable: Optional[Drawable] = None) -> None:
104 | if drawable is None:
105 | drawable = self.focused_drawable
106 | if drawable is None:
107 | return
108 |
109 | self.drawables = [note for note in self.drawables if note != drawable]
110 | drawable.remove()
111 | self.focused_drawable = None
112 | if len(self.drawables) == 0:
113 | self.can_focus = True
114 |
115 | async def on_mouse_move(self, event: MouseMove) -> None:
116 | if event.ctrl and self.is_draggin:
117 | await self._move_play_area(event.delta)
118 |
119 | async def on_mouse_down(self, event: MouseDown) -> None:
120 | if event.ctrl:
121 | self.is_draggin = True
122 | self.capture_mouse()
123 |
124 | async def on_mouse_up(self, _: MouseUp) -> None:
125 | self.is_draggin = False
126 | self.capture_mouse(False)
127 |
128 | async def on_click(self, event: Click) -> None:
129 | self.post_message(PlayArea.Clicked())
130 |
131 | async def on_drawable_move(self, event: Drawable.Move) -> None:
132 | await self._resize_field_to_drawable(event.drawable, event.offset)
133 |
134 | async def on_drawable_focus(self, message: Drawable.Focus) -> None:
135 | drawable = self.screen.get_widget_by_id(message.index)
136 | self.focused_drawable = cast(Drawable, drawable)
137 |
138 | async def move_drawable(self, direction: str, value: int) -> None:
139 | if self.focused_drawable is not None:
140 | await self.focused_drawable.move(direction, value)
141 |
142 | def _calculate_additional_offset(self, size_a: Size, size_b: Size):
143 | return Offset((size_a.width - size_b.width) // 2, (size_a.height - size_b.height) // 2)
144 |
145 | def _calculate_size(self, min_size: Size, max_size: Size):
146 | calculated_width = ((max_size.width - min_size.width + 1) // CHUNK_SIZE.x) * CHUNK_SIZE.x + CHUNK_SIZE.x
147 | calculated_height = ((max_size.height - min_size.height + 1) // CHUNK_SIZE.y) * CHUNK_SIZE.y + CHUNK_SIZE.y
148 | return calculated_width, calculated_height
149 |
150 | def _mount_drawable(self, drawable: Drawable) -> None:
151 | self.drawables.append(drawable)
152 | self.mount(drawable)
153 | self.is_draggin = False
154 | self.can_focus = False
155 |
156 | async def _move_play_area(self, offset: Offset) -> None:
157 | self.offset = self.offset + offset
158 |
159 | async def _resize_field_to_drawable(self, drawable: Drawable, offset: Offset = Offset(0, 0)) -> None:
160 | xx, yy = offset.x, offset.y
161 | if drawable.region.right + xx >= self.region.right:
162 | self.styles.width = self.styles.width.value + CHUNK_SIZE.x
163 |
164 | if drawable.region.bottom + yy >= self.region.bottom:
165 | self.styles.height = self.styles.height.value + CHUNK_SIZE.y
166 |
167 | if drawable.region.x + xx <= self.region.x:
168 | self.styles.width = self.styles.width.value + CHUNK_SIZE.x
169 | self.styles.offset = (self.styles.offset.x.value - CHUNK_SIZE.x, self.styles.offset.y.value)
170 | for child in self.children:
171 | child.styles.offset = (child.styles.offset.x.value + CHUNK_SIZE.x, child.styles.offset.y.value)
172 |
173 | if drawable.region.y + yy <= self.region.y:
174 | self.styles.height = self.styles.height.value + CHUNK_SIZE.y
175 | self.styles.offset = (self.styles.offset.x.value, self.styles.offset.y.value - CHUNK_SIZE.y)
176 | for child in self.children:
177 | child.styles.offset = (child.styles.offset.x.value, child.styles.offset.y.value + CHUNK_SIZE.y)
178 |
179 | def dump(self) -> dict[str, Any]:
180 | return {
181 | "color": self.color.hex6,
182 | "border_color": self.border_color.hex6,
183 | "type": self.background_type,
184 | }
185 |
186 | def load(self, obj: Optional[dict[Any, Any]]):
187 | if obj is None:
188 | return
189 | self.color = Color.parse(obj["color"])
190 | self.border_color = Color.parse(obj["border_color"])
191 | self.background_type = obj["type"]
192 |
193 | class Clicked(Message):
194 | def __init__(
195 | self,
196 | ) -> None:
197 | super().__init__()
198 |
--------------------------------------------------------------------------------
/notesh/main.css:
--------------------------------------------------------------------------------
1 | Screen {
2 | layers: log note_disactive footer sidebar topper;
3 | background: $background-darken-1;
4 | overflow-x: hidden;
5 | overflow-y: hidden;
6 | }
7 |
8 | PlayArea {
9 | opacity: 100%;
10 | background: $surface;
11 | layer: note_disactive;
12 | border: outer $secondary;
13 | overflow-x: hidden;
14 | overflow-y: hidden;
15 | layout: grid;
16 | }
17 |
18 | Footer {
19 | layer: footer;
20 | }
21 |
22 | Note {
23 | layer: note_disactive;
24 | border: none;
25 | text-style: none;
26 | padding: 0 0 0 0;
27 | margin: 0;
28 | height: 14;
29 | min-height: 6;
30 | width: 20;
31 | overflow: hidden;
32 | opacity: 100%;
33 | }
34 |
35 | Note TextArea {
36 | background: $secondary-darken-1;
37 | color: $text;
38 | border: none;
39 | width: 100%;
40 | height: 100%;
41 | overflow: hidden;
42 | text-style: none;
43 |
44 | border: none;
45 | border-right: outer $secondary-darken-3;
46 | border-left: outer $secondary-lighten-1;
47 | border-top: none;
48 | /* border-bottom: outer $secondary-darken-3; */
49 |
50 | padding: 0;
51 | margin: 0;
52 |
53 | &.-textual-compact {
54 | }
55 | & .text-area--cursor {
56 | }
57 | & .text-area--gutter {
58 | }
59 |
60 | & .text-area--cursor-gutter {
61 | }
62 |
63 | & .text-area--cursor-line {
64 | background: $boost;
65 | }
66 |
67 | & .text-area--selection {
68 | }
69 |
70 | & .text-area--matching-bracket {
71 | }
72 | }
73 |
74 | #note-toper {
75 | dock: top;
76 | height: 4;
77 |
78 | padding: 0;
79 | margin: 0;
80 | }
81 |
82 | #note-top {
83 | text-align: center;
84 | background: $secondary;
85 | border-top: outer $secondary-lighten-1;
86 | border-left: outer $secondary-lighten-1;
87 | border-bottom: none;
88 | height: 3;
89 |
90 | padding: 0;
91 | margin: 0;
92 | }
93 |
94 | #note-spacer {
95 | background: $secondary-darken-3;
96 | color: $secondary-lighten-1;
97 | border: none;
98 | height: 1;
99 | min-height: 1;
100 |
101 | padding: 0;
102 | margin: 0;
103 | }
104 |
105 |
106 | #note-resizer-bar {
107 | dock: bottom;
108 | width: 100%;
109 | height: 1;
110 | border: none;
111 |
112 | padding: 0;
113 | margin: 0;
114 | }
115 |
116 |
117 | #note-resizer-left {
118 | dock: left;
119 |
120 | background: $secondary-darken-3;
121 | color: $secondary-lighten-1;
122 | border: none;
123 | height: 1;
124 | min-height: 1;
125 | width: 1fr;
126 |
127 | padding: 0;
128 | margin: 0;
129 | }
130 |
131 | #note-resizer {
132 | dock: right;
133 | background: $secondary-darken-3;
134 | color: $secondary-lighten-1;
135 | border: none;
136 | height: 1;
137 | min-height: 1;
138 | width: 2;
139 | min-width: 2;
140 | max-width: 2;
141 |
142 | padding: 0;
143 | margin: 0;
144 | }
145 |
146 | #box-resizer {
147 | dock: bottom;
148 | width: 100%;
149 | height: 1;
150 | border: none;
151 |
152 | padding: 0;
153 | margin: 0;
154 |
155 | }
156 |
157 | Box Resizer{
158 | dock: right;
159 | border: none;
160 | height: 1;
161 | min-height: 1;
162 | width: 2;
163 | min-width: 2;
164 | max-width: 2;
165 |
166 | padding: 0;
167 | margin: 0;
168 |
169 | }
170 |
171 | Box ResizerBoxLeft {
172 | height: 2;
173 | min-height: 2;
174 | max-height: 2;
175 | width: 100%;
176 | min-width: 100%;
177 | max-width: 100%;
178 |
179 | padding: 0;
180 | margin: -1 0;
181 | }
182 |
183 | Drawable Resizer:hover{
184 | opacity: 80%;
185 | }
186 |
187 |
188 | Sidebar {
189 | layer: sidebar;
190 | dock: right;
191 | width: 32;
192 | height: 100%;
193 | padding: 0 0 0 0;
194 | border: outer $accent-darken-3;
195 | transition: offset 500ms in_out_cubic;
196 | background: $panel;
197 | }
198 |
199 | Sidebar:focus-within {
200 | offset: 0 0 !important;
201 | }
202 |
203 | Sidebar.-hidden {
204 | offset-x: 100%;
205 | }
206 |
207 | #sidebar-title {
208 | border: heavy $secondary;
209 | padding: 0 2;
210 | text-style: bold;
211 | }
212 |
213 | /* Drawable TextArea { */
214 | /* background: orange; */
215 | /* border: outer blue; */
216 | /* } */
217 |
218 | Sidebar TextArea {
219 | background: $boost;
220 | color: $text;
221 | border: heavy $secondary-darken-3;
222 | height: auto;
223 | min-height: 1;
224 | width: 100%;
225 | }
226 |
227 | Sidebar TextArea.-hidden {
228 | min-height: 0;
229 | min-width: 0;
230 | height: 0;
231 | width: 0;
232 | border: none;
233 | visibility: hidden;
234 | dock: bottom;
235 | }
236 |
237 | Sidebar Button {
238 | width: 100%;
239 | height: 3;
240 | margin: 0 1;
241 | }
242 |
243 | Sidebar ColorPicker.-hidden {
244 | min-height: 0;
245 | min-width: 0;
246 | height: 0;
247 | width: 0;
248 | border: none;
249 | visibility: hidden;
250 | dock: bottom;
251 | }
252 |
253 | Sidebar Input.-hidden {
254 | min-height: 0;
255 | min-width: 0;
256 | height: 0;
257 |
258 | width: 0;
259 | border: none;
260 | padding: 0;
261 | visibility: hidden;
262 | dock: bottom;
263 | }
264 |
265 | Sidebar Button.-hidden {
266 | min-height: 0;
267 | min-width: 0;
268 | height: 0;
269 | width: 0;
270 | border: none;
271 | visibility: hidden;
272 | dock: bottom;
273 | }
274 |
275 | ColorPicker {
276 | align-horizontal: center;
277 | background: $panel;
278 | width: 100%;
279 | height: auto;
280 | margin: 0 0 0 0;
281 | }
282 |
283 | #color-picker-title {
284 | border: heavy $primary-lighten-3;
285 | padding: 0 2;
286 | text-style: bold;
287 | text-align: center;
288 |
289 | }
290 |
291 | ColorPickerDisplay {
292 | background: $panel;
293 | /* border: outer $panel-darken-3; */
294 | border-bottom: outer $panel-darken-3;
295 | content-align: center middle;
296 | padding: 0;
297 | margin: 0 1 0 1;
298 | color: $text;
299 | text-style: bold;
300 | height: 2;
301 | width: 100%;
302 | }
303 |
304 | ColorPickerChanger {
305 | grid-size: 3;
306 | height: 2;
307 | width: 8;
308 | }
309 |
310 | #color-changers {
311 | padding: 0;
312 | margin: 0 0 1 1;
313 | height: 2;
314 | width: auto;
315 | }
316 |
317 | #random-color {
318 | min-width: 1;
319 | min-height: 1;
320 | width: 4;
321 | height: 3;
322 | padding: 0;
323 | margin: 0;
324 | background: black;
325 | color: white;
326 | border: none;
327 | align-horizontal: center;
328 | content-align: center top;
329 | }
330 |
331 | ColorPickerChanger Static {
332 | background: $primary;
333 | row-span: 3;
334 | column-span: 2;
335 | min-width: 1;
336 | min-height: 1;
337 | height: 2;
338 | width: 6;
339 | color: $text;
340 | border: none;
341 | padding: 0;
342 | margin: 0;
343 | content-align: center top;
344 | text-style: bold;
345 | text-align: center;
346 | align-horizontal: center;
347 | }
348 |
349 | ColorPickerChanger Button {
350 | border: none;
351 | background: $accent;
352 | padding: 0 0 0 0;
353 | margin: 0 0 0 0;
354 | }
355 |
356 | ColorPickerChanger Button.-up {
357 | background: $accent-lighten-1;
358 | }
359 |
360 | ColorPickerChanger Button.-down {
361 | background: $accent-darken-3;
362 | }
363 |
364 | ColorPickerChanger Button:focus.-up {
365 | background: $accent-lighten-3;
366 | }
367 |
368 | ColorPickerChanger Button:focus.-down {
369 | background: $accent-darken-1;
370 | }
371 |
372 | Sidebar #border-picker {
373 | margin: 0 1 1 1;
374 | }
375 |
376 | Drawable {
377 | background: $error;
378 | min-width: 4;
379 | min-height: 3;
380 | color: $text;
381 | /* border: outer $primary; */
382 | /* border-bottom: outer $primary; */
383 | overflow-x: hidden;
384 | overflow-y: hidden;
385 | }
386 |
387 | Drawable #default-body {
388 | color: $text;
389 | background: $panel;
390 | min-width: 4;
391 | min-height: 1;
392 | border: outer $primary;
393 | height: 100%;
394 | overflow-x: hidden;
395 | overflow-y: hidden;
396 | }
397 |
398 | SidebarLeft {
399 | layer: sidebar;
400 | dock: left;
401 | width: 32;
402 | height: 100%;
403 | padding: 0 0 0 0;
404 | border: outer $accent-darken-3;
405 | transition: offset 500ms in_out_cubic;
406 | background: $panel;
407 | }
408 | SidebarLeft:focus-within {
409 | offset: 0 0 !important;
410 | }
411 |
412 | SidebarLeft.-hidden {
413 | offset-x: -100%;
414 | }
415 |
416 | HopScreen {
417 | background: 0%;
418 | }
419 |
--------------------------------------------------------------------------------
/notesh/main.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | from pathlib import Path
5 | from typing import Optional
6 |
7 | from textual.app import App, ComposeResult
8 | from textual.binding import Binding
9 | from textual.geometry import Offset, Size
10 | from textual.keys import KEY_ALIASES
11 | from textual.widget import Widget
12 | from textual._context import active_app
13 |
14 | from notesh.drawables.drawable import Drawable
15 | from notesh.play_area import PlayArea
16 | from notesh.utils import load_binding_config_file, load_drawables, save_drawables, set_bindings
17 | from notesh.widgets.focusable_footer import FocusableFooter
18 | from notesh.widgets.sidebar import DeleteDrawable, Sidebar
19 | from notesh.widgets.sidebar_left import SidebarLeft
20 | from hoptex.configs import HoptexBindingConfig
21 | from hoptex.decorator import hoptex
22 |
23 | KEY_ALIASES["backspace"] = ["ctrl+h"]
24 |
25 |
26 | def load_confing_hoptex():
27 | conf = load_binding_config_file(str(Path(__file__).parent / "default_bindings.toml"))
28 | conf.update(load_binding_config_file(str(Path(__file__).parent / "user_bindings.toml")))
29 | return conf.get("hoptex", {})
30 |
31 |
32 | # Not perfect solution to load file here,
33 | # but cant load confing for hoptex other way
34 | hoptex_conf = load_confing_hoptex()
35 |
36 | hoptex_binding = HoptexBindingConfig(
37 | focus=hoptex_conf.get("hoptex_focus", "ctrl+n"),
38 | quit=hoptex_conf.get("hoptex_quit", "escape,ctrl+c"),
39 | unfocus="",
40 | )
41 |
42 |
43 | @hoptex(bindings=hoptex_binding)
44 | class NoteApp(App[None]):
45 | CSS_PATH = "main.css"
46 |
47 | BINDINGS = [
48 | Binding("ctrl+c", "quit", "Quit"),
49 | ]
50 |
51 | DEFAULT_FILE = os.environ.get(
52 | "NOTESH_FILE",
53 | str(
54 | (
55 | Path(os.getenv("APPDATA", Path.home()))
56 | if os.name == "nt"
57 | else Path(os.getenv("XDG_DATA_HOME", Path("~/.local/share").expanduser()))
58 | )
59 | / "notesh"
60 | / "notes.json"
61 | ),
62 | )
63 |
64 | def __init__(
65 | self,
66 | watch_css: bool = False,
67 | file: str = DEFAULT_FILE,
68 | ):
69 | super().__init__(watch_css=watch_css)
70 | active_app.set(self)
71 | self.file = file
72 | self.footer = FocusableFooter()
73 | self.sidebar_left = SidebarLeft(classes="-hidden")
74 | self.sidebar = Sidebar(classes="-hidden")
75 |
76 | def compose(self) -> ComposeResult:
77 | self.play_area = PlayArea(file=self.file, screen_size=self.size)
78 | self.sidebar_left.set_play_area(self.play_area)
79 |
80 | self._load_key_bindings()
81 |
82 | yield self.sidebar
83 | yield self.sidebar_left
84 | yield self.play_area
85 | yield self.footer
86 | self._hoptex_parent_widgets: set[Widget] = {self.play_area}
87 |
88 | self.set_focus(self.footer)
89 |
90 | async def action_delete(self):
91 | await self._delete_drawable()
92 |
93 | async def _edit_drawable(self):
94 | if self.play_area.focused_drawable is None:
95 | return
96 | self._hoptex_parent_widgets.add(self.sidebar)
97 | await self.sidebar.set_drawable(self.play_area.focused_drawable, True)
98 | self.set_focus(self.sidebar.get_child())
99 | self.play_area.can_focus_children = False
100 |
101 | async def action_edit(self):
102 | await self._edit_drawable()
103 |
104 | async def on_drawable_clicked(self, event: Drawable.Clicked):
105 | self.play_area.focused_drawable = event.drawable
106 | await self._edit_drawable()
107 |
108 | async def _move_drawable(self, direction: str) -> None:
109 | value = 1
110 | if "_" in direction:
111 | direction_parsed = direction.split("_")
112 | direction, value = direction_parsed[0], int(direction_parsed[1])
113 | await self.play_area.move_drawable(direction, value)
114 |
115 | async def _bring(self, direction: str) -> None:
116 | if self.play_area.focused_drawable is not None:
117 | getattr(self.play_area.focused_drawable, f"bring_{direction}")()
118 |
119 | async def _resize(self, direction: str) -> None:
120 | d = {"h_plus": (1, 0), "h_minus": (-1, 0), "v_plus": (0, 1), "v_minus": (0, -1)}
121 | if self.play_area.focused_drawable is not None:
122 | await self.play_area.focused_drawable.resize_drawable(*d[direction])
123 |
124 | def _unfocus(self, fully: bool = False):
125 | self.play_area.can_focus_children = True
126 | self.sidebar.set_focus(False)
127 | self.sidebar_left.set_focus(False)
128 | self._hoptex_parent_widgets.discard(self.sidebar)
129 | self._hoptex_parent_widgets.discard(self.sidebar_left)
130 | # Already Unfocused
131 | if self.focused is None:
132 | return
133 | # Unfocuss Fully (forced) or from view with selected one drawable
134 | if self.focused is self.play_area.focused_drawable or fully:
135 | self.set_focus(self.footer)
136 | self.play_area.focused_drawable = None
137 | return
138 | self.set_focus(self.play_area.focused_drawable)
139 | self.play_area.focused_drawable = None
140 |
141 | async def _delete_drawable(self, drawable: Optional[Drawable] = None):
142 | self.play_area.delete_drawable(drawable)
143 | await self.sidebar.set_drawable(None)
144 | if self.play_area.can_focus:
145 | self.set_focus(self.play_area)
146 | else:
147 | self._unfocus()
148 |
149 | async def action_unfocus(self):
150 | self._unfocus()
151 |
152 | def action_add_note(self) -> None:
153 | new_drawable = self.play_area.add_new_drawable("note")
154 | self._add_new_drawable(new_drawable)
155 |
156 | def action_add_drawable(self) -> None:
157 | new_drawable = self.play_area.add_new_drawable("drawable")
158 | self._add_new_drawable(new_drawable)
159 |
160 | def action_add_box(self) -> None:
161 | new_drawable = self.play_area.add_new_drawable("box")
162 | self._add_new_drawable(new_drawable)
163 |
164 | def action_toggle_sidebar(self) -> None:
165 | if self.sidebar.toggle_focus():
166 | self._hoptex_parent_widgets.add(self.sidebar)
167 | else:
168 | self._hoptex_parent_widgets.discard(self.sidebar)
169 |
170 | def action_toggle_sidebar_left(self) -> None:
171 | if not self.sidebar_left.toggle_focus():
172 | self._hoptex_parent_widgets.discard(self.sidebar_left)
173 | focus_candidat = self.play_area.focused_drawable
174 | self.play_area.can_focus_children = True
175 | if focus_candidat is not None:
176 | self.set_focus(focus_candidat)
177 | else:
178 | self._hoptex_parent_widgets.add(self.sidebar_left)
179 | self.play_area.can_focus_children = False
180 | self.set_focus(self.sidebar_left.children[0])
181 |
182 | def action_save_notes(self) -> None:
183 | save_drawables(self.file, self.play_area.drawables, list(self.screen.layers), self.play_area.dump())
184 |
185 | def action_load_notes(self, min_size: Size = Size(0, 0)) -> None:
186 | self.play_area.clear_drawables()
187 | drawables, background = load_drawables(self.file)
188 | for name, drawable_obj in drawables:
189 | self.play_area.add_parsed_drawable(drawable_obj, name, Offset(min_size.width, min_size.height))
190 | self.play_area.load(background)
191 | self.refresh()
192 |
193 | async def action_quit(self) -> None:
194 | self.action_save_notes()
195 | self.exit() # type: ignore
196 |
197 | async def on_play_area_clicked(self, message: PlayArea.Clicked):
198 | self._unfocus(fully=True)
199 |
200 | async def on_drawable_focus(self, message: Drawable.Focus):
201 | drawable = self.screen.get_widget_by_id(message.index)
202 | if isinstance(drawable, Drawable):
203 | await self.sidebar.set_drawable(drawable, message.display_sidebar)
204 |
205 | async def on_delete_drawable(self, message: DeleteDrawable) -> None:
206 | await self._delete_drawable(message.drawable)
207 |
208 | def _add_new_drawable(self, new_drawable: Drawable) -> None:
209 | self.set_focus(new_drawable)
210 | self.play_area.focused_drawable = new_drawable
211 | self.play_area.can_focus_children = True
212 |
213 | def _load_key_bindings(self):
214 | conf = load_binding_config_file(str(Path(__file__).parent / "default_bindings.toml"))
215 | conf.update(load_binding_config_file(str(Path(__file__).parent / "user_bindings.toml")))
216 |
217 | set_bindings(self, conf["default"], show=True)
218 |
219 | set_bindings(self, conf["moving_drawables"], func=self._move_drawable)
220 | set_bindings(self, conf["bring_drawable"], func=self._bring)
221 | set_bindings(self, conf["resize_drawable"], func=self._resize)
222 |
223 | set_bindings(self, conf["normal_insert"])
224 | set_bindings(self.play_area, conf["normal"])
225 |
226 |
227 | if __name__ == "__main__":
228 | app = NoteApp(watch_css=True)
229 | app.run() # type: ignore
230 |
--------------------------------------------------------------------------------
/notesh/drawables/drawable.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Literal, Optional, OrderedDict, Type, TypeVar, cast
4 |
5 | from rich.console import RenderableType
6 | from rich.markdown import Markdown
7 | from textual import events
8 | from textual.app import ComposeResult
9 | from textual.color import Color
10 | from textual.containers import Vertical, Horizontal
11 | from textual.geometry import Offset, Size
12 | from textual.message import Message
13 | from textual.reactive import reactive
14 | from textual.widget import Widget
15 | from textual.widgets import Input, Static, TextArea
16 |
17 | from notesh.utils import generate_short_uuid
18 |
19 | _T = TypeVar("_T")
20 |
21 |
22 | class Drawable(Static):
23 | can_focus: bool = True
24 | type: str = "drawable"
25 | is_entered: reactive[bool] = reactive(False)
26 |
27 | def __init__(
28 | self,
29 | id: str | None = None,
30 | body: str = "",
31 | color: str = "#ffaa00",
32 | pos: Offset = Offset(0, 0),
33 | size: Size = Size(20, 14),
34 | parent: Optional[Widget] = None,
35 | init_parts: bool = True,
36 | ) -> None:
37 | if id is None or id == "":
38 | id = f"note-{generate_short_uuid()}"
39 | super().__init__(id=id)
40 | self.note_id: str = id
41 | self.clicked = (0, 0)
42 | self.styles.layer = f"{id}"
43 | self.color = Color.parse(color)
44 | self.styles.offset = pos
45 | self.styles.width = size.width
46 | self.styles.height = size.height
47 | self.pparent = parent
48 |
49 | if init_parts:
50 | self.init_parts()
51 |
52 | def init_parts(self) -> None:
53 | self.body = Body(self, body="", id="default-body")
54 | self.resizer = Resizer(body=" ", id=f"{self.id}-resizer", parent=self)
55 |
56 | def drawable_body(self) -> ComposeResult:
57 | yield Vertical(self.body, self.resizer)
58 |
59 | def compose(self) -> ComposeResult:
60 | yield from self.drawable_body()
61 |
62 | self.change_color(self.color, duration=0.0)
63 | self.bring_forward()
64 |
65 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None:
66 | if isinstance(new_color, str):
67 | base_color = Color.parse(new_color)
68 | else:
69 | base_color = new_color
70 |
71 | if part_type == "" or part_type == "body":
72 | self.color = base_color
73 | self.update_layout(duration)
74 |
75 | def update_layout(self, duration: float = 1.0):
76 | base_color = self.color
77 | if self.is_entered:
78 | base_color = base_color.darken(0.1) if base_color.brightness > 0.9 else base_color.lighten(0.1)
79 |
80 | self.body.styles.animate("background", value=base_color, duration=duration)
81 |
82 | self.body.styles.border = ("outer", base_color.darken(0.1))
83 | self.body.styles.border_left = ("outer", base_color.lighten(0.1))
84 | self.body.styles.border_top = ("outer", base_color.lighten(0.1))
85 | self.resizer.styles.animate("background", value=base_color.darken(0.1), duration=duration)
86 |
87 | async def drawable_is_moved_from_key(self, offset: Offset):
88 | note = self
89 | note.offset = note.offset + offset
90 | self.post_message(Drawable.Move(drawable=self, offset=offset))
91 |
92 | async def move(self, direction: str, value: int = 1):
93 | d = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0)}
94 | offset = Offset(*d[direction]) * value
95 | await self.drawable_is_moved_from_key(offset)
96 |
97 | async def drawable_is_moved(self, event: events.MouseMove):
98 | if self.clicked is not None and event.button != 0:
99 | note = self
100 | if event.delta:
101 | note.offset = note.offset + event.delta
102 | self.post_message(Drawable.Move(drawable=self, offset=event.delta))
103 |
104 | async def drawable_is_focused(self, event: events.MouseEvent, display_sidebar: bool = False):
105 | self.clicked = event.offset
106 | self.bring_forward()
107 | self.post_message(Drawable.Focus(self.note_id, display_sidebar))
108 |
109 | async def drawable_is_unfocused(self, event: events.MouseUp) -> None:
110 | self.clicked = None
111 |
112 | async def resize_drawable(self, delta_x: int, delta_y: int):
113 | note = self
114 | note.styles.width = note.styles.width.value + delta_x
115 | note.styles.height = note.styles.height.value + delta_y
116 | note.refresh()
117 |
118 | async def drawable_is_resized(self, event: events.MouseMove) -> None:
119 | if self.clicked is not None and event.button != 0:
120 | await self.resize_drawable(event.delta_x, event.delta_y)
121 |
122 | async def on_mouse_move(self, event: events.MouseMove) -> None:
123 | ...
124 | # await self.drawable_is_moved(event)
125 |
126 | async def on_mouse_down(self, event: events.MouseDown) -> None:
127 | if self.app.mouse_captured is None:
128 | self.capture_mouse()
129 | await self.drawable_is_focused(event)
130 |
131 | async def on_mouse_up(self, event: events.MouseUp) -> None:
132 | if self.app.mouse_captured is None:
133 | self.capture_mouse(False)
134 | await self.drawable_is_unfocused(event)
135 |
136 | async def on_click(self, event: events.Click):
137 | self.post_message(Drawable.Clicked(drawable=self))
138 | event.stop()
139 |
140 | async def on_focus(self, event: events.Focus):
141 | await self.on_enter(cast(events.Enter, event))
142 | self.post_message(Drawable.Focus(self.note_id, False))
143 |
144 | async def on_blur(self, event: events.Blur):
145 | await self.on_leave(cast(events.Leave, event))
146 |
147 | async def on_enter(self, event: events.Enter):
148 | self.is_entered = True
149 |
150 | async def on_leave(self, event: events.Leave):
151 | self.is_entered = False
152 |
153 | async def watch_is_entered(self, new_value: bool) -> None:
154 | if new_value:
155 | self.update_layout(duration=0.3)
156 | else:
157 | self.update_layout(duration=0.1)
158 |
159 | def next_border(self): ...
160 |
161 | def bring_forward(self):
162 | layers = tuple(x for x in self.screen.styles.layers if x not in [self.layer, f"{self.layer}-resizer"])
163 | self.screen.styles.layers = layers + (self.styles.layer, f"{self.styles.layer}-resizer")
164 |
165 | def bring_backward(self):
166 | layers = tuple(x for x in self.screen.styles.layers if x not in [self.layer, f"{self.layer}-resizer"])
167 | self.screen.styles.layers = (f"{self.styles.layer}", f"{self.styles.layer}-resizer") + layers
168 |
169 | def input_changed(self, event: Input.Changed): ...
170 |
171 | def multiline_array_changed(self, event: TextArea.Changed): ...
172 |
173 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None:
174 | widgets["body_color_picker"].remove_class("-hidden")
175 | widgets["delete_button"].remove_class("-hidden")
176 |
177 | widgets["body_color_picker"].update_colors(self.color)
178 |
179 | def dump(self) -> dict[str, Any]:
180 | return {
181 | "body": self.body.text,
182 | "pos": (self.styles.offset.x.value, self.styles.offset.y.value),
183 | "color": self.color.hex6,
184 | "size": (self.styles.width.value, self.styles.height.value),
185 | "type": self.type,
186 | }
187 |
188 | @classmethod
189 | def load(cls: Type[_T], obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)):
190 | return cls(
191 | id=drawable_id,
192 | body=obj["body"],
193 | color=obj["color"],
194 | pos=Offset(*obj["pos"]) - offset,
195 | size=Size(*obj["size"]),
196 | )
197 |
198 | class Mess(Message):
199 | def __init__(self, value: str | None) -> None:
200 | super().__init__()
201 | self.value = value
202 |
203 | class Focus(Message):
204 | def __init__(self, index: str, display_sidebar: bool = False) -> None:
205 | super().__init__()
206 | self.index = index
207 | self.display_sidebar = display_sidebar
208 |
209 | class Move(Message):
210 | def __init__(
211 | self,
212 | drawable: Drawable,
213 | offset: Offset,
214 | ) -> None:
215 | super().__init__()
216 | self.drawable = drawable
217 | # this offset is used because when we update drawable offset
218 | # it does not update region and style until first idle
219 | self.offset = offset
220 |
221 | class Clicked(Message):
222 | def __init__(
223 | self,
224 | drawable: Drawable,
225 | ) -> None:
226 | super().__init__()
227 | self.drawable = drawable
228 |
229 |
230 | class DrawablePartStatic(Static):
231 | body: reactive[str] = reactive("")
232 |
233 | def __init__(
234 | self,
235 | parent: Drawable,
236 | name: str | None = None,
237 | id: str | None = None,
238 | classes: str | None = None,
239 | body: str = "",
240 | ) -> None:
241 | super().__init__(body, name=name, id=id, classes=classes)
242 | self.clicked = Offset(0, 0)
243 | self.pparent: Drawable = parent
244 | self.body = str(body)
245 |
246 | def watch_body(self, body_text: str):
247 | self.text = body_text
248 |
249 | async def on_mouse_down(self, event: events.MouseDown):
250 | self.capture_mouse()
251 | await self.pparent.drawable_is_focused(event)
252 |
253 | async def on_mouse_up(self, event: events.MouseUp):
254 | self.capture_mouse(False)
255 | await self.pparent.drawable_is_unfocused(event)
256 |
257 | async def on_mouse_move(self, event: events.MouseMove) -> None: ...
258 |
259 | async def on_enter(self, event: events.Enter):
260 | await self.pparent.on_enter(event)
261 |
262 | async def on_leave(self, event: events.Leave):
263 | await self.pparent.on_leave(event)
264 |
265 |
266 | class DrawablePart(TextArea):
267 | body: reactive[str] = reactive("")
268 |
269 | def __init__(
270 | self,
271 | parent: Drawable,
272 | name: str | None = None,
273 | id: str | None = None,
274 | classes: str | None = None,
275 | body: str = "",
276 | ) -> None:
277 | language: str | None = None
278 | soft_wrap: bool = True
279 | tab_behavior: Literal["focus", "indent"] = "indent"
280 | read_only: bool = False
281 | show_line_numbers: bool = False
282 | line_number_start: int = 1
283 | max_checkpoints: int = 50
284 | disabled: bool = False
285 | tooltip: RenderableType | None = None
286 | compact: bool = True
287 | highlight_cursor_line: bool = True
288 |
289 | super().__init__(
290 | body,
291 | name=name,
292 | id=id,
293 | classes=classes,
294 | show_cursor=False,
295 | # Taken
296 | language=language,
297 | soft_wrap=soft_wrap,
298 | tab_behavior=tab_behavior,
299 | read_only=read_only,
300 | show_line_numbers=show_line_numbers,
301 | line_number_start=line_number_start,
302 | max_checkpoints=max_checkpoints,
303 | disabled=disabled,
304 | tooltip=tooltip,
305 | compact=compact,
306 | highlight_cursor_line=highlight_cursor_line,
307 | )
308 |
309 | self.clicked = Offset(0, 0)
310 | self.pparent: Drawable = parent
311 | self.body = str(body)
312 |
313 | def watch_body(self, body_text: str):
314 | self.text = body_text
315 |
316 | async def on_mouse_down(self, event: events.MouseDown):
317 | self.capture_mouse()
318 | await self.pparent.drawable_is_focused(event)
319 |
320 | async def on_mouse_up(self, event: events.MouseUp):
321 | self.capture_mouse(False)
322 | await self.pparent.drawable_is_unfocused(event)
323 |
324 | async def on_mouse_move(self, event: events.MouseMove) -> None: ...
325 |
326 | async def on_enter(self, event: events.Enter):
327 | await self.pparent.on_enter(event)
328 | # self.read_only = False
329 |
330 | async def on_leave(self, event: events.Leave):
331 | await self.pparent.on_leave(event)
332 | self.show_cursor = False
333 |
334 | async def on_text_area_changed(self, event: TextArea.Changed):
335 | self.pparent.multiline_array_changed(event)
336 |
337 | def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
338 | """Finalize the selection that has been made using the mouse."""
339 | if not self._has_cursor:
340 | self.scroll_up()
341 | return
342 | target = self.get_cursor_up_location()
343 | self.move_cursor(target, record_width=False)
344 |
345 | def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
346 | """Finalize the selection that has been made using the mouse."""
347 | if not self._has_cursor:
348 | self.scroll_down()
349 | return
350 | target = self.get_cursor_down_location()
351 | self.move_cursor(target, record_width=False)
352 |
353 | def _watch_has_focus(self, focus: bool) -> None:
354 | self._cursor_visible = focus
355 | if focus:
356 | self._restart_blink()
357 | self.app.cursor_position = self.cursor_screen_offset
358 | self.history.checkpoint()
359 | else:
360 | self._pause_blink(visible=False)
361 | self.show_line_numbers = False
362 | self.move_cursor((0,0))
363 |
364 |
365 | class Body(DrawablePart):
366 | async def on_mouse_move(self, event: events.MouseMove) -> None:
367 | ...
368 | await self.pparent.drawable_is_moved(event)
369 |
370 |
371 | class Resizer(DrawablePartStatic):
372 | def __init__(
373 | self,
374 | parent: Drawable,
375 | name: str | None = None,
376 | id: str | None = None,
377 | classes: str | None = None,
378 | body: str = "",
379 | ) -> None:
380 | super().__init__(parent, name=name, id=id, classes=classes, body=body)
381 | self.styles.layer = f"{id}"
382 |
383 | async def on_mouse_move(self, event: events.MouseMove) -> None:
384 | await self.pparent.drawable_is_resized(event)
385 |
--------------------------------------------------------------------------------