├── .flake8 ├── .gitignore ├── LICENSE ├── Notesh.desktop ├── README.md ├── documentation ├── .gitkeep ├── ChangeBackgroundColor.gif ├── CreateNote.gif ├── DynamicResize.gif ├── HoptexNotesh.gif ├── HoverOver.gif ├── Layers.gif ├── NewDrawable.png ├── NoteshApp.png └── Resizing.gif ├── notesh ├── __init__.py ├── command_line.py ├── default_bindings.toml ├── drawables │ ├── __init__.py │ ├── box.py │ ├── drawable.py │ └── sticknote.py ├── main.css ├── main.py ├── play_area.py ├── utils.py └── widgets │ ├── __init__.py │ ├── color_picker.py │ ├── focusable_footer.py │ ├── multiline_input.py │ ├── sidebar.py │ └── sidebar_left.py ├── pyproject.toml ├── requirements.txt ├── setup.cfg └── setup.py /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__ 3 | notes.json 4 | notes*.json 5 | dist/ 6 | build/ 7 | *.egg-info 8 | 9 | test 10 | -------------------------------------------------------------------------------- /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.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /documentation/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/.gitkeep -------------------------------------------------------------------------------- /documentation/ChangeBackgroundColor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/ChangeBackgroundColor.gif -------------------------------------------------------------------------------- /documentation/CreateNote.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/CreateNote.gif -------------------------------------------------------------------------------- /documentation/DynamicResize.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/DynamicResize.gif -------------------------------------------------------------------------------- /documentation/HoptexNotesh.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/HoptexNotesh.gif -------------------------------------------------------------------------------- /documentation/HoverOver.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/HoverOver.gif -------------------------------------------------------------------------------- /documentation/Layers.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/Layers.gif -------------------------------------------------------------------------------- /documentation/NewDrawable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/NewDrawable.png -------------------------------------------------------------------------------- /documentation/NoteshApp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/NoteshApp.png -------------------------------------------------------------------------------- /documentation/Resizing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/Resizing.gif -------------------------------------------------------------------------------- /notesh/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/notesh/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /notesh/drawables/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/notesh/drawables/__init__.py -------------------------------------------------------------------------------- /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 | 11 | from notesh.drawables.drawable import Body, Drawable, Resizer 12 | from notesh.widgets.multiline_input import MultilineArray 13 | 14 | BORDERS = [ 15 | "outer", 16 | "ascii", 17 | "round", 18 | "solid", 19 | "double", 20 | "dashed", 21 | "heavy", 22 | "hkey", 23 | "vkey", 24 | "none", 25 | ] 26 | 27 | _T = TypeVar("_T") 28 | 29 | 30 | class Box(Drawable): 31 | type: str = "box" 32 | border_index: int = 0 33 | border_type: reactive[str] = reactive(BORDERS[border_index], always_update=True) 34 | 35 | def __init__( 36 | self, 37 | body: str = "", 38 | color: str = "#ffaa00", 39 | pos: Offset = Offset(0, 0), 40 | size: Size = Size(20, 14), 41 | parent: Optional[Widget] = None, 42 | border_color: str = "#ffaa00", 43 | border_type: str = "outer", 44 | id: str | None = None, 45 | ) -> None: 46 | super().__init__(id=id, init_parts=False, color=color, pos=pos, parent=parent, size=size, body=body) 47 | 48 | self.border_color = Color.parse(border_color) 49 | self.border_index = BORDERS.index(border_type) 50 | self.border_type = BORDERS[self.border_index] 51 | self._body = body 52 | 53 | self.init_parts() 54 | 55 | def init_parts(self) -> None: 56 | self.body = Body(self, body=self._body, id="default-body") 57 | self.resizer = Resizer(body=" ", id=f"{self.id}-resizer", parent=self) 58 | 59 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None: 60 | if isinstance(new_color, str): 61 | base_color = Color.parse(new_color) 62 | else: 63 | base_color = new_color 64 | 65 | if part_type == "" or part_type == "body": 66 | self.color = base_color 67 | else: 68 | self.border_color = base_color 69 | self.update_layout(duration) 70 | 71 | def update_layout(self, duration: float = 1.0): 72 | base_color = self.color 73 | border_color = self.border_color 74 | if self.is_entered: 75 | base_color = base_color.darken(0.1) if base_color.brightness > 0.9 else base_color.lighten(0.1) 76 | border_color = border_color.darken(0.1) if border_color.brightness > 0.9 else border_color.lighten(0.1) 77 | 78 | self.body.styles.animate("background", value=base_color, duration=duration) 79 | 80 | self.body.styles.border = (self.border_type, border_color.darken(0.1)) 81 | self.body.styles.border_left = (self.border_type, border_color.lighten(0.1)) 82 | self.body.styles.border_top = (self.border_type, border_color.lighten(0.1)) 83 | self.resizer.styles.animate("background", value=border_color.darken(0.1), duration=duration) 84 | 85 | def next_border(self): 86 | self.border_index = (self.border_index + 1) % len(BORDERS) 87 | self.border_type = BORDERS[self.border_index] 88 | self.update_layout(duration=1.0) 89 | 90 | def multiline_array_changed(self, event: MultilineArray.Changed): 91 | text = [str(x.value) for x in event.input.lines] 92 | self.body.body = " \n".join(text) 93 | 94 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None: 95 | widgets["multiline_array"].remove_class("-hidden") 96 | widgets["body_color_picker"].remove_class("-hidden") 97 | widgets["border_picker"].remove_class("-hidden") 98 | widgets["border_color_picker"].remove_class("-hidden") 99 | widgets["delete_button"].remove_class("-hidden") 100 | 101 | widgets["multiline_array"].recreate_multiline(str(self.body.body)) 102 | widgets["body_color_picker"].update_colors(self.color) 103 | widgets["border_color_picker"].update_colors(self.border_color) 104 | 105 | def dump(self) -> dict[str, Any]: 106 | return { 107 | "body": self.body.body, 108 | "pos": (self.styles.offset.x.value, self.styles.offset.y.value), 109 | "color": self.color.hex6, 110 | "border_color": self.border_color.hex6, 111 | "border_type": self.border_type, 112 | "size": (self.styles.width.value, self.styles.height.value), 113 | "type": self.type, 114 | } 115 | 116 | @classmethod 117 | def load(cls: Type[_T], obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)): 118 | return cls( 119 | id=drawable_id, 120 | body=obj["body"], 121 | color=obj["color"], 122 | pos=Offset(*obj["pos"]) - offset, 123 | size=Size(*obj["size"]), 124 | border_color=obj["border_color"], 125 | border_type=obj["border_type"], 126 | ) 127 | -------------------------------------------------------------------------------- /notesh/drawables/drawable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Optional, OrderedDict, Type, TypeVar, cast 4 | 5 | from rich.markdown import Markdown 6 | from textual import events 7 | from textual.app import ComposeResult 8 | from textual.color import Color 9 | from textual.containers import Vertical 10 | from textual.geometry import Offset, Size 11 | from textual.message import Message 12 | from textual.reactive import reactive 13 | from textual.widget import Widget 14 | from textual.widgets import Input, Static 15 | 16 | from notesh.utils import generate_short_uuid 17 | from notesh.widgets.multiline_input import MultilineArray 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( 58 | self.body, 59 | ) 60 | yield self.resizer 61 | 62 | def compose(self) -> ComposeResult: 63 | yield from self.drawable_body() 64 | 65 | self.change_color(self.color, duration=0.0) 66 | self.bring_forward() 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 | self.update_layout(duration) 77 | 78 | def update_layout(self, duration: float = 1.0): 79 | base_color = self.color 80 | if self.is_entered: 81 | base_color = base_color.darken(0.1) if base_color.brightness > 0.9 else base_color.lighten(0.1) 82 | 83 | self.body.styles.animate("background", value=base_color, duration=duration) 84 | 85 | self.body.styles.border = ("outer", base_color.darken(0.1)) 86 | self.body.styles.border_left = ("outer", base_color.lighten(0.1)) 87 | self.body.styles.border_top = ("outer", base_color.lighten(0.1)) 88 | self.resizer.styles.animate("background", value=base_color.darken(0.1), duration=duration) 89 | 90 | async def drawable_is_moved_from_key(self, offset: Offset): 91 | note = self 92 | note.offset = note.offset + offset 93 | self.post_message(Drawable.Move(drawable=self, offset=offset)) 94 | 95 | async def move(self, direction: str, value: int = 1): 96 | d = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0)} 97 | offset = Offset(*d[direction]) * value 98 | await self.drawable_is_moved_from_key(offset) 99 | 100 | async def drawable_is_moved(self, event: events.MouseMove): 101 | if self.clicked is not None and event.button != 0: 102 | note = self 103 | if event.delta: 104 | note.offset = note.offset + event.delta 105 | self.post_message(Drawable.Move(drawable=self, offset=event.delta)) 106 | 107 | async def drawable_is_focused(self, event: events.MouseEvent, display_sidebar: bool = False): 108 | self.clicked = event.offset 109 | self.bring_forward() 110 | self.post_message(Drawable.Focus(self.note_id, display_sidebar)) 111 | 112 | async def drawable_is_unfocused(self, event: events.MouseUp) -> None: 113 | self.clicked = None 114 | 115 | async def resize_drawable(self, delta_x: int, delta_y: int): 116 | note = self 117 | note.styles.width = note.styles.width.value + delta_x 118 | note.styles.height = note.styles.height.value + delta_y 119 | note.refresh() 120 | 121 | async def drawable_is_resized(self, event: events.MouseMove) -> None: 122 | if self.clicked is not None and event.button != 0: 123 | await self.resize_drawable(event.delta_x, event.delta_y) 124 | 125 | async def on_mouse_move(self, event: events.MouseMove) -> None: 126 | ... 127 | # await self.drawable_is_moved(event) 128 | 129 | async def on_mouse_down(self, event: events.MouseDown) -> None: 130 | if self.app.mouse_captured is None: 131 | self.capture_mouse() 132 | await self.drawable_is_focused(event) 133 | 134 | async def on_mouse_up(self, event: events.MouseUp) -> None: 135 | if self.app.mouse_captured is None: 136 | self.capture_mouse(False) 137 | await self.drawable_is_unfocused(event) 138 | 139 | async def on_click(self, event: events.Click): 140 | self.post_message(Drawable.Clicked(drawable=self)) 141 | event.stop() 142 | 143 | async def on_focus(self, event: events.Focus): 144 | await self.on_enter(cast(events.Enter, event)) 145 | self.post_message(Drawable.Focus(self.note_id, False)) 146 | 147 | async def on_blur(self, event: events.Blur): 148 | await self.on_leave(cast(events.Leave, event)) 149 | 150 | async def on_enter(self, event: events.Enter): 151 | self.is_entered = True 152 | 153 | async def on_leave(self, event: events.Leave): 154 | self.is_entered = False 155 | 156 | async def watch_is_entered(self, new_value: bool) -> None: 157 | if new_value: 158 | self.update_layout(duration=0.3) 159 | else: 160 | self.update_layout(duration=0.1) 161 | 162 | def next_border(self): 163 | ... 164 | 165 | def bring_forward(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 = layers + (self.styles.layer, f"{self.styles.layer}-resizer") 168 | 169 | def bring_backward(self): 170 | layers = tuple(x for x in self.screen.styles.layers if x not in [self.layer, f"{self.layer}-resizer"]) 171 | self.screen.styles.layers = (f"{self.styles.layer}", f"{self.styles.layer}-resizer") + layers 172 | 173 | def input_changed(self, event: Input.Changed): 174 | ... 175 | 176 | def multiline_array_changed(self, event: MultilineArray.Changed): 177 | ... 178 | 179 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None: 180 | widgets["body_color_picker"].remove_class("-hidden") 181 | widgets["delete_button"].remove_class("-hidden") 182 | 183 | widgets["body_color_picker"].update_colors(self.color) 184 | 185 | def dump(self) -> dict[str, Any]: 186 | return { 187 | "body": self.body.body, 188 | "pos": (self.styles.offset.x.value, self.styles.offset.y.value), 189 | "color": self.color.hex6, 190 | "size": (self.styles.width.value, self.styles.height.value), 191 | "type": self.type, 192 | } 193 | 194 | @classmethod 195 | def load(cls: Type[_T], obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)): 196 | return cls( 197 | id=drawable_id, 198 | body=obj["body"], 199 | color=obj["color"], 200 | pos=Offset(*obj["pos"]) - offset, 201 | size=Size(*obj["size"]), 202 | ) 203 | 204 | class Mess(Message): 205 | def __init__(self, value: str | None) -> None: 206 | super().__init__() 207 | self.value = value 208 | 209 | class Focus(Message): 210 | def __init__(self, index: str, display_sidebar: bool = False) -> None: 211 | super().__init__() 212 | self.index = index 213 | self.display_sidebar = display_sidebar 214 | 215 | class Move(Message): 216 | def __init__( 217 | self, 218 | drawable: Drawable, 219 | offset: Offset, 220 | ) -> None: 221 | super().__init__() 222 | self.drawable = drawable 223 | # this offset is used because when we update drawable offset 224 | # it does not update region and style until first idle 225 | self.offset = offset 226 | 227 | class Clicked(Message): 228 | def __init__( 229 | self, 230 | drawable: Drawable, 231 | ) -> None: 232 | super().__init__() 233 | self.drawable = drawable 234 | 235 | 236 | class DrawablePart(Static): 237 | body: reactive[str] = reactive("") 238 | 239 | def __init__( 240 | self, 241 | parent: Drawable, 242 | name: str | None = None, 243 | id: str | None = None, 244 | classes: str | None = None, 245 | body: str = "", 246 | ) -> None: 247 | super().__init__(body, name=name, id=id, classes=classes) 248 | self.clicked = Offset(0, 0) 249 | self.pparent: Drawable = parent 250 | self.body = str(body) 251 | 252 | def watch_body(self, body_text: str): 253 | self.update(Markdown(body_text)) 254 | 255 | async def on_mouse_down(self, event: events.MouseDown): 256 | self.capture_mouse() 257 | await self.pparent.drawable_is_focused(event) 258 | 259 | async def on_mouse_up(self, event: events.MouseUp): 260 | self.capture_mouse(False) 261 | await self.pparent.drawable_is_unfocused(event) 262 | 263 | async def on_mouse_move(self, event: events.MouseMove) -> None: 264 | ... 265 | 266 | async def on_enter(self, event: events.Enter): 267 | await self.pparent.on_enter(event) 268 | 269 | async def on_leave(self, event: events.Leave): 270 | await self.pparent.on_leave(event) 271 | 272 | 273 | class Body(DrawablePart): 274 | async def on_mouse_move(self, event: events.MouseMove) -> None: 275 | ... 276 | await self.pparent.drawable_is_moved(event) 277 | 278 | 279 | class Resizer(DrawablePart): 280 | def __init__( 281 | self, 282 | parent: Drawable, 283 | name: str | None = None, 284 | id: str | None = None, 285 | classes: str | None = None, 286 | body: str = "", 287 | ) -> None: 288 | super().__init__(parent, name=name, id=id, classes=classes, body=body) 289 | self.styles.layer = f"{id}" 290 | 291 | async def on_mouse_move(self, event: events.MouseMove) -> None: 292 | await self.pparent.drawable_is_resized(event) 293 | -------------------------------------------------------------------------------- /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 | 13 | from notesh.drawables.drawable import Drawable, DrawablePart, Resizer 14 | from notesh.utils import generate_short_uuid 15 | from notesh.widgets.multiline_input import MultilineArray 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 | self.title.styles.animate("background", value=default, duration=duration) 91 | self.title.styles.border_top = ("outer", lighter) 92 | self.title.styles.border_left = ("outer", lighter) 93 | self.title.styles.border_right = ("outer", much_darker) 94 | 95 | self.body.styles.animate("background", value=darker, duration=duration) 96 | self.body.styles.border_right = ("outer", much_darker) 97 | self.body.styles.border_bottom = ("none", much_darker) 98 | self.body.styles.border_left = ("outer", lighter) 99 | 100 | self.resizer_left.styles.background = much_darker 101 | self.resizer_left.styles.color = lighter 102 | 103 | self.resizer.styles.background = much_darker 104 | self.resizer.styles.color = default 105 | 106 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None: 107 | widgets["input"].remove_class("-hidden") 108 | widgets["multiline_array"].remove_class("-hidden") 109 | widgets["body_color_picker"].remove_class("-hidden") 110 | widgets["delete_button"].remove_class("-hidden") 111 | 112 | widgets["input"].value = str(self.title.body) 113 | widgets["multiline_array"].recreate_multiline(str(self.body.body)) 114 | widgets["body_color_picker"].update_colors(self.color) 115 | 116 | def input_changed(self, event: Input.Changed): 117 | self.title.body = str(event.value) 118 | 119 | def multiline_array_changed(self, event: MultilineArray.Changed): 120 | text = [str(x.value) for x in event.input.lines] 121 | self.body.body = " \n".join(text) 122 | 123 | def dump(self) -> dict[str, Any]: 124 | return { 125 | "title": self.title.body, 126 | "body": self.body.body, 127 | "pos": (self.styles.offset.x.value, self.styles.offset.y.value), 128 | "color": self.color.hex6, 129 | "size": (self.styles.width.value, self.styles.height.value), 130 | "type": self.type, 131 | } 132 | 133 | @classmethod 134 | def load(cls: Type[_T], obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)): 135 | return cls( 136 | id=drawable_id, 137 | title=obj["title"], 138 | body=obj["body"], 139 | color=obj["color"], 140 | pos=Offset(*obj["pos"]) - offset, 141 | size=Size(*obj["size"]), 142 | ) 143 | 144 | 145 | class NoteBody(DrawablePart): 146 | async def on_mouse_move(self, event: events.MouseMove) -> None: 147 | await self.pparent.drawable_is_moved(event) 148 | 149 | 150 | class NoteTop(DrawablePart): 151 | async def on_mouse_move(self, event: events.MouseMove) -> None: 152 | await self.pparent.drawable_is_moved(event) 153 | 154 | 155 | class Spacer(DrawablePart): 156 | async def on_mouse_move(self, event: events.MouseMove) -> None: 157 | await self.pparent.drawable_is_moved(event) 158 | 159 | 160 | class ResizerLeft(DrawablePart): 161 | async def on_mouse_move(self, event: events.MouseMove) -> None: 162 | await self.pparent.drawable_is_moved(event) 163 | -------------------------------------------------------------------------------- /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 | background:; 25 | border: none; 26 | text-style:; 27 | rule: ; 28 | padding: 0 0 0 0; 29 | margin: 0; 30 | height: 14; 31 | min-height: 6; 32 | width: 20; 33 | overflow: hidden; 34 | opacity: 100%; 35 | } 36 | 37 | Note Static { 38 | background: $secondary-darken-1; 39 | color: $text; 40 | border: none; 41 | width: 100%; 42 | height: 100%; 43 | overflow: hidden; 44 | text-style:; 45 | rule:; 46 | 47 | border: none; 48 | border-right: outer $secondary-darken-3; 49 | border-left: outer $secondary-lighten-1; 50 | border-top: none; 51 | border-bottom: outer $secondary-darken-3; 52 | } 53 | 54 | #note-toper { 55 | dock: top; 56 | height: 4; 57 | } 58 | 59 | #note-top { 60 | text-align: center; 61 | background: $secondary; 62 | border-top: outer $secondary-lighten-1; 63 | border-left: outer $secondary-lighten-1; 64 | border-bottom: none; 65 | height: 3; 66 | } 67 | 68 | #note-spacer { 69 | background: $secondary-darken-3; 70 | color: $secondary-lighten-1; 71 | border: none; 72 | height: 1; 73 | min-height: 1; 74 | } 75 | 76 | #note-resizer-left { 77 | background: $secondary-darken-3; 78 | color: $secondary-lighten-1; 79 | border: none; 80 | border-bottom: tall; 81 | height: 1; 82 | min-height: 1; 83 | width: 1fr; 84 | content-align: left bottom; 85 | } 86 | 87 | #note-resizer { 88 | background: $secondary-darken-3; 89 | color: $secondary-lighten-1; 90 | border: none; 91 | height: 1; 92 | min-height: 1; 93 | width: 2; 94 | content-align: right bottom; 95 | } 96 | 97 | #note-resizer-bar { 98 | dock: bottom; 99 | height: 1; 100 | 101 | } 102 | 103 | Sidebar { 104 | layer: sidebar; 105 | dock: right; 106 | width: 32; 107 | height: 100%; 108 | padding: 0 0 0 0; 109 | border: outer $accent-darken-3; 110 | transition: offset 500ms in_out_cubic; 111 | background: $panel; 112 | } 113 | 114 | Sidebar:focus-within { 115 | offset: 0 0 !important; 116 | } 117 | 118 | Sidebar.-hidden { 119 | offset-x: 100%; 120 | } 121 | 122 | #sidebar-title { 123 | border: heavy $secondary; 124 | padding: 0 2; 125 | text-style: bold; 126 | } 127 | 128 | MultilineArray { 129 | background: $boost; 130 | color: $text; 131 | border: heavy $secondary-darken-3; 132 | height: auto; 133 | min-height: 1; 134 | } 135 | 136 | 137 | MultilineInput { 138 | border: none; 139 | } 140 | 141 | Sidebar Button { 142 | width: 100%; 143 | height: 3; 144 | margin: 0 1; 145 | } 146 | 147 | 148 | Sidebar MultilineArray.-hidden { 149 | min-height: 0; 150 | min-width: 0; 151 | height: 0; 152 | width: 0; 153 | border: none; 154 | visibility: hidden; 155 | dock: bottom; 156 | } 157 | 158 | Sidebar ColorPicker.-hidden { 159 | min-height: 0; 160 | min-width: 0; 161 | height: 0; 162 | width: 0; 163 | border: none; 164 | visibility: hidden; 165 | dock: bottom; 166 | } 167 | 168 | Sidebar Input.-hidden { 169 | min-height: 0; 170 | min-width: 0; 171 | height: 0; 172 | 173 | width: 0; 174 | border: none; 175 | padding: 0; 176 | visibility: hidden; 177 | dock: bottom; 178 | } 179 | 180 | Sidebar Button.-hidden { 181 | min-height: 0; 182 | min-width: 0; 183 | height: 0; 184 | width: 0; 185 | border: none; 186 | visibility: hidden; 187 | dock: bottom; 188 | } 189 | 190 | ColorPicker { 191 | align-horizontal: center; 192 | background: $panel; 193 | /* background: $accent; */ 194 | height: auto; 195 | margin: 0 0 0 0; 196 | } 197 | 198 | #color-picker-title { 199 | border: heavy $primary-lighten-3; 200 | padding: 0 2; 201 | text-style: bold; 202 | text-align: center; 203 | 204 | } 205 | 206 | ColorPickerDisplay { 207 | background: $panel; 208 | /* border: outer $panel-darken-3; */ 209 | border-bottom: outer $panel-darken-3; 210 | content-align: center middle; 211 | padding: 0; 212 | margin: 0 1 0 1; 213 | color: $text; 214 | text-style: bold; 215 | height: 2; 216 | width: 100%; 217 | } 218 | 219 | ColorPickerChanger { 220 | grid-size: 3; 221 | height: 2; 222 | width: 8; 223 | } 224 | 225 | #color-changers { 226 | padding: 0; 227 | margin: 0 0 1 1; 228 | height: 2; 229 | width: auto; 230 | } 231 | 232 | #random-color { 233 | min-width: 1; 234 | min-height: 1; 235 | width: 4; 236 | height: 3; 237 | padding: 0; 238 | margin: 0; 239 | background: black; 240 | color: white; 241 | border: none; 242 | align-horizontal: center; 243 | content-align: center top; 244 | } 245 | 246 | ColorPickerChanger Static { 247 | background: $primary; 248 | row-span: 3; 249 | column-span: 2; 250 | min-width: 1; 251 | min-height: 1; 252 | height: 2; 253 | width: 6; 254 | color: $text; 255 | border: none; 256 | padding: 0; 257 | margin: 0; 258 | content-align: center top; 259 | text-style: bold; 260 | text-align: center; 261 | align-horizontal: center; 262 | } 263 | 264 | ColorPickerChanger Button { 265 | row-span: 1; 266 | column-span: 1; 267 | height: 1; 268 | width: 3; 269 | background: $accent; 270 | } 271 | 272 | ColorPickerChanger Button.-up { 273 | background: $accent-lighten-1; 274 | } 275 | 276 | ColorPickerChanger Button.-down { 277 | background: $accent-darken-3; 278 | } 279 | 280 | ColorPickerChanger Button:focus.-up { 281 | background: $accent-lighten-3; 282 | } 283 | 284 | ColorPickerChanger Button:focus.-down { 285 | background: $accent-darken-1; 286 | } 287 | 288 | Sidebar #border-picker { 289 | margin: 0 1 1 1; 290 | } 291 | 292 | Drawable { 293 | background: $error; 294 | min-width: 4; 295 | min-height: 3; 296 | color: $text; 297 | /* border: outer $primary; */ 298 | /* border-bottom: outer $primary; */ 299 | overflow-x: hidden; 300 | overflow-y: hidden; 301 | } 302 | 303 | Drawable #default-body { 304 | color: $text; 305 | background: $panel; 306 | min-width: 4; 307 | min-height: 1; 308 | border: outer $primary; 309 | height: 100%; 310 | overflow-x: hidden; 311 | overflow-y: hidden; 312 | } 313 | 314 | Drawable Resizer{ 315 | opacity: 100%; 316 | color: $error; 317 | dock: bottom; 318 | height: 1; 319 | width: 2; 320 | min-height: 1; 321 | min-width: 1; 322 | align-horizontal: right; 323 | border: none; 324 | overflow-x: hidden; 325 | overflow-y: hidden; 326 | } 327 | 328 | Drawable Resizer:hover{ 329 | opacity: 80%; 330 | } 331 | 332 | SidebarLeft { 333 | layer: sidebar; 334 | dock: left; 335 | width: 32; 336 | height: 100%; 337 | padding: 0 0 0 0; 338 | border: outer $accent-darken-3; 339 | transition: offset 500ms in_out_cubic; 340 | background: $panel; 341 | } 342 | SidebarLeft:focus-within { 343 | offset: 0 0 !important; 344 | } 345 | 346 | SidebarLeft.-hidden { 347 | offset-x: -100%; 348 | } 349 | 350 | HopScreen { 351 | background: 0%; 352 | } 353 | -------------------------------------------------------------------------------- /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 | 13 | from notesh.drawables.drawable import Drawable 14 | from notesh.play_area import PlayArea 15 | from notesh.utils import calculate_size_for_file, load_binding_config_file, load_drawables, save_drawables, set_bindings 16 | from notesh.widgets.focusable_footer import FocusableFooter 17 | from notesh.widgets.sidebar import DeleteDrawable, Sidebar 18 | from notesh.widgets.sidebar_left import SidebarLeft 19 | from hoptex.configs import HoptexBindingConfig 20 | from hoptex.decorator import hoptex 21 | 22 | KEY_ALIASES["backspace"] = ["ctrl+h"] 23 | 24 | 25 | def load_confing_hoptex(): 26 | conf = load_binding_config_file(str(Path(__file__).parent / "default_bindings.toml")) 27 | conf.update(load_binding_config_file(str(Path(__file__).parent / "user_bindings.toml"))) 28 | return conf.get("hoptex", {}) 29 | 30 | 31 | # Not perfect solution to load file here, 32 | # but cant load confing for hoptex other way 33 | hoptex_conf = load_confing_hoptex() 34 | 35 | hoptex_binding = HoptexBindingConfig( 36 | focus=hoptex_conf.get("hoptex_focus", "ctrl+n"), 37 | quit=hoptex_conf.get("hoptex_quit", "escape,ctrl+c"), 38 | unfocus="", 39 | ) 40 | 41 | 42 | @hoptex(bindings=hoptex_binding) 43 | class NoteApp(App[None]): 44 | CSS_PATH = "main.css" 45 | 46 | BINDINGS = [ 47 | Binding("ctrl+c", "quit", "Quit"), 48 | ] 49 | 50 | DEFAULT_FILE = os.environ.get( 51 | "NOTESH_FILE", 52 | str( 53 | ( 54 | Path(os.getenv("APPDATA", Path.home())) 55 | if os.name == "nt" 56 | else Path(os.getenv("XDG_DATA_HOME", Path("~/.local/share").expanduser())) 57 | ) 58 | / "notesh" 59 | / "notes.json" 60 | ), 61 | ) 62 | 63 | def __init__( 64 | self, 65 | watch_css: bool = False, 66 | file: str = DEFAULT_FILE, 67 | ): 68 | super().__init__(watch_css=watch_css) 69 | self.file = file 70 | self.footer = FocusableFooter() 71 | self.sidebar_left = SidebarLeft(classes="-hidden") 72 | self.sidebar = Sidebar(classes="-hidden") 73 | 74 | def compose(self) -> ComposeResult: 75 | min_size, max_size = calculate_size_for_file(self.file) 76 | self.play_area = PlayArea(min_size=min_size, max_size=max_size, screen_size=self.size) 77 | self.action_load_notes(min_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/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 | 18 | CHUNK_SIZE = Offset(20, 5) 19 | 20 | 21 | class PlayArea(Container): 22 | can_focus: bool = True 23 | drawables: list[Drawable] = [] 24 | is_draggin = False 25 | focused_drawable: Optional[Drawable] = None 26 | background_type: reactive[str] = reactive("plain") 27 | 28 | def __init__( 29 | self, 30 | *children: Widget, 31 | name: str | None = None, 32 | id: str | None = None, 33 | classes: str | None = None, 34 | min_size: Size = Size(0, 0), 35 | max_size: Size = Size(100, 40), 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 | calculated_width, calculated_height = self._calculate_size(min_size, max_size) 42 | self.styles.width, self.styles.height = calculated_width, calculated_height 43 | self.offset += self._calculate_additional_offset(screen_size, Size(calculated_width, calculated_height)) 44 | self.color = Color.parse(color) 45 | self.border_color = Color.parse(border_color) 46 | 47 | def compose(self) -> ComposeResult: 48 | self.change_color(self.color, duration=0.0) 49 | yield from () 50 | 51 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None: 52 | if isinstance(new_color, str): 53 | base_color = Color.parse(new_color) 54 | else: 55 | base_color = new_color 56 | 57 | if part_type == "" or part_type == "body": 58 | self.color = base_color 59 | else: 60 | self.border_color = base_color 61 | self.update_layout(duration) 62 | 63 | def update_layout(self, duration: float = 1.0): 64 | base_color = self.color 65 | border_color = self.border_color 66 | 67 | self.styles.animate("background", value=base_color, duration=duration) 68 | self.styles.border = ("outer", border_color) 69 | 70 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None: 71 | widgets["body_color_picker"].remove_class("-hidden") 72 | widgets["border_color_picker"].remove_class("-hidden") 73 | 74 | widgets["body_color_picker"].update_colors(self.color) 75 | widgets["border_color_picker"].update_colors(self.border_color) 76 | 77 | def add_new_drawable(self, drawable_type: str) -> Drawable: 78 | d = {"note": Note, "box": Box} 79 | drawable = cast(Drawable, d.get(drawable_type, Drawable)()) 80 | self._mount_drawable(drawable) 81 | 82 | return drawable 83 | 84 | def add_parsed_drawable(self, obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)) -> None: 85 | drawable_type = obj["type"] 86 | d = {"note": Note, "box": Box} 87 | drawable = cast(Drawable, d.get(drawable_type, Drawable).load(obj, drawable_id, offset)) 88 | self._mount_drawable(drawable) 89 | 90 | def clear_drawables(self) -> None: 91 | while self.drawables: 92 | self.drawables.pop().remove() 93 | 94 | def delete_drawable(self, drawable: Optional[Drawable] = None) -> None: 95 | if drawable is None: 96 | drawable = self.focused_drawable 97 | if drawable is None: 98 | return 99 | 100 | self.drawables = [note for note in self.drawables if note != drawable] 101 | drawable.remove() 102 | self.focused_drawable = None 103 | if len(self.drawables) == 0: 104 | self.can_focus = True 105 | 106 | async def on_mouse_move(self, event: MouseMove) -> None: 107 | if event.ctrl and self.is_draggin: 108 | await self._move_play_area(event.delta) 109 | 110 | async def on_mouse_down(self, event: MouseDown) -> None: 111 | if event.ctrl: 112 | self.is_draggin = True 113 | self.capture_mouse() 114 | 115 | async def on_mouse_up(self, _: MouseUp) -> None: 116 | self.is_draggin = False 117 | self.capture_mouse(False) 118 | 119 | async def on_click(self, event: Click) -> None: 120 | self.post_message(PlayArea.Clicked()) 121 | 122 | async def on_drawable_move(self, event: Drawable.Move) -> None: 123 | await self._resize_field_to_drawable(event.drawable, event.offset) 124 | 125 | async def on_drawable_focus(self, message: Drawable.Focus) -> None: 126 | drawable = self.screen.get_widget_by_id(message.index) 127 | self.focused_drawable = cast(Drawable, drawable) 128 | 129 | async def move_drawable(self, direction: str, value: int) -> None: 130 | if self.focused_drawable is not None: 131 | await self.focused_drawable.move(direction, value) 132 | 133 | def _calculate_additional_offset(self, size_a: Size, size_b: Size): 134 | return Offset((size_a.width - size_b.width) // 2, (size_a.height - size_b.height) // 2) 135 | 136 | def _calculate_size(self, min_size: Size, max_size: Size): 137 | calculated_width = ((max_size.width - min_size.width + 1) // CHUNK_SIZE.x) * CHUNK_SIZE.x + CHUNK_SIZE.x 138 | calculated_height = ((max_size.height - min_size.height + 1) // CHUNK_SIZE.y) * CHUNK_SIZE.y + CHUNK_SIZE.y 139 | return calculated_width, calculated_height 140 | 141 | def _mount_drawable(self, drawable: Drawable) -> None: 142 | self.drawables.append(drawable) 143 | self.mount(drawable) 144 | self.is_draggin = False 145 | self.can_focus = False 146 | 147 | async def _move_play_area(self, offset: Offset) -> None: 148 | self.offset = self.offset + offset 149 | 150 | async def _resize_field_to_drawable(self, drawable: Drawable, offset: Offset = Offset(0, 0)) -> None: 151 | xx, yy = offset.x, offset.y 152 | if drawable.region.right + xx >= self.region.right: 153 | self.styles.width = self.styles.width.value + CHUNK_SIZE.x 154 | 155 | if drawable.region.bottom + yy >= self.region.bottom: 156 | self.styles.height = self.styles.height.value + CHUNK_SIZE.y 157 | 158 | if drawable.region.x + xx <= self.region.x: 159 | self.styles.width = self.styles.width.value + CHUNK_SIZE.x 160 | self.styles.offset = (self.styles.offset.x.value - CHUNK_SIZE.x, self.styles.offset.y.value) 161 | for child in self.children: 162 | child.styles.offset = (child.styles.offset.x.value + CHUNK_SIZE.x, child.styles.offset.y.value) 163 | 164 | if drawable.region.y + yy <= self.region.y: 165 | self.styles.height = self.styles.height.value + CHUNK_SIZE.y 166 | self.styles.offset = (self.styles.offset.x.value, self.styles.offset.y.value - CHUNK_SIZE.y) 167 | for child in self.children: 168 | child.styles.offset = (child.styles.offset.x.value, child.styles.offset.y.value + CHUNK_SIZE.y) 169 | 170 | def dump(self) -> dict[str, Any]: 171 | return { 172 | "color": self.color.hex6, 173 | "border_color": self.border_color.hex6, 174 | "type": self.background_type, 175 | } 176 | 177 | def load(self, obj: Optional[dict[Any, Any]]): 178 | if obj is None: 179 | return 180 | self.color = Color.parse(obj["color"]) 181 | self.border_color = Color.parse(obj["border_color"]) 182 | self.background_type = obj["type"] 183 | 184 | class Clicked(Message): 185 | def __init__( 186 | self, 187 | ) -> None: 188 | super().__init__() 189 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/notesh/widgets/__init__.py -------------------------------------------------------------------------------- /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/widgets/focusable_footer.py: -------------------------------------------------------------------------------- 1 | from textual.widgets import Footer 2 | 3 | 4 | class FocusableFooter(Footer): 5 | can_focus = True 6 | -------------------------------------------------------------------------------- /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/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 11 | 12 | from notesh.drawables.drawable import Drawable 13 | from notesh.play_area import PlayArea 14 | from notesh.widgets.color_picker import ColorPicker 15 | from notesh.widgets.multiline_input import MultilineArray 16 | 17 | 18 | class DeleteDrawable(Message): 19 | def __init__(self, drawable: Drawable) -> None: 20 | super().__init__() 21 | self.drawable = drawable 22 | 23 | 24 | class Sidebar(Vertical): 25 | can_focus_children: bool = False 26 | 27 | def __init__( 28 | self, 29 | *children: Widget, 30 | name: str | None = None, 31 | id: str | None = None, 32 | classes: str | None = None, 33 | ) -> None: 34 | super().__init__(*children, name=name, id=id, classes=classes) 35 | self.drawable: Optional[Drawable] = None 36 | 37 | input = Input("Title", id="sidebar-title") 38 | multiline_array = MultilineArray() 39 | 40 | body_color_picker = ColorPicker(type="body", title="Body Color Picker") 41 | 42 | border_color_picker = ColorPicker(type="border", title="Border Color Picker") 43 | border_type = Button("Change Border", id="border-picker", variant="warning") 44 | 45 | button = Button("Delete Note", id="delete-sticknote", variant="error") 46 | 47 | self.widget_list: OrderedDict[str, Widget] = OrderedDict( 48 | { 49 | "input": input, 50 | "multiline_array": multiline_array, 51 | "body_color_picker": body_color_picker, 52 | "border_color_picker": border_color_picker, 53 | "border_picker": border_type, 54 | "delete_button": button, 55 | } 56 | ) 57 | 58 | def compose(self) -> ComposeResult: 59 | self.current_layout = Vertical(*self.widget_list.values()) 60 | yield self.current_layout 61 | 62 | def change_sidebar(self): 63 | for widget in self.widget_list.values(): 64 | widget.add_class("-hidden") 65 | 66 | if self.drawable is not None: 67 | self.drawable.sidebar_layout(self.widget_list) 68 | 69 | async def set_drawable(self, drawable: Optional[Drawable], display_sidebar: bool = False): 70 | self.drawable = drawable 71 | self.change_sidebar() 72 | 73 | if self.drawable is None: 74 | self.set_focus(False) 75 | elif display_sidebar: 76 | self.set_focus(True) 77 | self.refresh() 78 | 79 | async def on_input_changed(self, event: Input.Changed): 80 | if self.drawable is not None: 81 | self.drawable.input_changed(event) 82 | 83 | async def on_multiline_array_changed(self, event: MultilineArray.Changed): 84 | if self.drawable is not None: 85 | self.drawable.multiline_array_changed(event) 86 | 87 | def change_drawable_color(self, color: Color | str, part_type: str): 88 | if self.drawable: 89 | self.drawable.change_color(color, part_type=part_type) 90 | 91 | def on_button_pressed(self, event: Button.Pressed): 92 | if not self.drawable: 93 | return 94 | button_id = event.button.id 95 | if button_id == "delete-sticknote": 96 | if not self.drawable: 97 | return 98 | self.post_message(DeleteDrawable(self.drawable)) 99 | self.screen.query_one(PlayArea).post_message(DeleteDrawable(self.drawable)) 100 | self.refresh() 101 | if button_id == "border-picker": 102 | self.drawable.next_border() 103 | 104 | def on_color_picker_change(self, message: ColorPicker.Change): 105 | self.change_drawable_color(message.color, message.type) 106 | 107 | def get_child(self, index: Optional[int] = None) -> Optional[Widget]: 108 | if index is None: 109 | for child in self.widget_list.values(): 110 | if child.has_class("-hidden"): 111 | continue 112 | # Should be change to something pretier 113 | if isinstance(child, MultilineArray): 114 | return child.lines[0] 115 | else: 116 | return child 117 | return None 118 | child: Widget = list(self.widget_list.values())[index % len(self.widget_list)] 119 | if child.has_class("-hidden"): 120 | return None 121 | return child 122 | 123 | def toggle_focus(self) -> bool: 124 | if self.has_class("-hidden"): 125 | self.set_focus(True) 126 | return True 127 | else: 128 | self.set_focus(False) 129 | return False 130 | 131 | def set_focus(self, focus: bool): 132 | if focus is True: 133 | self.remove_class("-hidden") 134 | self.can_focus_children = True 135 | else: 136 | self.add_class("-hidden") 137 | self.can_focus_children = False 138 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | textual 2 | tomli 3 | hoptex 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_files = LICENSE 3 | license = MIT 4 | -------------------------------------------------------------------------------- /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.8.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.7, <4", 19 | install_requires=["textual==0.37.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 | --------------------------------------------------------------------------------