├── 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 | ![Hoptex Usage](https://raw.githubusercontent.com/Cvaniak/NoteSH/master/documentation/HoptexNotesh.gif) 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 | ![New note](https://raw.githubusercontent.com/Cvaniak/NoteSH/master/documentation/CreateNote.gif) 54 | 55 | ## 🧅 It supports layers 56 | 57 | * To move note grab it top part and move with mouse 58 | 59 | ![Layers](https://raw.githubusercontent.com/Cvaniak/NoteSH/master/documentation/Layers.gif) 60 | 61 | ## 🗚 You can resize notes 62 | 63 | * To resize grab left bottom corner and move with mouse 64 | 65 | ![Resize Notes](https://raw.githubusercontent.com/Cvaniak/NoteSH/master/documentation/Resizing.gif) 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 | ![Resize Background](https://raw.githubusercontent.com/Cvaniak/NoteSH/master/documentation/DynamicResize.gif) 73 | 74 | ## 💡 Highlight when mouse is over 75 | 76 | ![Resize Background](https://raw.githubusercontent.com/Cvaniak/NoteSH/master/documentation/HoverOver.gif) 77 | 78 | ## ➕ New Drawable that support borders change 79 | 80 | ![Resize Background](https://raw.githubusercontent.com/Cvaniak/NoteSH/master/documentation/NewDrawable.png) 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 | ![New note](https://raw.githubusercontent.com/Cvaniak/NoteSH/master/documentation/ChangeBackgroundColor.gif) 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 | --------------------------------------------------------------------------------