diff --git a/README.md b/README.md index c79f629..1e5dd92 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Notes application written in Python 3.8 & KivyMD ## version history | version | date | description | | :---: | :---: | :---: | +| 0.1.1 | 12/7/2022 | removing item drawer menu highlight, improving diff feature, bugfixes | | 0.1.0 | 19/6/2022 | initial release | ## FAQ diff --git a/notes_app/color.py b/notes_app/color.py index d613325..b9fc3b0 100644 --- a/notes_app/color.py +++ b/notes_app/color.py @@ -1,4 +1,4 @@ -from typing import AnyStr, List +from typing import List class Color: @@ -33,7 +33,7 @@ def __init__(self, name, rgba_value): ] -def get_color_by_name(colors_list: List[Color], color_name: AnyStr) -> Color: +def get_color_by_name(colors_list: List[Color], color_name: str) -> Color: for color in colors_list: if color.name == color_name: return color diff --git a/notes_app/controller/notes_controller.py b/notes_app/controller/notes_controller.py index b8428b4..833ce1f 100644 --- a/notes_app/controller/notes_controller.py +++ b/notes_app/controller/notes_controller.py @@ -1,5 +1,3 @@ -from typing import AnyStr - from notes_app.view.notes_view import NotesView @@ -39,7 +37,7 @@ def set_file_path(self, file_path) -> None: self.model.update() self.model.dump() - def read_file_data(self, file_path=None) -> AnyStr: + def read_file_data(self, file_path=None) -> str: f = open(file_path or self.model.file_path, "r") s = f.read() f.close() diff --git a/notes_app/diff.py b/notes_app/diff.py index fb9f06b..6480ea5 100644 --- a/notes_app/diff.py +++ b/notes_app/diff.py @@ -1,5 +1,5 @@ import difflib -from typing import AnyStr +from typing import List TEXT_FILE_LINE_BREAK_CHAR = "\n" # TEXT_FILE_LINE_BREAK_CHAR_TEMP_REPLACEMENT is used because difflib SequenceMatcher consumes line endings @@ -27,35 +27,103 @@ def _merge(left, right): def _replace_line_endings( - input_text: AnyStr, line_ending: AnyStr, line_ending_replacement: AnyStr -) -> AnyStr: + input_text: str, line_ending: str, line_ending_replacement: str +) -> str: """ _replace_line_endings """ return input_text.replace(line_ending, line_ending_replacement) -def merge_strings(before: AnyStr, after: AnyStr) -> AnyStr: +SEPARATORS = { + " ", # (blank space) + "~", # (tilde) + "`", # (grave accent) + "!", # (exclamation mark) + "@", # (at) + "#", # (pound) + "$", # (dollar sign) + "%", # (percent) + "^", # (carat) + "&", # (ampersand) + "*", # (asterisk) + "(", # (open parenthesis) + ")", # (close parenthesis) + "_", # (underscore) + "-", # (hyphen) + "+", # (plus sign) + "=", # (equals) + "{", # (open brace) + "}", # (close brace) + "[", # (open bracket) + "]", # (close bracket) + "|", # (pipe) + "\\", # (backslash) + ":", # (colon) + ";", # (semicolon) + "<", # (less than) + ",", # (comma) + ">", # (greater than) + ".", # (period) + "?", # (question mark) + "/", # (forward slash) +} + + +def _split(input_text: str) -> List: + result = [] + offset = 0 + for idx, string in enumerate(input_text): + if string in SEPARATORS: + result.append(input_text[offset:idx]) + result.append(string) + offset = idx + 1 + # the last word in the enumerated input text + if idx + 1 == len(input_text): + result.append(input_text[offset : idx + 1]) + return result + + +def _join(input_list: List, separator: str) -> str: + result = str() + for idx, el in enumerate(input_list): + if ( + idx == 0 + or (el in SEPARATORS) + or (idx > 1 and input_list[idx - 1] in SEPARATORS) + ): + result += el + else: + result += f"{separator}{el}" + return result + + +def merge_strings(before: str, after: str) -> str: """ merge_strings """ + default_separator = " " merged = _merge( - _replace_line_endings( - input_text=before, - line_ending=TEXT_FILE_LINE_BREAK_CHAR, - line_ending_replacement=TEXT_FILE_LINE_BREAK_CHAR_TEMP_REPLACEMENT, - ).split(), - _replace_line_endings( - input_text=after, - line_ending=TEXT_FILE_LINE_BREAK_CHAR, - line_ending_replacement=TEXT_FILE_LINE_BREAK_CHAR_TEMP_REPLACEMENT, - ).split(), + _split( + _replace_line_endings( + input_text=before, + line_ending=TEXT_FILE_LINE_BREAK_CHAR, + line_ending_replacement=TEXT_FILE_LINE_BREAK_CHAR_TEMP_REPLACEMENT, + ) + ), + _split( + _replace_line_endings( + input_text=after, + line_ending=TEXT_FILE_LINE_BREAK_CHAR, + line_ending_replacement=TEXT_FILE_LINE_BREAK_CHAR_TEMP_REPLACEMENT, + ) + ), ) merged_result_list = [el for sublist in merged for el in sublist] return _replace_line_endings( - input_text=" ".join(merged_result_list), + input_text=_join(input_list=merged_result_list, separator=default_separator), line_ending=TEXT_FILE_LINE_BREAK_CHAR_TEMP_REPLACEMENT, line_ending_replacement=TEXT_FILE_LINE_BREAK_CHAR, ) diff --git a/notes_app/file.py b/notes_app/file.py index 62373d9..a75bf0d 100644 --- a/notes_app/file.py +++ b/notes_app/file.py @@ -1,16 +1,22 @@ import re -from typing import AnyStr, List, Dict +from typing import List, Dict SECTION_FILE_NEW_SECTION_PLACEHOLDER = "" SECTION_FILE_NAME_MINIMAL_CHAR_COUNT = 2 +def get_validated_file_path(file_path): + try: + with open(file=file_path, mode="r"): + pass + except (PermissionError, FileNotFoundError, IsADirectoryError): + return + return file_path + + class SectionIdentifier: def __init__( - self, - defaults, - section_file_separator: AnyStr = None, - section_name: AnyStr = None, + self, defaults, section_file_separator: str = None, section_name: str = None, ): self.defaults = defaults @@ -25,13 +31,13 @@ def __init__( ) self.section_name = section_name or self._transform_separator_to_name() - def _transform_separator_to_name(self) -> AnyStr: + def _transform_separator_to_name(self) -> str: return re.search( self.defaults.DEFAULT_SECTION_FILE_SEPARATOR_GROUP_SUBSTR_REGEX, self.section_file_separator, ).group(1) - def _transform_name_to_separator(self, section_name) -> AnyStr: + def _transform_name_to_separator(self, section_name) -> str: return self.defaults.DEFAULT_SECTION_FILE_SEPARATOR.format(name=section_name) @@ -42,7 +48,7 @@ def __init__(self, file_path, controller, defaults): self.defaults = defaults - self._raw_data_content: AnyStr = self._get_validated_raw_data( + self._raw_data_content: str = self._get_validated_raw_data( raw_data=self.get_raw_data_content() ) @@ -51,19 +57,10 @@ def __init__(self, file_path, controller, defaults): ] = self._get_section_identifiers_from_raw_data_content() self._data_by_sections: Dict[ - AnyStr, AnyStr + str, str ] = self._transform_raw_data_content_to_data_by_sections() - @staticmethod - def get_validated_file_path(file_path): - try: - with open(file=file_path, mode="r"): - pass - except (PermissionError, FileNotFoundError, IsADirectoryError): - return - return file_path - - def _get_validated_raw_data(self, raw_data) -> AnyStr: + def _get_validated_raw_data(self, raw_data) -> str: matches = re.findall( self.defaults.DEFAULT_SECTION_FILE_SEPARATOR_REGEX, raw_data ) @@ -84,7 +81,7 @@ def reload(self): ) self._data_by_sections = self._transform_raw_data_content_to_data_by_sections() - def get_raw_data_content(self) -> AnyStr: + def get_raw_data_content(self) -> str: return self._controller.read_file_data(file_path=self._file_path) def _get_section_identifiers_from_raw_data_content(self) -> List[SectionIdentifier]: @@ -123,7 +120,7 @@ def delete_section_identifier(self, section_file_separator) -> None: def set_section_content(self, section_file_separator, section_content) -> None: self._data_by_sections[section_file_separator] = section_content - def get_section_content(self, section_file_separator) -> AnyStr: + def get_section_content(self, section_file_separator) -> str: return self._data_by_sections[section_file_separator] def delete_all_sections_content(self) -> None: @@ -140,14 +137,14 @@ def rename_section( by replacing the section identifier in the _section_identifiers list and by replacing the key in the _data_by_sections dict """ - idx = 0 - for idx, section_identifier in enumerate(self._section_identifiers): - if section_identifier.section_file_separator == old_section_file_separator: - break # need to preserve the order of the _section_identifiers list item # and the _data_by_sections dict items so that new items are placed at the end - self._section_identifiers.pop(idx) + + for idx, section_identifier in enumerate(self._section_identifiers): + if section_identifier.section_file_separator == old_section_file_separator: + self._section_identifiers.pop(idx) + self._section_identifiers.append( SectionIdentifier( defaults=self.defaults, @@ -160,7 +157,7 @@ def rename_section( ] del self._data_by_sections[old_section_file_separator] - def _transform_raw_data_content_to_data_by_sections(self) -> Dict[AnyStr, AnyStr]: + def _transform_raw_data_content_to_data_by_sections(self) -> Dict[str, str]: dict_data = dict() for item in zip( self._section_identifiers, @@ -172,7 +169,7 @@ def _transform_raw_data_content_to_data_by_sections(self) -> Dict[AnyStr, AnyStr dict_data[item[0].section_file_separator] = item[1] return dict_data - def transform_data_by_sections_to_raw_data_content(self) -> AnyStr: + def transform_data_by_sections_to_raw_data_content(self) -> str: text_data = str() for k, v in self._data_by_sections.items(): text_data += k diff --git a/notes_app/font.py b/notes_app/font.py index 8f1fc1a..bc8f47b 100644 --- a/notes_app/font.py +++ b/notes_app/font.py @@ -1,4 +1,4 @@ -from typing import AnyStr, List +from typing import List AVAILABLE_FONTS = [ "DejaVuSans", @@ -10,7 +10,7 @@ ] -def get_next_font(fonts_list: List[AnyStr], font_name: AnyStr) -> AnyStr: +def get_next_font(fonts_list: List[str], font_name: str) -> str: iterable_available_fonts = iter(fonts_list) for font in iterable_available_fonts: diff --git a/notes_app/mark.py b/notes_app/mark.py index 78e25de..f8a930d 100644 --- a/notes_app/mark.py +++ b/notes_app/mark.py @@ -1,9 +1,4 @@ -from typing import AnyStr - - -def get_marked_text( - text: AnyStr, highlight_style: AnyStr, highlight_color: AnyStr -) -> AnyStr: +def get_marked_text(text: str, highlight_style: str, highlight_color: str) -> str: return ( f"[{highlight_style}]" f"[color={highlight_color}]" diff --git a/notes_app/model/notes_model.py b/notes_app/model/notes_model.py index 5b280b9..ce0728d 100644 --- a/notes_app/model/notes_model.py +++ b/notes_app/model/notes_model.py @@ -8,12 +8,11 @@ import json import time from os import linesep, path -from typing import AnyStr GENERAL_DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" -def format_local_epoch(format: AnyStr, epoch_time: int) -> AnyStr: +def format_local_epoch(format: str, epoch_time: int) -> str: """ format epoch in local time based on provided format """ diff --git a/notes_app/search.py b/notes_app/search.py index 36ff192..8f7138c 100644 --- a/notes_app/search.py +++ b/notes_app/search.py @@ -1,5 +1,4 @@ import re -from typing import AnyStr SEARCH_MINIMAL_CHAR_COUNT = 2 @@ -105,7 +104,7 @@ def search_for_occurrences(self, pattern, file, current_section_identifier): def transform_position_text_placeholder_to_position( - position_text_placeholder: AnyStr = None, + position_text_placeholder: str = None, ) -> int: if position_text_placeholder: return int( @@ -116,15 +115,15 @@ def transform_position_text_placeholder_to_position( return 0 -def transform_position_to_position_text_placeholder(position_start: int = 0) -> AnyStr: +def transform_position_to_position_text_placeholder(position_start: int = 0) -> str: if position_start: return f"{SEARCH_LIST_ITEM_POSITION_DISPLAY_VALUE}{position_start}" return f"{SEARCH_LIST_ITEM_POSITION_DISPLAY_VALUE}0" def transform_section_text_placeholder_to_section_name( - section_text_placeholder: AnyStr = None, -) -> AnyStr: + section_text_placeholder: str = None, +) -> str: if section_text_placeholder: return section_text_placeholder.replace( SEARCH_LIST_ITEM_SECTION_DISPLAY_VALUE, "" @@ -132,9 +131,7 @@ def transform_section_text_placeholder_to_section_name( return "" -def transform_section_name_to_section_text_placeholder( - section_name: AnyStr = "", -) -> AnyStr: +def transform_section_name_to_section_text_placeholder(section_name: str = "",) -> str: if section_name: return f"{SEARCH_LIST_ITEM_SECTION_DISPLAY_VALUE}{section_name}" return f"{SEARCH_LIST_ITEM_SECTION_DISPLAY_VALUE}" diff --git a/notes_app/version.py b/notes_app/version.py index 3dc1f76..485f44a 100644 --- a/notes_app/version.py +++ b/notes_app/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/notes_app/view/notes_view.kv b/notes_app/view/notes_view.kv index 63103a3..fa546db 100644 --- a/notes_app/view/notes_view.kv +++ b/notes_app/view/notes_view.kv @@ -18,7 +18,7 @@ id: section_item text: root.text theme_text_color: "Custom" - on_release: self.parent.set_color_item(self) + # on_release: self.parent.set_color_item(self) on_size: self.ids._right_container.width = icons_container.width self.ids._right_container.x = icons_container.width @@ -51,7 +51,7 @@ MDScreen: FloatLayout: id: float_text_layout - TextInput: + CustomTextInput: id: text_input multiline: True do_wrap: True diff --git a/notes_app/view/notes_view.py b/notes_app/view/notes_view.py index 207248e..a4d8138 100644 --- a/notes_app/view/notes_view.py +++ b/notes_app/view/notes_view.py @@ -1,4 +1,5 @@ import os +import re import webbrowser from enum import Enum from os import path, linesep @@ -12,6 +13,7 @@ from kivymd.theming import ThemableBehavior from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.dialog import MDDialog +from kivymd.uix.textfield import TextInput from kivymd.uix.filemanager import MDFileManager from kivymd.uix.list import ( MDList, @@ -23,6 +25,9 @@ from kivymd.uix.screen import MDScreen from kivymd.uix.snackbar import BaseSnackbar +from kivy.base import EventLoop +from kivy.uix.textinput import FL_IS_LINEBREAK + from notes_app import __version__ from notes_app.diff import merge_strings from notes_app.observer.notes_observer import Observer @@ -34,6 +39,7 @@ AVAILABLE_SNACK_BAR_COLORS, ) from notes_app.file import ( + get_validated_file_path, File, SectionIdentifier, SECTION_FILE_NEW_SECTION_PLACEHOLDER, @@ -62,6 +68,80 @@ EXTERNAL_REPOSITORY_URL = "https://www.github.com/datahappy1/notes_app/" +class CustomTextInput(TextInput): + # overriding TextInput.insert_text() with added extra condition and (len(_lines_flags) - 1 >= row + 1) + # to handle a edge case when external update adds multiple line breaks and results in uncaught index error + def insert_text(self, substring, from_undo=False): + '''Insert new text at the current cursor position. Override this + function in order to pre-process text for input validation. + ''' + _lines = self._lines + _lines_flags = self._lines_flags + + if self.readonly or not substring or not self._lines: + return + + if isinstance(substring, bytes): + substring = substring.decode('utf8') + + if self.replace_crlf: + substring = substring.replace(u'\r\n', u'\n') + + self._hide_handles(EventLoop.window) + + if not from_undo and self.multiline and self.auto_indent \ + and substring == u'\n': + substring = self._auto_indent(substring) + + mode = self.input_filter + if mode not in (None, 'int', 'float'): + substring = mode(substring, from_undo) + if not substring: + return + + col, row = self.cursor + cindex = self.cursor_index() + text = _lines[row] + len_str = len(substring) + new_text = text[:col] + substring + text[col:] + if mode is not None: + if mode == 'int': + if not re.match(self._insert_int_pat, new_text): + return + elif mode == 'float': + if not re.match(self._insert_float_pat, new_text): + return + self._set_line_text(row, new_text) + + if len_str > 1 or substring == u'\n' or\ + (substring == u' ' and _lines_flags[row] != FL_IS_LINEBREAK) or\ + (row + 1 < len(_lines) and (len(_lines_flags) - 1 >= row + 1) and + _lines_flags[row + 1] != FL_IS_LINEBREAK) or\ + (self._get_text_width( + new_text, + self.tab_width, + self._label_cached) > (self.width - self.padding[0] - + self.padding[2])): + # Avoid refreshing text on every keystroke. + # Allows for faster typing of text when the amount of text in + # TextInput gets large. + + ( + start, finish, lines, lines_flags, len_lines + ) = self._get_line_from_cursor(row, new_text) + + # calling trigger here could lead to wrong cursor positioning + # and repeating of text when keys are added rapidly in a automated + # fashion. From Android Keyboard for example. + self._refresh_text_from_property( + 'insert', start, finish, lines, lines_flags, len_lines + ) + + self.cursor = self.get_cursor_from_index(cindex + len_str) + # handle undo and redo + self._set_unredo_insert(cindex, cindex + len_str, substring, from_undo) + + class IconsContainer(IRightBodyTouch, MDBoxLayout): pass @@ -78,15 +158,17 @@ class ContentNavigationDrawer(MDBoxLayout): class DrawerList(ThemableBehavior, MDList): - def set_color_item(self, instance_item): - """Called when tap on a menu item. - Set the color of the icon and text for the menu item. - """ - for item in self.children: - if item.text_color == self.theme_cls.primary_color: - item.text_color = self.theme_cls.text_color - break - instance_item.text_color = self.theme_cls.primary_color + pass # set_color_item causing app crashes hard to reproduce + + # def set_color_item(self, instance_item): + # """Called when tap on a menu item. + # Set the color of the icon and text for the menu item. + # """ + # for item in self.children: + # if item.text_color == self.theme_cls.primary_color: + # item.text_color = self.theme_cls.text_color + # break + # instance_item.text_color = self.theme_cls.primary_color class OpenFileDialogContent(MDBoxLayout): @@ -225,6 +307,12 @@ def filter_data_split_by_section(self, section_identifier=None): # but changing the section without any actual typing is not an unsaved change self.auto_save_text_input_change_counter = 0 + # de-select text to cover edge case when + # the search result is selected even after the related section is deleted + self.text_section_view.select_text( + 0, 0 + ) + self.ids.toolbar.title = ( f"{APP_TITLE} section: {section_identifier.section_name}" ) @@ -378,7 +466,7 @@ def execute_open_file(self, file_path): self.show_error_bar(error_message="Invalid file") return - validated_file_path = File.get_validated_file_path(file_path) + validated_file_path = get_validated_file_path(file_path=file_path) if not validated_file_path: self.show_error_bar(error_message=f"Cannot open the file {file_path}") return @@ -612,6 +700,9 @@ def save_current_section_to_file(self): ) self.text_section_view.text = merged_current_section_text_data + # un-focus the TextInput so that the cursor is not offset by the external update + self.text_section_view.focus = False + self.set_drawer_items( section_identifiers=self.file.section_identifiers_sorted_by_name ) diff --git a/notes_app_recording.gif b/notes_app_recording.gif index 49ffb17..4cc4fb6 100644 Binary files a/notes_app_recording.gif and b/notes_app_recording.gif differ diff --git a/tests/test_unit_diff.py b/tests/test_unit_diff.py index 12e54a5..c31da9a 100644 --- a/tests/test_unit_diff.py +++ b/tests/test_unit_diff.py @@ -1,6 +1,12 @@ import pytest -from notes_app.diff import _merge, _replace_line_endings, merge_strings +from notes_app.diff import ( + _merge, + _replace_line_endings, + _split, + _join, + merge_strings, +) class TestDiff: @@ -21,28 +27,17 @@ class TestDiff: ( "", "this is some section.yeah", - [ - ["this", "is", "some", "section.yeah"], - ], + [["this", "is", "some", "section.yeah"],], ), ( "some section text", "this is some section.yeah", - [ - ["this", "is"], - ["some"], - ["section", "text"], - ["section.yeah"], - ], + [["this", "is"], ["some"], ["section", "text"], ["section.yeah"],], ), ( "some section text", "another text", - [ - ["some", "section"], - ["another"], - ["text"], - ], + [["some", "section"], ["another"], ["text"],], ), ], ) @@ -63,32 +58,51 @@ def test__replace_line_endings(self): == "ad na" ) + @pytest.mark.parametrize( + "input_text, result", + [ + ( + "this is some section.yeah", + ["this", " ", "is", " ", "some", " ", "section", ".", "yeah"], + ), + ("another text", ["another", " ", "text"],), + ], + ) + def test__split(self, input_text, result): + assert _split(input_text) == result + + @pytest.mark.parametrize( + "input_list, result", + [ + (["this", "is", "some", "section.yeah"], "this is some section.yeah",), + (["another", "text"], "another text",), + ], + ) + def test__join(self, input_list, result): + assert _join(input_list, separator=" ") == result + @pytest.mark.parametrize( "before, after, result", [ ("is", "this is some section.yeah", "this is some section.yeah"), ("is", "this some section.yeah", "is this some section.yeah"), ("", "this is some section.yeah", "this is some section.yeah"), - ( - "", - "this is some section.yeah", - "this is some section.yeah", - ), + ("", "this is some section.yeah", "this is some section.yeah",), ( "some section text", "this is some section.yeah", - "this is some section text section.yeah", + "this is some section text.yeah", ), ( "some section text", "another text this is some section.yeah", - "another text this is some section text section.yeah", + "another text this is some section text.yeah", ), ( "some \n section text", "this is some section.yeah", """this is some - section text section.yeah""", + section text.yeah""", ), ], ) diff --git a/tests/test_unit_file.py b/tests/test_unit_file.py index d08f236..32f1fda 100644 --- a/tests/test_unit_file.py +++ b/tests/test_unit_file.py @@ -2,13 +2,21 @@ import pytest -from notes_app.file import File, SectionIdentifier +from notes_app.file import SectionIdentifier, get_validated_file_path from notes_app.defaults import Defaults defaults = Defaults() +def test_get_validated_file_path(): + file_path = defaults.DEFAULT_NOTES_FILE_NAME + assert get_validated_file_path(file_path=file_path) == file_path + + file_path = f"sample_not_existing_{uuid.uuid4().hex}.txt" + assert get_validated_file_path(file_path=file_path) is None + + class TestSectionIdentifier: def test_section_identifier(self): with pytest.raises(ValueError): @@ -34,13 +42,6 @@ def test_section_identifier(self): class TestFile: - def test_get_validated_file_path(self): - file_path = defaults.DEFAULT_NOTES_FILE_NAME - assert File.get_validated_file_path(file_path=file_path) == file_path - - file_path = f"sample_not_existing_{uuid.uuid4().hex}.txt" - assert File.get_validated_file_path(file_path=file_path) is None - def test_get_raw_data_content(self, get_file): raw_data = get_file.get_raw_data_content() assert ( diff --git a/tests/test_unit_view.py b/tests/test_unit_view.py index 10e43c4..f5aca8c 100644 --- a/tests/test_unit_view.py +++ b/tests/test_unit_view.py @@ -66,6 +66,7 @@ def test_filter_data_split_by_section(self, get_app): assert screen.text_section_view.section_file_separator == " " assert screen.text_section_view.text == f"Quod equidem non reprehendo\n" + assert screen.text_section_view.selection_text == "" assert screen.ids.toolbar.title == "Notes section: first" def test_set_drawer_items(self, get_app): @@ -742,6 +743,8 @@ def test_save_current_section_to_file_is_external_update(self, get_app): == """ Quod equidem non reprehendo\n Quis istum dolorem timet test text""" ) + assert screen.text_section_view.focus is False + def test_save_current_section_to_file_is_external_update_with_changes_to_current_section(self, get_app): screen = get_app.controller.get_screen()