From 036b297d7938a2b570477903eb97bb694fc20ccd Mon Sep 17 00:00:00 2001 From: megmugur Date: Sun, 18 Oct 2020 10:59:23 -0700 Subject: [PATCH 01/15] browser wip : added file buttons --- browserApp/.idea/.gitignore | 2 + browserApp/.idea/browserApp.iml | 12 ++ .../inspectionProfiles/profiles_settings.xml | 6 + browserApp/.idea/misc.xml | 7 + browserApp/.idea/modules.xml | 8 + browserApp/.idea/vcs.xml | 6 + browserApp/app.py | 21 +-- browserApp/collection_view.py | 49 +++--- browserApp/file_app.py | 2 +- browserApp/info_view.py | 141 ++++++++++++++++-- browserApp/model/file.py | 3 +- browserApp/tree_browser.py | 4 +- 12 files changed, 213 insertions(+), 48 deletions(-) create mode 100644 browserApp/.idea/.gitignore create mode 100644 browserApp/.idea/browserApp.iml create mode 100644 browserApp/.idea/inspectionProfiles/profiles_settings.xml create mode 100644 browserApp/.idea/misc.xml create mode 100644 browserApp/.idea/modules.xml create mode 100644 browserApp/.idea/vcs.xml diff --git a/browserApp/.idea/.gitignore b/browserApp/.idea/.gitignore new file mode 100644 index 0000000..e7e9d11 --- /dev/null +++ b/browserApp/.idea/.gitignore @@ -0,0 +1,2 @@ +# Default ignored files +/workspace.xml diff --git a/browserApp/.idea/browserApp.iml b/browserApp/.idea/browserApp.iml new file mode 100644 index 0000000..94483d8 --- /dev/null +++ b/browserApp/.idea/browserApp.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/browserApp/.idea/inspectionProfiles/profiles_settings.xml b/browserApp/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/browserApp/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/browserApp/.idea/misc.xml b/browserApp/.idea/misc.xml new file mode 100644 index 0000000..e264df9 --- /dev/null +++ b/browserApp/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/browserApp/.idea/modules.xml b/browserApp/.idea/modules.xml new file mode 100644 index 0000000..cd0cd42 --- /dev/null +++ b/browserApp/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/browserApp/.idea/vcs.xml b/browserApp/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/browserApp/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/browserApp/app.py b/browserApp/app.py index 0db661e..7293bad 100644 --- a/browserApp/app.py +++ b/browserApp/app.py @@ -1,11 +1,16 @@ """ This is the main application that implments everything in an abstract way """ -from PyQt5 import QtWidgets, QtCore -import sys +try: + from PySide2 import QtWidgets, QtCore, QtGui +except: + from PyQt5 import QtWidgets, QtCore, QtGui -from browserApp import info_view, tree_browser, collection_view -from browserApp.model import file as model_file +import sys +# from browserApp import info_view, tree_browser, collection_view +# from browserApp.model import file as model_file +import info_view, tree_browser, collection_view +from model import file as model_file class App(QtWidgets.QMainWindow): @@ -43,15 +48,12 @@ def __init__(self, top_item, parent=None): self.show() - def itemSelected(self, model_info): """ User as clicked on an item in the tree, pass item's data to the info view """ self.info.populate(model_info) self.collection.populate(model_info) - - def show_app(top_item): app = QtWidgets.QApplication(sys.argv) win = App(top_item) @@ -59,6 +61,7 @@ def show_app(top_item): if __name__ == "__main__": - top_item = model_file.FileItem("/Volumes/T7/GhostKid") + # top_item = model_file.FileItem("/Volumes/T7/GhostKid") + top_item = model_file.FileItem("D:\Programming\AlsSchool_Backup\class_3") - show_app(top_item) \ No newline at end of file + show_app(top_item) diff --git a/browserApp/collection_view.py b/browserApp/collection_view.py index f443b8f..fde3e99 100644 --- a/browserApp/collection_view.py +++ b/browserApp/collection_view.py @@ -1,6 +1,9 @@ """ This will show detailed information about an item """ -from PyQt5 import QtWidgets +try: + from PySide2 import QtWidgets, QtCore, QtGui +except: + from PyQt5 import QtWidgets, QtCore, QtGui class CollectionView(QtWidgets.QWidget): @@ -35,28 +38,28 @@ def populate(self, items): self.label.setText("Total Size: %d" % file_size) - # keys = set() - # - # integer_values = {} - # - # for i in range(child_count): - # subitem = items.get_child(i) - # meta = subitem.get_info() - # keys.update(meta.keys()) - # - # for i in range(child_count): - # subitem = items.get_child(i) - # meta = subitem.get_info() - # - # for key in keys: - # value = meta[key] - # if isinstance(value, int): - # if key not in integer_values: - # integer_values[key] = 0 - # integer_values[key] += value - # - # - # print(integer_values) + keys = set() + + integer_values = {} + + for i in range(child_count): + subitem = items.get_child(i) + meta = subitem.get_info() + keys.update(meta.keys()) + + for i in range(child_count): + subitem = items.get_child(i) + meta = subitem.get_info() + + for key in keys: + value = meta[key] + if isinstance(value, int): + if key not in integer_values: + integer_values[key] = 0 + integer_values[key] += value + + + print(integer_values) diff --git a/browserApp/file_app.py b/browserApp/file_app.py index 0fdc6d6..3e19089 100644 --- a/browserApp/file_app.py +++ b/browserApp/file_app.py @@ -10,4 +10,4 @@ def start_app(): if __name__ == "__main__": - start_app() \ No newline at end of file + start_app() diff --git a/browserApp/info_view.py b/browserApp/info_view.py index 6c60d43..f82f56d 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -1,35 +1,152 @@ """ This will show detailed information about an item """ -from PyQt5 import QtWidgets +try: + from PySide2 import QtWidgets, QtCore, QtGui +except: + from PyQt5 import QtWidgets, QtCore, QtGui +from os import listdir, open, O_RDWR +from os.path import isfile, join +from functools import partial class InfoView(QtWidgets.QWidget): def __init__(self, parent=None): super(InfoView, self).__init__(parent) - layout = QtWidgets.QHBoxLayout() + layout = QtWidgets.QVBoxLayout() - self.table = QtWidgets.QTreeWidget() - self.table.setColumnCount(2) + self.icons_widget = QtWidgets.QWidget() + self.icons_layout = QtWidgets.QGridLayout() + + self.list_table = QtWidgets.QWidget() + self.list_layout = QtWidgets.QGridLayout() + self.list_table.setLayout(self.list_layout) + self.list_layout.setAlignment(QtCore.Qt.AlignTop) + self.file_name_header = QtWidgets.QLabel("File:") + self.size_header = QtWidgets.QLabel("Size:") + self.type_header = QtWidgets.QLabel("Type:") + self.created_header = QtWidgets.QLabel("Created:") + self.list_layout.addWidget(self.file_name_header, 0, 0) + self.list_layout.addWidget(self.size_header, 0, 1) + self.list_layout.addWidget(self.type_header, 0, 2) + self.list_layout.addWidget(self.created_header, 0, 3) + + self.list_view_button = QtWidgets.QPushButton("List View") + self.icons_view_button = QtWidgets.QPushButton("Icon View") + self.top_bar_layout = QtWidgets.QHBoxLayout() + self.top_bar_layout.addWidget(self.list_view_button) + self.top_bar_layout.addWidget(self.icons_view_button) + + self.list_view_button.clicked.connect(self.setup_list_view) + self.icons_view_button.clicked.connect(self.setup_icons_view) + + self.stacked_pages = QtWidgets.QStackedWidget() + self.stacked_pages.addWidget(self.list_table) + self.stacked_pages.addWidget(self.icons_widget) + + self.stacked_pages.setCurrentWidget(self.list_table) layout.addSpacing(1) - layout.addWidget(self.table) + layout.addLayout(self.top_bar_layout) + layout.addWidget(self.stacked_pages) layout.addSpacing(1) + self.column_index = 0 + self.row_index = 0 self.setLayout(layout) def populate(self, data): - print("Do something useful with: " + str(data)) + # print("Do something useful with: " + str(data)) - # Clear the table - self.table.clear() + # Clear the list_table + self.clear_layout(self.list_layout) + self.stacked_pages.setCurrentWidget(self.list_table) - # Update the table with name : value from the get_info() dict + # If item clicked on is a file, display info in list_table. Else, display buttons with file names. meta = data.get_info() - for k, v in meta.items(): - item = QtWidgets.QTreeWidgetItem([k,v]) - self.table.addTopLevelItem(item) + if 'type' in meta: + # File: + if meta['type'] == 'File': + pass + # for k, v in meta.items(): + # item = QtWidgets.QTreeWidgetItem([k, v]) + # self.details_list_table.addTopLevelItem(item) + + # Directory: + elif meta['type'] == 'Dir': + self.stacked_pages.setCurrentWidget(self.icons_widget) + self.icons_widget.setLayout(self.icons_layout) + + self.clear_layout(self.icons_layout) + self.icons_layout.setAlignment(QtCore.Qt.AlignTop) + + # Request addition of 'file_name' to data model + if 'full_path' in meta: + dir_path = meta['full_path'] + files = [f for f in listdir(dir_path) if isfile(join(dir_path, f))] + buttons_list = [] + print(files) + + for file in files: + file_name = file.split('.')[0] + exec("%s_button = QtWidgets.QPushButton('%s')" % (file_name, file_name)) + exec("buttons_list.append(%s_button)" % file_name) + + self.icons_layout.addWidget(buttons_list[-1], self.row_index, self.column_index) + self.update_grid_positions() + self.set_button_style(buttons_list[-1]) + + # signal-slot connection for file buttons: + buttons_list[-1].clicked.connect(partial(self.on_item_clicked, dir_path + "\\" + file)) + + # file_name = os.path.basename(meta['full_path']) + + def on_item_clicked(self, file): # request to add the parameter in stub + print(file) + open(file, O_RDWR) + + def clear_layout(self, icons_layout): # request to add the method in stub + # clear all buttons + while self.icons_layout.count(): + child = self.icons_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + # file buttons + self.column_index = 0 + self.row_index = 0 + + def update_grid_positions(self): + self.column_index += 1 + if self.column_index == 3: # go to next row + self.row_index += 1 + self.column_index = 0 + + def set_button_style(self, button): + button.setFixedSize(100, 30) + # button.setStyleSheet("background-color : #FFF3F3") + button.setStyleSheet("border - width: 0px") + button.setStyleSheet("background : none") + # button.setAlignment(QtCore.Qt.AlignCenter) + button.setStyleSheet("QPushButton::hover" + "{" + "background-color : lightblue;" + "}") + + def setup_list_view(self): + self.stacked_pages.setCurrentWidget(self.list_table) + + def setup_icons_view(self): + self.stacked_pages.setCurrentWidget(self.icons_widget) + + + + + + + + + diff --git a/browserApp/model/file.py b/browserApp/model/file.py index d83fcea..fdffa94 100644 --- a/browserApp/model/file.py +++ b/browserApp/model/file.py @@ -1,4 +1,5 @@ -from browserApp.model.base import BaseItem +# from browserApp.model.base import BaseItem +from model.base import BaseItem import os diff --git a/browserApp/tree_browser.py b/browserApp/tree_browser.py index e9296f7..2b3842a 100644 --- a/browserApp/tree_browser.py +++ b/browserApp/tree_browser.py @@ -7,8 +7,8 @@ from PyQt5 import QtWidgets, QtCore, QtGui create_signal = QtCore.pyqtSignal -from browserApp.model import base - +# from browserApp.model import base +from model import base class TreeBrowser(QtWidgets.QTreeWidget): From 73955f33262e36f162bd9a26b928b1cd2bfdc0d4 Mon Sep 17 00:00:00 2001 From: Alastair Macleod mini home Date: Sun, 18 Oct 2020 12:31:47 -0700 Subject: [PATCH 02/15] bug fixes for buttons --- browserApp/app.py | 4 ++-- browserApp/info_view.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/browserApp/app.py b/browserApp/app.py index 84b3726..68eed2c 100644 --- a/browserApp/app.py +++ b/browserApp/app.py @@ -63,7 +63,7 @@ def show_app(top_item): if __name__ == "__main__": - # top_item = model_file.FileItem("/Volumes/T7/GhostKid") - top_item = model_file.FileItem("D:\Programming\AlsSchool_Backup\class_3") + top_item = model_file.FileItem("/Volumes/T7/GhostKid") + #top_item = model_file.FileItem("D:\Programming\AlsSchool_Backup\class_3") show_app(top_item) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index 6a6875e..094ab49 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -56,6 +56,8 @@ def __init__(self, parent=None): self.column_index = 0 self.row_index = 0 + self.buttons = {} + self.setLayout(layout) def populate(self, data): @@ -87,26 +89,27 @@ def populate(self, data): if 'full_path' in meta: dir_path = meta['full_path'] files = [f for f in listdir(dir_path) if isfile(join(dir_path, f))] - buttons_list = [] + self.buttons.clear() print(files) for file in files: file_name = file.split('.')[0] - exec("%s_button = QtWidgets.QPushButton('%s')" % (file_name, file_name)) - exec("buttons_list.append(%s_button)" % file_name) + button = QtWidgets.QPushButton(file_name) + self.buttons[file_name] = button - self.icons_layout.addWidget(buttons_list[-1], self.row_index, self.column_index) + self.icons_layout.addWidget(button, self.row_index, self.column_index) self.update_grid_positions() - self.set_button_style(buttons_list[-1]) + self.set_button_style(button) # signal-slot connection for file buttons: - buttons_list[-1].clicked.connect(partial(self.on_item_clicked, dir_path + "\\" + file)) + button.clicked.connect(partial(self.on_item_clicked, dir_path + "\\" + file)) # file_name = os.path.basename(meta['full_path']) def on_item_clicked(self, file): # request to add the parameter in stub - print(file) - open(file, O_RDWR) + + print("Do something with: " + str(file)) + def clear_layout(self, icons_layout): # request to add the method in stub # clear all buttons From 86225f65f15ac4db9d8e31e4e264b2eec2ccb0b3 Mon Sep 17 00:00:00 2001 From: megmugur Date: Sun, 25 Oct 2020 09:35:45 -0700 Subject: [PATCH 03/15] added file open feature, added icons, better icon layout --- browserApp/info_view.py | 130 ++++++++++++++++++++++++++-------------- 1 file changed, 86 insertions(+), 44 deletions(-) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index f82f56d..59d3d2e 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -1,4 +1,5 @@ """ This will show detailed information about an item """ +import os try: from PySide2 import QtWidgets, QtCore, QtGui @@ -13,14 +14,19 @@ class InfoView(QtWidgets.QWidget): def __init__(self, parent=None): super(InfoView, self).__init__(parent) + self.icon_image_path = "/images/folder_icon.png" + layout = QtWidgets.QVBoxLayout() self.icons_widget = QtWidgets.QWidget() self.icons_layout = QtWidgets.QGridLayout() + self.buttons_dict = {} - self.list_table = QtWidgets.QWidget() + self.list_widget = QtWidgets.QWidget() self.list_layout = QtWidgets.QGridLayout() - self.list_table.setLayout(self.list_layout) + self.list_grid_column_index = 0 + self.list_grid_row_index = 1 + # self.list_widget.setLayout(self.list_layout) self.list_layout.setAlignment(QtCore.Qt.AlignTop) self.file_name_header = QtWidgets.QLabel("File:") self.size_header = QtWidgets.QLabel("Size:") @@ -41,10 +47,10 @@ def __init__(self, parent=None): self.icons_view_button.clicked.connect(self.setup_icons_view) self.stacked_pages = QtWidgets.QStackedWidget() - self.stacked_pages.addWidget(self.list_table) + self.stacked_pages.addWidget(self.list_widget) self.stacked_pages.addWidget(self.icons_widget) - self.stacked_pages.setCurrentWidget(self.list_table) + self.stacked_pages.setCurrentWidget(self.icons_widget) layout.addSpacing(1) layout.addLayout(self.top_bar_layout) @@ -53,58 +59,92 @@ def __init__(self, parent=None): self.column_index = 0 self.row_index = 0 - + self.data = {} self.setLayout(layout) def populate(self, data): - # print("Do something useful with: " + str(data)) + self.data = data # Clear the list_table self.clear_layout(self.list_layout) - self.stacked_pages.setCurrentWidget(self.list_table) # If item clicked on is a file, display info in list_table. Else, display buttons with file names. meta = data.get_info() + self.list_grid_row_index += 1 + if 'full_path' in meta: + item_path = meta['full_path'] + print("full_path is : ", item_path) + else: + print("Could not find file path in dictionary") + return + if 'type' in meta: # File: - if meta['type'] == 'File': - pass - # for k, v in meta.items(): - # item = QtWidgets.QTreeWidgetItem([k, v]) - # self.details_list_table.addTopLevelItem(item) + if meta['type'] == 'File': # If we remove files from the collection view, we can delete this check. + self.process_file(meta, item_path, item_path) # Directory: elif meta['type'] == 'Dir': - self.stacked_pages.setCurrentWidget(self.icons_widget) - self.icons_widget.setLayout(self.icons_layout) - self.clear_layout(self.icons_layout) self.icons_layout.setAlignment(QtCore.Qt.AlignTop) # Request addition of 'file_name' to data model - if 'full_path' in meta: - dir_path = meta['full_path'] - files = [f for f in listdir(dir_path) if isfile(join(dir_path, f))] - buttons_list = [] - print(files) - for file in files: - file_name = file.split('.')[0] - exec("%s_button = QtWidgets.QPushButton('%s')" % (file_name, file_name)) - exec("buttons_list.append(%s_button)" % file_name) + files = [f for f in listdir(item_path) if isfile(join(item_path, f))] + buttons_list = [] + print(files) + + for file in files: + print("file: ", file) + self.process_file(meta, file, item_path) + else: + print("Could not find file type in dictionary") + + def process_file(self, meta, file, file_path): + + # LIST VIEW + self.list_widget.setLayout(self.list_layout) + self.list_grid_row_index += 1 + if 'file_size' in meta: + file_size = meta['file_size'] + size_label = QtWidgets.QLabel("0.0 MB") + self.list_grid_column_index = 1 + self.list_layout.addWidget(size_label, 1, 1) + + # ICON VIEW + self.icons_widget.setLayout(self.icons_layout) + self.icons_layout.setSpacing(0) + file_name = file.split('.')[0] + button = QtWidgets.QPushButton(file_name) + + self.buttons_dict[file_name] = button - self.icons_layout.addWidget(buttons_list[-1], self.row_index, self.column_index) - self.update_grid_positions() - self.set_button_style(buttons_list[-1]) + self.icons_layout.addWidget(button, self.row_index, self.column_index) + self.update_grid_positions() + self.set_button_style(button, self.icon_image_path) - # signal-slot connection for file buttons: - buttons_list[-1].clicked.connect(partial(self.on_item_clicked, dir_path + "\\" + file)) + if "." not in file_path: + path = file_path + "\\" + file + else: + path = file_path + # signal-slot connection for file buttons: + button.clicked.connect(partial(self.on_item_clicked, path)) - # file_name = os.path.basename(meta['full_path']) + file_info = QtCore.QFileInfo(path) + icon_provider = QtWidgets.QFileIconProvider() + icon = icon_provider.icon(file_info) - def on_item_clicked(self, file): # request to add the parameter in stub + button.setIcon(icon) + + # file_name = os.path.basename(meta['full_path']) + + def on_item_clicked(self, file): # Does not work if there are any spaces in the file name print(file) - open(file, O_RDWR) + os.system("start " + file) + if " " in file: + print("Cannot process file names with spaces, yet.") + return + def clear_layout(self, icons_layout): # request to add the method in stub # clear all buttons @@ -118,23 +158,22 @@ def clear_layout(self, icons_layout): # request to add the method in stub def update_grid_positions(self): self.column_index += 1 - if self.column_index == 3: # go to next row + if self.column_index == 2: # go to next row self.row_index += 1 self.column_index = 0 - def set_button_style(self, button): - button.setFixedSize(100, 30) - # button.setStyleSheet("background-color : #FFF3F3") - button.setStyleSheet("border - width: 0px") - button.setStyleSheet("background : none") - # button.setAlignment(QtCore.Qt.AlignCenter) - button.setStyleSheet("QPushButton::hover" - "{" - "background-color : lightblue;" - "}") + def set_button_style(self, button, icon_path): + button.setFixedSize(250, 25) + # button.setIcon(QtGui.QIcon(icon_path)) + + button.setStyleSheet("""QPushButton {text-align:left; background-color: none; border: none; } + QPushButton:hover { background-color: #CBE1F5 } + QPushButton:pressed { border-width: 5px; background-color: #B7D9F9 }""") + def setup_list_view(self): - self.stacked_pages.setCurrentWidget(self.list_table) + self.stacked_pages.setCurrentWidget(self.list_widget) + def setup_icons_view(self): self.stacked_pages.setCurrentWidget(self.icons_widget) @@ -151,3 +190,6 @@ def setup_icons_view(self): + + + From 44264df9c090b671205234155ca30b05ba7ac87d Mon Sep 17 00:00:00 2001 From: megmugur Date: Sun, 25 Oct 2020 09:37:32 -0700 Subject: [PATCH 04/15] added file open feature, added icons, better icon layout --- browserApp/info_view.py | 1 - 1 file changed, 1 deletion(-) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index 59d3d2e..5f07dc6 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -91,7 +91,6 @@ def populate(self, data): # Request addition of 'file_name' to data model files = [f for f in listdir(item_path) if isfile(join(item_path, f))] - buttons_list = [] print(files) for file in files: From 94eae7c54ff1150ffa4136fb54827d3db3257b9c Mon Sep 17 00:00:00 2001 From: megmugur Date: Sun, 25 Oct 2020 22:59:18 -0700 Subject: [PATCH 05/15] WIP: added details view with size, type, date, and file open feature --- browserApp/app.py | 4 +- browserApp/collection_view.py | 3 +- browserApp/info_view.py | 187 +++++++++++++++++++++------------- 3 files changed, 121 insertions(+), 73 deletions(-) diff --git a/browserApp/app.py b/browserApp/app.py index 68eed2c..84b3726 100644 --- a/browserApp/app.py +++ b/browserApp/app.py @@ -63,7 +63,7 @@ def show_app(top_item): if __name__ == "__main__": - top_item = model_file.FileItem("/Volumes/T7/GhostKid") - #top_item = model_file.FileItem("D:\Programming\AlsSchool_Backup\class_3") + # top_item = model_file.FileItem("/Volumes/T7/GhostKid") + top_item = model_file.FileItem("D:\Programming\AlsSchool_Backup\class_3") show_app(top_item) diff --git a/browserApp/collection_view.py b/browserApp/collection_view.py index 4fd5142..1033b7a 100644 --- a/browserApp/collection_view.py +++ b/browserApp/collection_view.py @@ -23,11 +23,12 @@ def __init__(self, parent=None): self.setLayout(layout) def populate(self, items): + return #self.label.clear() for label in self.labels: label.clear() self.layout().removeWidget(label) - self.labels.clear() + # self.labels.clear() child_count = items.children() if child_count == 0: diff --git a/browserApp/info_view.py b/browserApp/info_view.py index 53f86b7..467a1fa 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -1,5 +1,5 @@ """ This will show detailed information about an item """ -import os +import os, time try: from PySide2 import QtWidgets, QtCore @@ -8,10 +8,11 @@ from PyQt5 import QtWidgets, QtCore create_signal = QtCore.pyqtSignal -from os import listdir, open, O_RDWR +from os import listdir from os.path import isfile, join from functools import partial + class InfoView(QtWidgets.QWidget): def __init__(self, parent=None): super(InfoView, self).__init__(parent) @@ -24,23 +25,10 @@ def __init__(self, parent=None): self.icons_layout = QtWidgets.QGridLayout() self.buttons_dict = {} - self.list_widget = QtWidgets.QWidget() - self.list_layout = QtWidgets.QGridLayout() - self.list_grid_column_index = 0 - self.list_grid_row_index = 1 - # self.list_widget.setLayout(self.list_layout) - self.list_layout.setAlignment(QtCore.Qt.AlignTop) - self.file_name_header = QtWidgets.QLabel("File:") - self.size_header = QtWidgets.QLabel("Size:") - self.type_header = QtWidgets.QLabel("Type:") - self.created_header = QtWidgets.QLabel("Created:") - self.list_layout.addWidget(self.file_name_header, 0, 0) - self.list_layout.addWidget(self.size_header, 0, 1) - self.list_layout.addWidget(self.type_header, 0, 2) - self.list_layout.addWidget(self.created_header, 0, 3) - - self.list_view_button = QtWidgets.QPushButton("List View") - self.icons_view_button = QtWidgets.QPushButton("Icon View") + self.details_widget = QtWidgets.QTableWidget() + + self.list_view_button = QtWidgets.QPushButton("Details View") + self.icons_view_button = QtWidgets.QPushButton("Icons View") self.top_bar_layout = QtWidgets.QHBoxLayout() self.top_bar_layout.addWidget(self.list_view_button) self.top_bar_layout.addWidget(self.icons_view_button) @@ -49,7 +37,7 @@ def __init__(self, parent=None): self.icons_view_button.clicked.connect(self.setup_icons_view) self.stacked_pages = QtWidgets.QStackedWidget() - self.stacked_pages.addWidget(self.list_widget) + self.stacked_pages.addWidget(self.details_widget) self.stacked_pages.addWidget(self.icons_widget) self.stacked_pages.setCurrentWidget(self.icons_widget) @@ -59,62 +47,67 @@ def __init__(self, parent=None): layout.addWidget(self.stacked_pages) layout.addSpacing(1) - self.column_index = 0 - self.row_index = 0 + + self.details_header_list = ["Name", "Date", "Type", "Size"] + self.icons_column_index = 0 + self.icons_row_index = 0 + self.details_row_index = 0 + self.details_column_index = 0 self.data = {} - self.buttons = {} + self.icons_filename_buttons = {} + self.details_filename_buttons = {} self.setLayout(layout) def populate(self, data): self.data = data - # Clear the list_table - self.clear_layout(self.list_layout) - - # If item clicked on is a file, display info in list_table. Else, display buttons with file names. meta = data.get_info() - self.list_grid_row_index += 1 + print("Meta: ", meta) if 'full_path' in meta: item_path = meta['full_path'] - print("full_path is : ", item_path) else: print("Could not find file path in dictionary") return + # Clear List View: + self.clear_details_widget(self.details_widget) + + # Clear Icons View: + self.clear_icons_layout(self.icons_layout) + if 'type' in meta: # File: if meta['type'] == 'File': # If we remove files from the collection view, we can delete this check. - self.process_file(meta, item_path, item_path) + self.process_details_view(item_path, item_path) + self.process_icons_view(item_path, item_path) + + self.clear_icons_layout(self.icons_layout) + self.icons_layout.setAlignment(QtCore.Qt.AlignTop) # Directory: elif meta['type'] == 'Dir': - self.clear_layout(self.icons_layout) + self.icons_layout.setAlignment(QtCore.Qt.AlignTop) # Request addition of 'file_name' to data model - files = [f for f in listdir(item_path) if isfile(join(item_path, f))] print(files) - + self.reset_row_column_counts() for file in files: print("file: ", file) - self.process_file(meta, file, item_path) + self.process_details_view(file, item_path) + self.process_icons_view(file, item_path) else: print("Could not find file type in dictionary") - def process_file(self, meta, file, file_path): - - # LIST VIEW - self.list_widget.setLayout(self.list_layout) - self.list_grid_row_index += 1 - if 'file_size' in meta: - file_size = meta['file_size'] - size_label = QtWidgets.QLabel("0.0 MB") - self.list_grid_column_index = 1 - self.list_layout.addWidget(size_label, 1, 1) + def reset_row_column_counts(self): + self.details_row_index = 0 + self.details_column_index = 0 + self.icons_column_index = 0 + self.icons_row_index = 0 - # ICON VIEW + def process_icons_view(self, file, file_path): self.icons_widget.setLayout(self.icons_layout) self.icons_layout.setSpacing(0) file_name = file.split('.')[0] @@ -122,67 +115,121 @@ def process_file(self, meta, file, file_path): self.buttons_dict[file_name] = button - self.icons_layout.addWidget(button, self.row_index, self.column_index) + self.icons_layout.addWidget(button, self.icons_row_index, self.icons_column_index) self.update_grid_positions() - self.set_button_style(button, self.icon_image_path) + self.set_button_style(button) if "." not in file_path: path = file_path + "\\" + file else: path = file_path - # signal-slot connection for file buttons: + # signal-slot connection for file icons_filename_buttons: button.clicked.connect(partial(self.on_item_clicked, path)) + self.add_icon_to_button(button, path) + def add_icon_to_button(self, button, path): file_info = QtCore.QFileInfo(path) icon_provider = QtWidgets.QFileIconProvider() icon = icon_provider.icon(file_info) - button.setIcon(icon) - # file_name = os.path.basename(meta['full_path']) - - def on_item_clicked(self, file): # Does not work if there are any spaces in the file name - print(file) - os.system("start " + file) - if " " in file: + def on_item_clicked(self, file_path): # Does not work if there are any spaces in the file name + print("Opening", file_path, "...") + if " " in file_path: print("Cannot process file names with spaces, yet.") return + os.system("start " + file_path) + + def size_in_str(self, size_float): + if size_float < 1000: + return str(size_float) + " B" + elif size_float < 10 ** 6: + return str(size_float / (10 ** 3)) + " KB" + elif size_float < 10 ** 9: + return str(size_float/(10 ** 6)) + " MB" + else: + return str(size_float/(10 ** 9)) + " GB" + def clear_details_widget(self, table): + table.clearContents() - - def clear_layout(self, icons_layout): # request to add the method in stub - # clear all buttons - while self.icons_layout.count(): - child = self.icons_layout.takeAt(0) + def clear_icons_layout(self, icons_layout): + # clear all icons_filename_buttons + while icons_layout.count(): + child = icons_layout.takeAt(0) if child.widget(): child.widget().deleteLater() - # file buttons - self.column_index = 0 - self.row_index = 0 + # file icons_filename_buttons + self.icons_column_index = 0 + self.icons_row_index = 0 def update_grid_positions(self): - self.column_index += 1 - if self.column_index == 2: # go to next row - self.row_index += 1 - self.column_index = 0 + self.icons_column_index += 1 + if self.icons_column_index == 2: # go to next row + self.icons_row_index += 1 + self.icons_column_index = 0 - def set_button_style(self, button, icon_path): + def set_button_style(self, button): button.setFixedSize(250, 25) # button.setIcon(QtGui.QIcon(icon_path)) - button.setStyleSheet("""QPushButton {text-align:left; background-color: none; border: none; } QPushButton:hover { background-color: #CBE1F5 } QPushButton:pressed { border-width: 5px; background-color: #B7D9F9 }""") def setup_list_view(self): - self.stacked_pages.setCurrentWidget(self.list_widget) + self.stacked_pages.setCurrentWidget(self.details_widget) def setup_icons_view(self): self.stacked_pages.setCurrentWidget(self.icons_widget) + def process_details_view(self, file_name, file_path): + if self.details_row_index == 0: + self.details_widget.setRowCount(self.details_row_index) + self.details_widget.setColumnCount(len(self.details_header_list)) + self.details_widget.setHorizontalHeaderLabels(self.details_header_list) + + full_path = file_path + "\\" + file_name + self.details_widget.insertRow(self.details_widget.rowCount()) + + # column : filename button + button = self.create_details_filename_buttons(file_name, full_path) + self.details_widget.setCellWidget(self.details_widget.rowCount()-1, 0, button) + + # column : date and time modified + date_modified = time.ctime(os.path.getmtime(full_path)) # request this from data model + self.details_widget.setItem(self.details_widget.rowCount()-1, 1, QtWidgets.QTableWidgetItem(str(date_modified))) + + # column : file type + file_type = os.path.splitext(full_path)[1].strip('.').upper() + " File" # request this from data model + self.details_widget.setItem(self.details_widget.rowCount()-1, 2, QtWidgets.QTableWidgetItem(file_type)) + + # column : size + file_size = self.size_in_str(os.stat(full_path).st_size) # request this from data model + self.details_widget.setItem(self.details_widget.rowCount() - 1, 3, QtWidgets.QTableWidgetItem(file_size)) + + self.details_widget.setRowHeight(self.details_widget.rowCount() - 1, 25) + self.add_icon_to_button(button, full_path) + self.details_row_index += 1 + + self.details_table_style() + + def details_table_style(self): + self.details_widget.setShowGrid(False) + self.details_widget.verticalHeader().setVisible(False) + self.details_widget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) # Todo: Make filenames editable + self.details_widget.resizeColumnsToContents() + + def create_details_filename_buttons(self, button_name, file_path): + button = QtWidgets.QPushButton(button_name) + button.clicked.connect(partial(self.on_item_clicked, file_path)) + self.set_button_style(button) + return button + + + From 3797cca5b0b627f59411a7742089cd29271e68fd Mon Sep 17 00:00:00 2001 From: megmugur Date: Sat, 31 Oct 2020 07:11:01 -0700 Subject: [PATCH 06/15] wip adding folders --- browserApp/info_view.py | 91 +++++++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 31 deletions(-) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index 467a1fa..ec31cbf 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -1,5 +1,6 @@ -""" This will show detailed information about an item """ -import os, time +""" This will show detailed information about an item. +Assumptions made: file and folder names doe not contain spaces or dots. Only one dot, before extension.""" + try: from PySide2 import QtWidgets, QtCore @@ -8,7 +9,8 @@ from PyQt5 import QtWidgets, QtCore create_signal = QtCore.pyqtSignal -from os import listdir +import time +from os import listdir, system, path, stat from os.path import isfile, join from functools import partial @@ -47,7 +49,6 @@ def __init__(self, parent=None): layout.addWidget(self.stacked_pages) layout.addSpacing(1) - self.details_header_list = ["Name", "Date", "Type", "Size"] self.icons_column_index = 0 self.icons_row_index = 0 @@ -58,6 +59,7 @@ def __init__(self, parent=None): self.details_filename_buttons = {} self.setLayout(layout) + self.setMinimumWidth(520) def populate(self, data): self.data = data @@ -79,25 +81,40 @@ def populate(self, data): if 'type' in meta: # File: if meta['type'] == 'File': # If we remove files from the collection view, we can delete this check. - self.process_details_view(item_path, item_path) - self.process_icons_view(item_path, item_path) - - self.clear_icons_layout(self.icons_layout) - self.icons_layout.setAlignment(QtCore.Qt.AlignTop) + pass + # self.process_details_view(item_path, item_path) + # self.process_icons_view(item_path, item_path) + # + # self.clear_icons_layout(self.icons_layout) + # self.icons_layout.setAlignment(QtCore.Qt.AlignTop) # Directory: elif meta['type'] == 'Dir': self.icons_layout.setAlignment(QtCore.Qt.AlignTop) - + files = [] + directories = [] # Request addition of 'file_name' to data model - files = [f for f in listdir(item_path) if isfile(join(item_path, f))] - print(files) + files_and_folders = listdir(item_path) + for item in files_and_folders: + if isfile(join(item_path, item)): + files.append(item) + else: + directories.append(item) + + print("Files: ", files, "Folders:", directories) + + # process files self.reset_row_column_counts() - for file in files: - print("file: ", file) - self.process_details_view(file, item_path) - self.process_icons_view(file, item_path) + # for file in files: + # print("file: ", file) + # self.process_details_view(file, item_path) + # self.process_icons_view(file, item_path) + + for item in files_and_folders: + self.process_details_view(item, item_path) + self.process_icons_view(item, item_path) + else: print("Could not find file type in dictionary") @@ -120,26 +137,31 @@ def process_icons_view(self, file, file_path): self.set_button_style(button) if "." not in file_path: - path = file_path + "\\" + file + full_path = join(file_path, file) else: - path = file_path + full_path = file_path + # signal-slot connection for file icons_filename_buttons: - button.clicked.connect(partial(self.on_item_clicked, path)) + button.clicked.connect(partial(self.on_item_clicked, file, full_path)) - self.add_icon_to_button(button, path) + self.add_icon_to_button(button, full_path) - def add_icon_to_button(self, button, path): - file_info = QtCore.QFileInfo(path) + def add_icon_to_button(self, button, file_path): + file_info = QtCore.QFileInfo(str(file_path)) icon_provider = QtWidgets.QFileIconProvider() icon = icon_provider.icon(file_info) button.setIcon(icon) - def on_item_clicked(self, file_path): # Does not work if there are any spaces in the file name + def on_item_clicked(self, file_name, file_path): # ?? Does not work if there are any spaces in the file name print("Opening", file_path, "...") if " " in file_path: print("Cannot process file names with spaces, yet.") return - os.system("start " + file_path) + if isfile(file_name): + system("start " + file_path) + else: + print("found directory", file_name) + def size_in_str(self, size_float): if size_float < 1000: @@ -195,19 +217,19 @@ def process_details_view(self, file_name, file_path): self.details_widget.insertRow(self.details_widget.rowCount()) # column : filename button - button = self.create_details_filename_buttons(file_name, full_path) + button = self.create_details_filename_buttons(file_name, file_name, full_path) self.details_widget.setCellWidget(self.details_widget.rowCount()-1, 0, button) # column : date and time modified - date_modified = time.ctime(os.path.getmtime(full_path)) # request this from data model + date_modified = time.ctime(path.getmtime(full_path)) # request this from data model self.details_widget.setItem(self.details_widget.rowCount()-1, 1, QtWidgets.QTableWidgetItem(str(date_modified))) # column : file type - file_type = os.path.splitext(full_path)[1].strip('.').upper() + " File" # request this from data model + file_type = path.splitext(full_path)[1].strip('.').upper() + " File" # request this from data model self.details_widget.setItem(self.details_widget.rowCount()-1, 2, QtWidgets.QTableWidgetItem(file_type)) # column : size - file_size = self.size_in_str(os.stat(full_path).st_size) # request this from data model + file_size = self.size_in_str(stat(full_path).st_size) # request this from data model self.details_widget.setItem(self.details_widget.rowCount() - 1, 3, QtWidgets.QTableWidgetItem(file_size)) self.details_widget.setRowHeight(self.details_widget.rowCount() - 1, 25) @@ -221,10 +243,17 @@ def details_table_style(self): self.details_widget.verticalHeader().setVisible(False) self.details_widget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) # Todo: Make filenames editable self.details_widget.resizeColumnsToContents() - - def create_details_filename_buttons(self, button_name, file_path): + self.details_widget.setStyleSheet("QTableWidget {background-color: transparent; border: none}" + "QHeaderView::section {background-color: transparent;" + "border-right:1px solid gray;}" + "QHeaderView {background-color: transparent;" + "border-right: 1px solid gray;}" + "QTableCornerButton::section {background-color: transparent;}") + self.details_widget.horizontalHeader().setDefaultAlignment(QtCore.Qt.AlignLeft) # left align header text + + def create_details_filename_buttons(self, button_name, file_name, file_path): button = QtWidgets.QPushButton(button_name) - button.clicked.connect(partial(self.on_item_clicked, file_path)) + button.clicked.connect(partial(self.on_item_clicked, file_name, file_path)) self.set_button_style(button) return button From 290011c0021cead28202b1470e7a701c42eb4f2c Mon Sep 17 00:00:00 2001 From: megmugur Date: Sat, 31 Oct 2020 07:19:58 -0700 Subject: [PATCH 07/15] wip adding folders --- browserApp/info_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index ec31cbf..5d10b8a 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -157,7 +157,7 @@ def on_item_clicked(self, file_name, file_path): # ?? Does no if " " in file_path: print("Cannot process file names with spaces, yet.") return - if isfile(file_name): + if isfile(file_path): system("start " + file_path) else: print("found directory", file_name) From 5bec2bb9a36e0dbd97dd9fa013ea6c69fac57f6c Mon Sep 17 00:00:00 2001 From: megmugur Date: Sat, 31 Oct 2020 08:40:08 -0700 Subject: [PATCH 08/15] added folder open feature --- browserApp/info_view.py | 37 ++++++++++++++----------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index 5d10b8a..d715245 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -13,6 +13,7 @@ from os import listdir, system, path, stat from os.path import isfile, join from functools import partial +import model.file class InfoView(QtWidgets.QWidget): @@ -63,7 +64,6 @@ def __init__(self, parent=None): def populate(self, data): self.data = data - meta = data.get_info() print("Meta: ", meta) if 'full_path' in meta: @@ -82,11 +82,6 @@ def populate(self, data): # File: if meta['type'] == 'File': # If we remove files from the collection view, we can delete this check. pass - # self.process_details_view(item_path, item_path) - # self.process_icons_view(item_path, item_path) - # - # self.clear_icons_layout(self.icons_layout) - # self.icons_layout.setAlignment(QtCore.Qt.AlignTop) # Directory: elif meta['type'] == 'Dir': @@ -102,14 +97,7 @@ def populate(self, data): else: directories.append(item) - print("Files: ", files, "Folders:", directories) - - # process files self.reset_row_column_counts() - # for file in files: - # print("file: ", file) - # self.process_details_view(file, item_path) - # self.process_icons_view(file, item_path) for item in files_and_folders: self.process_details_view(item, item_path) @@ -152,7 +140,7 @@ def add_icon_to_button(self, button, file_path): icon = icon_provider.icon(file_info) button.setIcon(icon) - def on_item_clicked(self, file_name, file_path): # ?? Does not work if there are any spaces in the file name + def on_item_clicked(self, file_name, file_path): # Todo: Does not work if there are any spaces in the file name print("Opening", file_path, "...") if " " in file_path: print("Cannot process file names with spaces, yet.") @@ -160,8 +148,11 @@ def on_item_clicked(self, file_name, file_path): # ?? Does no if isfile(file_path): system("start " + file_path) else: - print("found directory", file_name) - + # if it's a folder, clear layouts, and populate with data inside that folder + self.clear_details_widget(self.details_widget) + self.clear_icons_layout(self.icons_layout) + file_item_object = model.file.FileItem(file_path) # create object of model.file > FileItem to generate data + self.populate(file_item_object) def size_in_str(self, size_float): if size_float < 1000: @@ -207,33 +198,33 @@ def setup_list_view(self): def setup_icons_view(self): self.stacked_pages.setCurrentWidget(self.icons_widget) - def process_details_view(self, file_name, file_path): + def process_details_view(self, file_name, folder_path): if self.details_row_index == 0: self.details_widget.setRowCount(self.details_row_index) self.details_widget.setColumnCount(len(self.details_header_list)) self.details_widget.setHorizontalHeaderLabels(self.details_header_list) - full_path = file_path + "\\" + file_name + file_path = str(join(folder_path, file_name)) self.details_widget.insertRow(self.details_widget.rowCount()) # column : filename button - button = self.create_details_filename_buttons(file_name, file_name, full_path) + button = self.create_details_filename_buttons(file_name, file_name, file_path) self.details_widget.setCellWidget(self.details_widget.rowCount()-1, 0, button) # column : date and time modified - date_modified = time.ctime(path.getmtime(full_path)) # request this from data model + date_modified = time.ctime(path.getmtime(file_path)) # request this from data model self.details_widget.setItem(self.details_widget.rowCount()-1, 1, QtWidgets.QTableWidgetItem(str(date_modified))) # column : file type - file_type = path.splitext(full_path)[1].strip('.').upper() + " File" # request this from data model + file_type = path.splitext(file_path)[1].strip('.').upper() + " File" # request this from data model self.details_widget.setItem(self.details_widget.rowCount()-1, 2, QtWidgets.QTableWidgetItem(file_type)) # column : size - file_size = self.size_in_str(stat(full_path).st_size) # request this from data model + file_size = self.size_in_str(stat(file_path).st_size) # request this from data model self.details_widget.setItem(self.details_widget.rowCount() - 1, 3, QtWidgets.QTableWidgetItem(file_size)) self.details_widget.setRowHeight(self.details_widget.rowCount() - 1, 25) - self.add_icon_to_button(button, full_path) + self.add_icon_to_button(button, file_path) self.details_row_index += 1 self.details_table_style() From 4118254f13e58a383064090c4dc56921dfc19635 Mon Sep 17 00:00:00 2001 From: megmugur Date: Sun, 1 Nov 2020 02:16:47 -0800 Subject: [PATCH 09/15] WIP: todo list at the top. Added documentation --- browserApp/app.py | 1 + browserApp/info_view.py | 307 +++++++++++++++++++++++++-------------- browserApp/model/file.py | 2 + 3 files changed, 203 insertions(+), 107 deletions(-) diff --git a/browserApp/app.py b/browserApp/app.py index 84b3726..2e79cdf 100644 --- a/browserApp/app.py +++ b/browserApp/app.py @@ -14,6 +14,7 @@ import info_view, tree_browser, collection_view from model import file as model_file + class App(QtWidgets.QMainWindow): """ This is the foo application, which should work with any kind of model data """ diff --git a/browserApp/info_view.py b/browserApp/info_view.py index d715245..98e9773 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -1,6 +1,18 @@ """ This will show detailed information about an item. Assumptions made: file and folder names doe not contain spaces or dots. Only one dot, before extension.""" +"""Todo: +1. Files should open only on double click. +2. Allow selection of multiple items. +3. Layout width is fixed to make it uniform. Make it scalable. +4. Allow folders and files with spaces in the name +5. Allow folders and files with dots in the name? Not sure. Maybe. +6. Add a small line between the header cells in the details table, so that the user can widen the columns if needed. +7. Remove bold font for selected column in header +8. Create buttons only for the selected View type, and not for all views. +9. Request more data from model. Refer to inline comments. +10. Add LMB click options. +""" try: from PySide2 import QtWidgets, QtCore @@ -9,7 +21,7 @@ from PyQt5 import QtWidgets, QtCore create_signal = QtCore.pyqtSignal -import time +import datetime from os import listdir, system, path, stat from os.path import isfile, join from functools import partial @@ -18,6 +30,7 @@ class InfoView(QtWidgets.QWidget): def __init__(self, parent=None): + """Initializes all necessary Qt widgets and variables.""" super(InfoView, self).__init__(parent) self.icon_image_path = "/images/folder_icon.png" @@ -26,7 +39,6 @@ def __init__(self, parent=None): self.icons_widget = QtWidgets.QWidget() self.icons_layout = QtWidgets.QGridLayout() - self.buttons_dict = {} self.details_widget = QtWidgets.QTableWidget() @@ -36,8 +48,8 @@ def __init__(self, parent=None): self.top_bar_layout.addWidget(self.list_view_button) self.top_bar_layout.addWidget(self.icons_view_button) - self.list_view_button.clicked.connect(self.setup_list_view) - self.icons_view_button.clicked.connect(self.setup_icons_view) + self.list_view_button.clicked.connect(partial(self.switch_page, self.details_widget)) + self.icons_view_button.clicked.connect(partial(self.switch_page, self.icons_widget)) self.stacked_pages = QtWidgets.QStackedWidget() self.stacked_pages.addWidget(self.details_widget) @@ -50,9 +62,11 @@ def __init__(self, parent=None): layout.addWidget(self.stacked_pages) layout.addSpacing(1) - self.details_header_list = ["Name", "Date", "Type", "Size"] self.icons_column_index = 0 self.icons_row_index = 0 + self.NUMBER_OF_GRID_COLUMNS = 2 + + self.details_header_list = ["Name", "Date", "Type", "Size"] # Is this a string constant?? self.details_row_index = 0 self.details_column_index = 0 self.data = {} @@ -63,6 +77,13 @@ def __init__(self, parent=None): self.setMinimumWidth(520) def populate(self, data): + """Populates the Display area of the window with a stacked widget. The stacked widgets contains two pages: + the Icons View page which shows the contents of the folder as a grid of buttons, and + the Details View page which shows a table of files(buttons) and their details, eg., file size, date modified. + Todo: Get file details from meta + :param data: object that contains the full path of the folder(or file), the list of files it contains, and some + methods to help process the data. + :type data: object of file.FileItem """ self.data = data meta = data.get_info() print("Meta: ", meta) @@ -73,10 +94,11 @@ def populate(self, data): return # Clear List View: - self.clear_details_widget(self.details_widget) + clear_table_widget(self.details_widget) # Clear Icons View: - self.clear_icons_layout(self.icons_layout) + clear_grid_layout(self.icons_layout) + self.reset_row_column_counts() if 'type' in meta: # File: @@ -85,151 +107,143 @@ def populate(self, data): # Directory: elif meta['type'] == 'Dir': - self.icons_layout.setAlignment(QtCore.Qt.AlignTop) files = [] directories = [] - # Request addition of 'file_name' to data model files_and_folders = listdir(item_path) + print(files_and_folders) for item in files_and_folders: - if isfile(join(item_path, item)): + if isfile(join(item_path, item)): # Request addition of 'file_name' to data model. files.append(item) else: directories.append(item) self.reset_row_column_counts() + self.icons_widget.setLayout(self.icons_layout) + self.icons_layout.setSpacing(0) # sets spacing between widgets in the layout to 0. + for item in files_and_folders: - self.process_details_view(item, item_path) - self.process_icons_view(item, item_path) + self.add_item_to_detailsview(item, item_path) + self.add_item_to_iconview(item, item_path) else: print("Could not find file type in dictionary") def reset_row_column_counts(self): + """ Resets the row and column indices to zero.""" self.details_row_index = 0 self.details_column_index = 0 self.icons_column_index = 0 self.icons_row_index = 0 - def process_icons_view(self, file, file_path): - self.icons_widget.setLayout(self.icons_layout) - self.icons_layout.setSpacing(0) - file_name = file.split('.')[0] - button = QtWidgets.QPushButton(file_name) - - self.buttons_dict[file_name] = button + def add_item_to_iconview(self, file, file_path): + """Creates a button, sets its style, and adds it to the Icons View page. Makes signal-slot connections. + The button displays an icon and the file name. + :param file: file name including extension. + :type file: str + :param file_path: full path to the file + :type file_path: str """ - self.icons_layout.addWidget(button, self.icons_row_index, self.icons_column_index) - self.update_grid_positions() - self.set_button_style(button) + file_name = file.split('.')[0] # removes the extension. + button = QtWidgets.QPushButton(file_name) + set_button_style(button) - if "." not in file_path: + if "." not in file_path: # Todo: use a different method. What if the file name contains a dot? full_path = join(file_path, file) else: full_path = file_path + add_icon_to_button(button, full_path) + button.clicked.connect(partial(self.on_item_clicked, full_path)) - # signal-slot connection for file icons_filename_buttons: - button.clicked.connect(partial(self.on_item_clicked, file, full_path)) - - self.add_icon_to_button(button, full_path) - - def add_icon_to_button(self, button, file_path): - file_info = QtCore.QFileInfo(str(file_path)) - icon_provider = QtWidgets.QFileIconProvider() - icon = icon_provider.icon(file_info) - button.setIcon(icon) - - def on_item_clicked(self, file_name, file_path): # Todo: Does not work if there are any spaces in the file name - print("Opening", file_path, "...") - if " " in file_path: - print("Cannot process file names with spaces, yet.") - return - if isfile(file_path): - system("start " + file_path) - else: - # if it's a folder, clear layouts, and populate with data inside that folder - self.clear_details_widget(self.details_widget) - self.clear_icons_layout(self.icons_layout) - file_item_object = model.file.FileItem(file_path) # create object of model.file > FileItem to generate data - self.populate(file_item_object) - - def size_in_str(self, size_float): - if size_float < 1000: - return str(size_float) + " B" - elif size_float < 10 ** 6: - return str(size_float / (10 ** 3)) + " KB" - elif size_float < 10 ** 9: - return str(size_float/(10 ** 6)) + " MB" - else: - return str(size_float/(10 ** 9)) + " GB" - - def clear_details_widget(self, table): - table.clearContents() - - def clear_icons_layout(self, icons_layout): - # clear all icons_filename_buttons - while icons_layout.count(): - child = icons_layout.takeAt(0) - if child.widget(): - child.widget().deleteLater() - # file icons_filename_buttons - self.icons_column_index = 0 - self.icons_row_index = 0 - - def update_grid_positions(self): - self.icons_column_index += 1 - if self.icons_column_index == 2: # go to next row - self.icons_row_index += 1 - self.icons_column_index = 0 - - def set_button_style(self, button): - button.setFixedSize(250, 25) - # button.setIcon(QtGui.QIcon(icon_path)) - button.setStyleSheet("""QPushButton {text-align:left; background-color: none; border: none; } - QPushButton:hover { background-color: #CBE1F5 } - QPushButton:pressed { border-width: 5px; background-color: #B7D9F9 }""") - - - def setup_list_view(self): - self.stacked_pages.setCurrentWidget(self.details_widget) - + self.icons_layout.addWidget(button, self.icons_row_index, self.icons_column_index) + self.increment_grid_position() - def setup_icons_view(self): - self.stacked_pages.setCurrentWidget(self.icons_widget) + def add_item_to_detailsview(self, file_name, folder_path): + """ Populates the details table widget with data. + If it is the first time it is being called, sets up the headers. + :param file_name: name of the file to be added to the button + :type file_name: str + :param folder_path: location of the folder that contains the file + :type folder_path: str + """ - def process_details_view(self, file_name, folder_path): if self.details_row_index == 0: - self.details_widget.setRowCount(self.details_row_index) - self.details_widget.setColumnCount(len(self.details_header_list)) - self.details_widget.setHorizontalHeaderLabels(self.details_header_list) + create_table_widget_header(self.details_widget, self.details_header_list) file_path = str(join(folder_path, file_name)) - self.details_widget.insertRow(self.details_widget.rowCount()) + self.details_widget.insertRow(self.details_widget.rowCount()) # insert a row + + item_data_object = model.file.FileItem(file_path) + item_data = item_data_object.get_info() # column : filename button - button = self.create_details_filename_buttons(file_name, file_name, file_path) + button = self.create_details_filename_buttons(file_name, file_path) self.details_widget.setCellWidget(self.details_widget.rowCount()-1, 0, button) - # column : date and time modified - date_modified = time.ctime(path.getmtime(file_path)) # request this from data model - self.details_widget.setItem(self.details_widget.rowCount()-1, 1, QtWidgets.QTableWidgetItem(str(date_modified))) + # column : date and time created + if 'created' in item_data: + date_created = datetime.datetime.fromtimestamp(float(item_data['created'])).strftime('%d/%m/%Y %H:%M') + # request this from data model in required format + self.details_widget.setItem(self.details_widget.rowCount()-1, 1, + QtWidgets.QTableWidgetItem(date_created)) # column : file type - file_type = path.splitext(file_path)[1].strip('.').upper() + " File" # request this from data model - self.details_widget.setItem(self.details_widget.rowCount()-1, 2, QtWidgets.QTableWidgetItem(file_type)) + if 'type' in item_data: + if item_data['type'] == 'File': + file_type = path.splitext(file_path)[1].strip('.').upper() + " File" # request this from data model + else: + file_type = "Folder" + self.details_widget.setItem(self.details_widget.rowCount() - 1, 2, QtWidgets.QTableWidgetItem(file_type)) # column : size - file_size = self.size_in_str(stat(file_path).st_size) # request this from data model - self.details_widget.setItem(self.details_widget.rowCount() - 1, 3, QtWidgets.QTableWidgetItem(file_size)) + if 'file_size' in item_data: + file_size = convert_filesize_to_str(item_data['file_size']) # request this for folders from data model + self.details_widget.setItem(self.details_widget.rowCount() - 1, 3, QtWidgets.QTableWidgetItem(file_size)) self.details_widget.setRowHeight(self.details_widget.rowCount() - 1, 25) - self.add_icon_to_button(button, file_path) + add_icon_to_button(button, file_path) self.details_row_index += 1 self.details_table_style() + def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name + """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with + contents of the folder that is clicked on. + :param file_path: full path to the file or folder button that is clicked on. + :type file_path: str""" + + print("Opening", file_path + "...") + if " " in file_path: + print("Cannot process file names with spaces, yet.") + return + if isfile(file_path): + system("start " + file_path) + else: + # if it's a folder, clear both pages, and populate with data inside that folder + clear_table_widget(self.details_widget) + clear_grid_layout(self.icons_layout) + file_item_object = model.file.FileItem(file_path) # to generate data, create object of model.file FileItem + self.populate(file_item_object) + + def increment_grid_position(self): + """Once a grid position is filled, this method is called, to point to the next position.""" + self.icons_column_index += 1 + if self.icons_column_index == self.NUMBER_OF_GRID_COLUMNS: # go to next row + self.icons_row_index += 1 + self.icons_column_index = 0 + + def switch_page(self, selected_widget): + """Switches current widget in the stack over to the selected widget. + This method has been created in case I need to change the functionality such that the buttons for a view are + created if, and only when, that view is selected. + :param selected_widget: The widget from the stack that needs to be set as the current widget. + :type selected_widget: QtWidget """ + self.stacked_pages.setCurrentWidget(selected_widget) + def details_table_style(self): + """Sets the style for the details table widget.""" + self.details_widget.setShowGrid(False) self.details_widget.verticalHeader().setVisible(False) self.details_widget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) # Todo: Make filenames editable @@ -242,13 +256,92 @@ def details_table_style(self): "QTableCornerButton::section {background-color: transparent;}") self.details_widget.horizontalHeader().setDefaultAlignment(QtCore.Qt.AlignLeft) # left align header text - def create_details_filename_buttons(self, button_name, file_name, file_path): + def create_details_filename_buttons(self, button_name, file_path): + """ Creates a button, and makes signal-slot connections for it. + :param button_name: name of the button. + :type button_name: str + :param file_path: full path to the file that is to be listed on the button. + :type file_path: str + :return button: button with the file name on it. + :rtype button: QPushButton widget + """ button = QtWidgets.QPushButton(button_name) - button.clicked.connect(partial(self.on_item_clicked, file_name, file_path)) - self.set_button_style(button) + button.clicked.connect(partial(self.on_item_clicked, file_path)) + set_button_style(button) return button +def create_table_widget_header(widget, header_items): + """ Creates the header for a table widget. + :param widget: the widget for which the header needs to be added. + :type widget: QTableWidget + :param header_items: List of headings for the table's columns + :rtype header_items: list of strings + """ + widget.setRowCount(0) + widget.setColumnCount(len(header_items)) + widget.setHorizontalHeaderLabels(header_items) + + +def add_icon_to_button(button, file_path): + """Adds an icon to the button that is the windows standard icon associated with that file. + :param button: button to which the icon is to be added + :type button: QPushButton + :param file_path: full path to the file whose icon is to be added to the button + :type file_path: str""" + + file_info = QtCore.QFileInfo(file_path) + icon_provider = QtWidgets.QFileIconProvider() + icon = icon_provider.icon(file_info) + button.setIcon(icon) + + +def clear_table_widget(table): + """ Clears the contents of the table. Keeps the headers intact. + :param table: table widget that needs to be cleared. + :type table: QtWidgets.QTableWidget + """ + table.clearContents() + + +def clear_grid_layout(grid_layout): + """ Deletes all the contents of a grid layout. + :param grid_layout: The layout the needs to be cleared. + :type grid_layout: QtWidgets.QGridLayout """ + + # clear all icons_filename_buttons + while grid_layout.count(): + child = grid_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + +def set_button_style(button): + """ Sets the button style and dimensions. No background or border by default. Turns blue with a border on hover. + :param button: the buttons with the file name and icon on them. + :type button: QWidgets.QPushButton """ + button.setFixedSize(250, 25) + button.setStyleSheet(""" QPushButton {text-align:left; background-color: none; border: none; } + QPushButton:hover { background-color: #CBE1F5 } + QPushButton:pressed { border-width: 5px; background-color: #B7D9F9 } """) + + +def convert_filesize_to_str(size_long): + """ Converts the file size from Bytes to KB, MB or GB. + :param size_long: file size in long float + :type size_long: long integer + :return string value: depending on what range the input fits in, returns the value in Kilo, Mega or Giga Bytes. + :rtype string value: str """ + + if size_long < 1000: + return str(size_long) + " B" + elif size_long < 10 ** 6: + return str(size_long / (10 ** 3)) + " KB" + elif size_long < 10 ** 9: + return str(size_long / (10 ** 6)) + " MB" + else: + return str(size_long / (10 ** 9)) + " GB" + diff --git a/browserApp/model/file.py b/browserApp/model/file.py index fdffa94..e170f14 100644 --- a/browserApp/model/file.py +++ b/browserApp/model/file.py @@ -51,7 +51,9 @@ def get_info(self): data['created'] = str(stat.st_ctime) if os.path.isdir(self.full_path): + # stat = os.stat(self.full_path) data['type'] = "Dir" + # data['created'] = str(stat.st_ctime) return data From 7695b22e54e4160cd80e2be3bda14fe6149a6817 Mon Sep 17 00:00:00 2001 From: megmugur Date: Sun, 8 Nov 2020 09:56:37 -0800 Subject: [PATCH 10/15] adding RMB context menu, wip --- browserApp/info_view.py | 171 +++++++++++++++++++++++++++++++--------- 1 file changed, 132 insertions(+), 39 deletions(-) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index 98e9773..623b77c 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -7,7 +7,7 @@ 3. Layout width is fixed to make it uniform. Make it scalable. 4. Allow folders and files with spaces in the name 5. Allow folders and files with dots in the name? Not sure. Maybe. -6. Add a small line between the header cells in the details table, so that the user can widen the columns if needed. +6. Add a separator in-between the header cells in the details table, so that the user can widen the columns if needed. 7. Remove bold font for selected column in header 8. Create buttons only for the selected View type, and not for all views. 9. Request more data from model. Refer to inline comments. @@ -15,14 +15,14 @@ """ try: - from PySide2 import QtWidgets, QtCore + from PySide2 import QtWidgets, QtCore, QtGui create_signal = QtCore.Signal except: - from PyQt5 import QtWidgets, QtCore + from PyQt5 import QtWidgets, QtCore, QtGui create_signal = QtCore.pyqtSignal import datetime -from os import listdir, system, path, stat +from os import listdir, system, path, remove, rename from os.path import isfile, join from functools import partial import model.file @@ -66,18 +66,19 @@ def __init__(self, parent=None): self.icons_row_index = 0 self.NUMBER_OF_GRID_COLUMNS = 2 - self.details_header_list = ["Name", "Date", "Type", "Size"] # Is this a string constant?? + self.details_header_list = ["Name", "Date", "Type", "Size"] # Is this a string constant list?? self.details_row_index = 0 self.details_column_index = 0 self.data = {} self.icons_filename_buttons = {} self.details_filename_buttons = {} + self.buttons_dictionary = {} self.setLayout(layout) self.setMinimumWidth(520) def populate(self, data): - """Populates the Display area of the window with a stacked widget. The stacked widgets contains two pages: + """ Populates the Display area of the window with a stacked widget. The stacked widgets contains two pages: the Icons View page which shows the contents of the folder as a grid of buttons, and the Details View page which shows a table of files(buttons) and their details, eg., file size, date modified. Todo: Get file details from meta @@ -87,11 +88,10 @@ def populate(self, data): self.data = data meta = data.get_info() print("Meta: ", meta) - if 'full_path' in meta: - item_path = meta['full_path'] - else: + if 'full_path' not in meta: print("Could not find file path in dictionary") return + item_path = meta['full_path'] # Clear List View: clear_table_widget(self.details_widget) @@ -99,39 +99,41 @@ def populate(self, data): # Clear Icons View: clear_grid_layout(self.icons_layout) self.reset_row_column_counts() + # self.add_view_options_buttons(self.icons_layout) + + if 'type' not in meta: + print("Could not find item type (file or folder) in dictionary.") + return + + # File: + if meta['type'] == 'File': # If we remove files from the collection view, we can delete this check. + return + + # Directory: + self.icons_layout.setAlignment(QtCore.Qt.AlignTop) + files = [] + directories = [] + files_and_folders = listdir(item_path) + print(files_and_folders) + for item in files_and_folders: + if isfile(join(item_path, item)): # Request addition of 'file_name' to data model. + files.append(item) + else: + directories.append(item) + + self.reset_row_column_counts() + + self.icons_widget.setLayout(self.icons_layout) + self.icons_layout.setSpacing(0) # sets spacing between widgets in the layout to 0. + + for item in files_and_folders: + self.add_item_to_detailsview(item, item_path) + self.add_item_to_iconview(item, item_path) - if 'type' in meta: - # File: - if meta['type'] == 'File': # If we remove files from the collection view, we can delete this check. - pass - - # Directory: - elif meta['type'] == 'Dir': - self.icons_layout.setAlignment(QtCore.Qt.AlignTop) - files = [] - directories = [] - files_and_folders = listdir(item_path) - print(files_and_folders) - for item in files_and_folders: - if isfile(join(item_path, item)): # Request addition of 'file_name' to data model. - files.append(item) - else: - directories.append(item) - - self.reset_row_column_counts() - - self.icons_widget.setLayout(self.icons_layout) - self.icons_layout.setSpacing(0) # sets spacing between widgets in the layout to 0. - - for item in files_and_folders: - self.add_item_to_detailsview(item, item_path) - self.add_item_to_iconview(item, item_path) - else: - print("Could not find file type in dictionary") def reset_row_column_counts(self): - """ Resets the row and column indices to zero.""" + """ Resets the row and column indices to zero. """ self.details_row_index = 0 self.details_column_index = 0 self.icons_column_index = 0 @@ -268,8 +270,74 @@ def create_details_filename_buttons(self, button_name, file_path): button = QtWidgets.QPushButton(button_name) button.clicked.connect(partial(self.on_item_clicked, file_path)) set_button_style(button) + + if not isfile(file_path): + return button + # set button context menu policy + button.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + button.customContextMenuRequested.connect(partial(self.show_rightclick_menu, button)) + self.buttons_dictionary[button] = file_path + return button + def show_rightclick_menu(self, button, point): + """ Displays a menu on right mouse button click. + :param button: button user clicks on. + :type button: QPushButton + :param point: ?? + :type point: QtCore.QPoint + """ + + # create context menu + pop_menu = QtWidgets.QMenu(self) + + delete_action = QtWidgets.QAction('Delete', self) + pop_menu.addAction(delete_action) + delete_action.triggered.connect(partial(self.delete_item, button)) + + open_action = QtWidgets.QAction('Open', self) + pop_menu.addAction(open_action) + open_action.triggered.connect(partial(self.open_item, button)) + + # rename_action = QtWidgets.QAction('Rename', self) + # pop_menu.addAction(rename_action) + # rename_action.triggered.connect(partial(self.rename_item, button)) + + # show context menu + pop_menu.exec_(button.mapToGlobal(point)) + + def delete_item(self, button): + """ Deletes the selected file. + :param button: button user clicks on. + :type button: QPushButton + """ + if path.exists(self.buttons_dictionary[button]): + remove(self.buttons_dictionary[button]) + print("Deleted:", self.buttons_dictionary[button]) + + # refresh view + self.refresh_details_view() + + def refresh_details_view(self): + """ Refreshes the view. """ + pass # Todo + + def open_item(self, button): + """ Opens the selected file. + :param button: button user clicks on. + :type button: QPushButton + """ + file_path = self.buttons_dictionary[button] + if path.exists(file_path): + self.on_item_clicked(file_path) + + # def rename_item(self, button): + # file_path = self.buttons_dictionary[button] + # + # new_file_path = "some new name" + # if path.exists(file_path): + # rename(file_path, new_file_path) + def create_table_widget_header(widget, header_items): """ Creates the header for a table widget. @@ -289,7 +357,7 @@ def add_icon_to_button(button, file_path): :type button: QPushButton :param file_path: full path to the file whose icon is to be added to the button :type file_path: str""" - + print(type(button)) file_info = QtCore.QFileInfo(file_path) icon_provider = QtWidgets.QFileIconProvider() icon = icon_provider.icon(file_info) @@ -316,6 +384,12 @@ def clear_grid_layout(grid_layout): child.widget().deleteLater() +# def add_view_options_buttons(grid_layout): +# details_button = QtWidgets.QPushButton('SP_FileDialogInfoView') +# icons_button = QtWidgets.QPushButton('SP_FileDialogListView') +# details_button.setIcon(self.style().standardIcon(getattr(QStyle, 'SP_FileDialogInfoView'))) +# icons_button.setIcon(self.style().standardIcon(getattr(QStyle, 'SP_FileDialogListView'))) + def set_button_style(button): """ Sets the button style and dimensions. No background or border by default. Turns blue with a border on hover. :param button: the buttons with the file name and icon on them. @@ -343,6 +417,25 @@ def convert_filesize_to_str(size_long): return str(size_long / (10 ** 9)) + " GB" +# class QDoublePushButton(QtWidgets.QPushButton): +# doubleClicked = QtCore.Signal() +# clicked = QtCore.Signal() +# +# def __init__(self, *args, **kwargs): +# QtWidgets.QPushButton.__init__(self, *args, **kwargs) +# self.timer = QtWidgets.QTimer() +# self.timer.setSingleShot(True) +# self.timer.timeout.connect(self.clicked.emit) +# super().clicked.connect(self.check_double_click) +# +# def check_double_click(self): +# if self.timer.isActive(): +# self.doubleClicked.emit() +# self.timer.stop() +# else: +# self.timer.start(250) + + From eeb2d873c6ac863f84a3d4d7eb1f1866aba1e217 Mon Sep 17 00:00:00 2001 From: megmugur Date: Sun, 8 Nov 2020 09:59:56 -0800 Subject: [PATCH 11/15] deleting assignment2/meg file --- assignment2/meg/CategorizeObjects.py | 44 ----- assignment2/meg/CreateSuffixDatabase.py | 14 -- assignment2/meg/Database/SuffixDatabase.json | 8 - assignment2/meg/NewNameOperations.py | 66 ------- assignment2/meg/ObjectRenamer.py | 196 ------------------- assignment2/meg/ReadMe.md | 10 - assignment2/meg/RenamerScreenshot.jpg | Bin 149835 -> 0 bytes assignment2/meg/__init__.py | 5 - 8 files changed, 343 deletions(-) delete mode 100644 assignment2/meg/CategorizeObjects.py delete mode 100644 assignment2/meg/CreateSuffixDatabase.py delete mode 100644 assignment2/meg/Database/SuffixDatabase.json delete mode 100644 assignment2/meg/NewNameOperations.py delete mode 100644 assignment2/meg/ObjectRenamer.py delete mode 100644 assignment2/meg/ReadMe.md delete mode 100644 assignment2/meg/RenamerScreenshot.jpg delete mode 100644 assignment2/meg/__init__.py diff --git a/assignment2/meg/CategorizeObjects.py b/assignment2/meg/CategorizeObjects.py deleted file mode 100644 index 315cfad..0000000 --- a/assignment2/meg/CategorizeObjects.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Class sorts the list of objects into categories based on the types of the objects in the list. -""" -import os -import pymel.core as pm -import pprint - -class CategorizeObjects: - def __init__(self): - self.objects_list = [] - self.shape_objects_list = [] - self.objects_dictionary = {} - - def create_object_containers(self): - """Calls the methods that create: - 1. A list of Shape objects. - 2. A dictionary with object names as values, and their types as keys.""" - self.create_shape_objects_list() - self.create_objects_dictionary() - - def create_shape_objects_list(self): - """Creates a list of Shape objects from the selection.""" - for each_object in self.objects_list: - if each_object.listRelatives(shapes=True): - shape_object = each_object.getShape() - self.shape_objects_list.append(shape_object) - - def create_objects_dictionary(self): - """Populates the dictionary with objects as values, and their types as keys. """ - for selected_object in self.objects_list: - if selected_object in self.shape_objects_list: # if it is a shape node, do not add to dictionary - continue - elif selected_object.listRelatives(shapes=True): # if it has a shape node as a child, - shape_object = selected_object.getShape() # get its type by accessing the shape node. - object_type = pm.objectType(shape_object) - elif pm.objectType(selected_object) == "transform": - object_type = "group" # if it contains a transform node but no shape node, could be a group - else: - object_type = pm.objectType(selected_object) # else, find it's type - if object_type not in self.objects_dictionary: # if category is not already a key in the dictionary, add it - self.objects_dictionary[object_type] = [] - self.objects_dictionary[object_type].append(selected_object) # add the object to the dictionary - - diff --git a/assignment2/meg/CreateSuffixDatabase.py b/assignment2/meg/CreateSuffixDatabase.py deleted file mode 100644 index cdba824..0000000 --- a/assignment2/meg/CreateSuffixDatabase.py +++ /dev/null @@ -1,14 +0,0 @@ -import json - -"""Creates a JSON file for storing suffixes for different object types""" -suffixes = {'mesh': '_geo', - 'joint': '_jnt', - 'nurbsCurve': '_crv', - 'locator': '_loc', - 'light': "_lgt", - 'group': "_grp"} -try: - with open('Database/SuffixDatabase.json', 'w') as data_file: - json.dump(suffixes, data_file, indent=2) -except ValueError as write_error: - print("Database could not be created.", write_error) diff --git a/assignment2/meg/Database/SuffixDatabase.json b/assignment2/meg/Database/SuffixDatabase.json deleted file mode 100644 index 30a9e1b..0000000 --- a/assignment2/meg/Database/SuffixDatabase.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "group": "_grp", - "nurbsCurve": "_crv", - "locator": "_loc", - "light": "_lgt", - "joint": "_jnt", - "mesh": "_geo" -} \ No newline at end of file diff --git a/assignment2/meg/NewNameOperations.py b/assignment2/meg/NewNameOperations.py deleted file mode 100644 index 17e6a2c..0000000 --- a/assignment2/meg/NewNameOperations.py +++ /dev/null @@ -1,66 +0,0 @@ -""" This class suggests an object name (its current name + suffix) based on its type. If the object name already -contains the correct suffix, it returns the name as is. """ -import re -import os -import json - -def validate_desired_names(desired_names_list): - """ Checks the new name entry for errors: - 1. If new name textbox is left empty - 2. If there is a space : replaces it with underscore - 3. If there are any special characters (other than underscore) - 4. If there are any duplicate object names - In case of error, adds an appropriate error message, and returns True. Else, returns False""" - error_message = None - if "" in desired_names_list: - error_message = "Error: New name invalid." - return error_message - for name_index, name in enumerate(desired_names_list): - if " " in name: - name = name.replace(" ", "_") - desired_names_list[name_index] = name - if re.findall('[^A-Za-z0-9]+', name.replace("_", "")): - error_message = "Error: Special characters (other than underscore) not allowed." - return error_message - if len(desired_names_list) != len(set(desired_names_list)): - error_message = "Error: Duplicate names." - - return error_message - - -def suggest_name(object_name, category_name): - """ Adds an appropriate suffix to the object name (if there isn't one already), and returns it as the - suggested name. Eg: The suffix '_geo' is added to a mesh object. If database is not accessible, prints an error and - returns the name without a suffix - :param object_name : Name of the object that needs renaming - :type object_name : String - :param category_name : Type of the object. Eg: mesh, joint, locator. - :type category_name : String - :return object_name : A name suggestion, with a suffix added to the object name(if no error). Suffix is based on - the object_type. - :rtype object_name : String """ - - suffix = "_" + category_name.lower()[:3] - - project_path = os.path.dirname(os.path.abspath(__file__)) - database_path = os.path.join(project_path, 'Database\SuffixDatabase.json') - - suffix_dict = None - object_name = str(object_name) - if not os.path.isfile(database_path) or not os.access(database_path, os.R_OK): # case: data loading failed. - print("Error: Directory not found or not readable") - return object_name - database_file = open(database_path) - - try: # case: data loading successful. - suffix_dict = json.load(database_file) - if category_name in suffix_dict: - suffix = suffix_dict[category_name] - except ValueError as json_error: - print("Error: ", str(json_error)) - if not suffix_dict: - print("Error: Suffixes database is empty") - - if not object_name.endswith(suffix): - object_name += suffix - return object_name diff --git a/assignment2/meg/ObjectRenamer.py b/assignment2/meg/ObjectRenamer.py deleted file mode 100644 index 578ca8b..0000000 --- a/assignment2/meg/ObjectRenamer.py +++ /dev/null @@ -1,196 +0,0 @@ -""" This is a program to rename selected objects in Maya. The pop-up window will contain: -a list of the selected objects, sorted by their types, -and a textbox next to each object, for the user to type the desired new name. -The textbox is populated with suggestions, based on the object type, and the user can edit it. -TODO - Future enhancements: -1. indicate which textbox needs editing, to fix the displayed error. -2. When user hits Undo once after using the tool, the entire list of names should be reverted at once. -3. Check more test cases -4. Add reload button to reload (possibly new) selection while window is still open -5. Add check-boxes to ask user if modifying suffix for one object should similarly modify suffixes for all -objects of that category. """ - -import pymel.core as pm -from PySide2 import QtWidgets, QtCore -from maya import OpenMayaUI as omui -from shiboken2 import wrapInstance - -import CategorizeObjects -import NewNameOperations - -class RenamerDialog(QtWidgets.QDialog): - def __init__(self): - """ The initialization includes setting up the UI framework for the tool window, and calling the method - which will do an initial check and start setting up the window.""" - pointer = omui.MQtUtil.mainWindow() - parent = wrapInstance(long(pointer), QtWidgets.QWidget) - super(RenamerDialog, self).__init__(parent) - - self.setWindowTitle("Object Renamer") - self.main_layout = QtWidgets.QGridLayout() - self.main_layout.setAlignment(QtCore.Qt.AlignCenter) - self.setLayout(self.main_layout) - self.setGeometry(500, 200, 520, 620) - - self.initial_check_and_start() - - def initial_check_and_start(self): - """This method creates a list of all selected dag objects, and checks if selection is empty. - If it is empty, displays an error message. If not, it goes ahead, and sets up the object lists' display. - It also creates an object of the CategorizeObjects class, and initializes variables which will be used later.""" - - self.all_selected_objects = pm.ls(dagObjects=True, selection=True, dependencyNodes=True) - - if not self.all_selected_objects: - no_selection_label = QtWidgets.QLabel("Nothing selected.") - self.main_layout.addWidget(no_selection_label) - return - - self.categorize_object = CategorizeObjects.CategorizeObjects() - self.row_index = 0 - self.new_name_dictionary = {} - self.desired_names_list = [] - self.initialize_ui_elements() - - self.populate_window() - - - def initialize_ui_elements(self): - """Creates all the UI elements for the dialog window, except the dynamically created ones.""" - self.scroll_area_widget = QtWidgets.QScrollArea() - self.list_widget = QtWidgets.QWidget() - self.list_grid_layout = QtWidgets.QGridLayout() - - self.current_name_label = QtWidgets.QLabel("Current name:") - self.new_name_label = QtWidgets.QLabel("New Name:") - self.heading_bar = QtWidgets.QWidget() - self.heading_bar_layout = QtWidgets.QHBoxLayout() - self.error_label = QtWidgets.QLabel() - - self.rename_button = QtWidgets.QPushButton("Rename and close") - - def populate_window(self): - """ Calls the methods that create and display the UI widgets in the popup window""" - - # add heading labels to the top of the window - self.setup_heading_labels() - - # create two lists (one for current object names, one for the desired new names) and add them to the display. - self.categorize_object.objects_list = self.all_selected_objects - self.categorize_object.create_object_containers() - self.setup_scroll_area_widget() - - # create a rename button, and add it to the bottom of the display - self.setup_rename_button() - - - def setup_heading_labels(self): - """ Encases the labels for 'Current name' and 'New name' list-headings in a QHBox layout, and adds - them to the main layout. """ - self.heading_bar.setLayout(self.heading_bar_layout) - self.heading_bar_layout.addWidget(self.current_name_label) - self.heading_bar_layout.addWidget(self.new_name_label) - self.main_layout.addWidget(self.heading_bar) - - def setup_rename_button(self): - """ Makes the signal-slot connection for the 'rename and close' button at the bottom. - i.e., on button press, calls the rename_objects() method. """ - self.rename_button.pressed.connect(self.rename_objects) - self.main_layout.addWidget(self.rename_button, 2, 0) - - def setup_scroll_area_widget(self): - """ Sets up the UI elements for scrollable area (the lists widget).""" - self.setup_scroll_area_style() - - self.list_widget.setLayout(self.list_grid_layout) - self.list_grid_layout.setAlignment(QtCore.Qt.AlignTop) - - self.populate_list_grid_layout() - - self.scroll_area_widget.setWidget(self.list_widget) - self.main_layout.addWidget(self.scroll_area_widget, 1, 0) - - def setup_scroll_area_style(self): - """ Sets up the style for the scrollable area containing the lists.""" - self.scroll_area_widget.setFocusPolicy(QtCore.Qt.NoFocus) - self.scroll_area_widget.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) - self.scroll_area_widget.setWidgetResizable(True) - - def populate_list_grid_layout(self): - """ The scroll area contains a list of categories(object types), each of which contains a QGridLayout, with all - the objects of that type, listed under it. - A new dictionary is created, which will hold: the object names as keys, and name of the textbox that contains - its new name as the value. This mapping will help with the renaming action, on button press.""" - - for object_type, objects_list in self.categorize_object.objects_dictionary.items(): - type_name = str(object_type).capitalize() + " objects:" - category_label = QtWidgets.QLabel(type_name) - self.list_grid_layout.addWidget(category_label, self.row_index, 0) - self.row_index += 1 - - # Create a new category widget (Eg. for Mesh type objects) - category_wise_grid = QtWidgets.QWidget() - - self.category_wise_grid_layout = QtWidgets.QGridLayout() # Dynamic creation - self.category_wise_grid_layout.setHorizontalSpacing(0) - self.category_wise_grid_layout.setVerticalSpacing(0) - - # Obtain a list of all objects of the category type - self.populate_each_category(objects_list, object_type) - - # Add the category along with its list to the display - category_wise_grid.setLayout(self.category_wise_grid_layout) - self.list_grid_layout.addWidget(category_wise_grid, self.row_index, 0) - self.row_index += 1 - # print(object_type, objects_list) - - def populate_each_category(self, all_objects_in_category, category): - """ For each object in the list, creates two textboxes: one for the current name, and one for the desired new - name. Adds these textboxes to the layout. The current names have to be non-editable. - :param all_objects_in_category : List of all objects of the same type. Eg: All locator objects. - :type all_objects_in_category : List - :param category : Type of the objects in the list - :type category : Object type - """ - object_index = 0 - for each_object in all_objects_in_category: - suggested_name = NewNameOperations.suggest_name(each_object, category) - from_textbox = QtWidgets.QLineEdit(str(each_object)) - to_textbox = QtWidgets.QLineEdit(suggested_name) - from_textbox.setEnabled(False) - - self.new_name_dictionary[each_object] = to_textbox - self.category_wise_grid_layout.addWidget(from_textbox, object_index, 0) - self.category_wise_grid_layout.addWidget(to_textbox, object_index, 1) - object_index += 1 - - def rename_objects(self): - """ This method goes through the key-value pairs in the new_name_dictionary, and renames the objects in Maya, - based on the content of the corresponding textboxes. - The new_name_dictionary contains: the object names as keys, - and names of the corresponding textboxes (which in turn, contain the desired new names) as the values. """ - - for every_object, corresponding_textbox in self.new_name_dictionary.items(): - self.desired_names_list.append(corresponding_textbox.text()) - error_message = NewNameOperations.validate_desired_names(self.desired_names_list) - if error_message: - self.error_label.setText(self.new_name_object.error_message) - self.list_grid_layout.addWidget(self.error_label, self.row_index, 0) - self.row_index += 1 - return - else: - for object_index, every_object in enumerate(self.new_name_dictionary.keys()): - pm.rename(every_object, self.desired_names_list[object_index]) - self.close() - - -INSTANCE = None - - -def show_gui(): - """ Singleton to create the gui if it doesn't exist, or show if it does """ - global INSTANCE - if not INSTANCE: - INSTANCE = RenamerDialog() - INSTANCE.show() - return INSTANCE diff --git a/assignment2/meg/ReadMe.md b/assignment2/meg/ReadMe.md deleted file mode 100644 index fe41991..0000000 --- a/assignment2/meg/ReadMe.md +++ /dev/null @@ -1,10 +0,0 @@ -This is a tool to rename the selected objects in Maya. -The tool interface, which is a pop-up window, will contain two columns: one with a list of the selected objects, sorted by object type; and the other, with a list of textboxes where the user can type the desired new name. -The second column is populated with suggestions, based on the object type, and the user can edit it. - -The logic is pretty straight forward. The only point to note, is that objects such as polygons and locators contain two nodes: a transform node and a shape node. The shape node is listed as a child of the transform node. In order to find out the type of the objects, we need to access the shape node. The renaming, however, must be done to its transform node. - - -Here's a screenshot of the tool: - -![Renamer tool screenshot](https://github.com/megmugur/MegsCodeGallery/blob/master/PyMelProjects/ObjectRenamerTool/RenamerScreenshot.jpg) \ No newline at end of file diff --git a/assignment2/meg/RenamerScreenshot.jpg b/assignment2/meg/RenamerScreenshot.jpg deleted file mode 100644 index 3a969283d2a24e4cf3a4e6cf3ed1370b398d2985..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 149835 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 Date: Sun, 8 Nov 2020 21:38:43 -0800 Subject: [PATCH 12/15] changing layout to show collection and info separately --- browserApp/app.py | 6 +- browserApp/collection_view.py | 280 ++++++++++-- browserApp/info_view.py | 408 ++++++------------ browserApp/resources/MediaFiles_Metadata.docx | Bin 0 -> 15506 bytes 4 files changed, 365 insertions(+), 329 deletions(-) create mode 100644 browserApp/resources/MediaFiles_Metadata.docx diff --git a/browserApp/app.py b/browserApp/app.py index 2e79cdf..ff6855b 100644 --- a/browserApp/app.py +++ b/browserApp/app.py @@ -40,8 +40,8 @@ def __init__(self, top_item, parent=None): layout.addWidget(self.collection) # Info view (on right) - self.info = info_view.InfoView() - layout.addWidget(self.info) + # self.info = info_view.InfoView() + # layout.addWidget(self.info) self.main_widget.setLayout(layout) @@ -53,7 +53,7 @@ def __init__(self, top_item, parent=None): def itemSelected(self, model_info): """ User as clicked on an item in the tree, pass item's data to the info view """ - self.info.populate(model_info) + # self.info.populate(model_info) self.collection.populate(model_info) diff --git a/browserApp/collection_view.py b/browserApp/collection_view.py index 1033b7a..1bd151a 100644 --- a/browserApp/collection_view.py +++ b/browserApp/collection_view.py @@ -5,6 +5,11 @@ except: from PyQt5 import QtWidgets, QtCore +from os import path, system, remove, listdir +from os.path import isfile, join +import model +from functools import partial +import info_view class CollectionView(QtWidgets.QWidget): @@ -12,28 +17,226 @@ def __init__(self, parent=None): super(CollectionView, self).__init__(parent) layout = QtWidgets.QHBoxLayout() + self.setLayout(layout) - #self.label = QtWidgets.QLabel() - self.labels = [] + self.icons_widget = QtWidgets.QWidget() + self.icons_layout = QtWidgets.QGridLayout() + self.icon_image_path = "/images/folder_icon.png" + self.icons_column_index = 0 + self.icons_row_index = 0 + self.NUMBER_OF_GRID_COLUMNS = 1 + self.data = None - layout.addSpacing(1) - #layout.addWidget(self.label) - layout.addSpacing(1) + self.info = info_view.InfoView() - self.setLayout(layout) + layout.addWidget(self.icons_widget) + layout.addWidget(self.info) + # self.icons_filename_buttons = {} + + def populate(self, data): + """ Populates the Display area of the window with a stacked widget. The stacked widgets contains two pages: + the Icons View page which shows the contents of the folder as a grid of buttons, and + the Details View page which shows a table of files(buttons) and their details, eg., file size, date modified. + Todo: Get file details from meta + :param data: object that contains the full path of the folder(or file), the list of files it contains, and some + methods to help process the data. + :type data: object of file.FileItem """ + self.data = data + meta = data.get_info() + print("Meta: ", meta) + if 'full_path' not in meta: + print("Could not find file path in dictionary") + return + item_path = meta['full_path'] - def populate(self, items): - return - #self.label.clear() - for label in self.labels: - label.clear() - self.layout().removeWidget(label) - # self.labels.clear() + # Clear Icons View: + clear_grid_layout(self.icons_layout) + self.reset_row_column_counts() + # self.add_view_options_buttons(self.icons_layout) - child_count = items.children() - if child_count == 0: + if 'type' not in meta: + print("Could not find item type (file or folder) in dictionary.") return + # File: + if meta['type'] == 'File': # If we remove files from the collection view, we can delete this check. + return + + # Directory: + self.icons_layout.setAlignment(QtCore.Qt.AlignTop) + list_of_files = [] + list_of_folders = [] + list_of_files_and_folders = listdir(item_path) + print(list_of_files_and_folders) + for item in list_of_files_and_folders: + if path.isfile(path.join(item_path, item)): # Request addition of 'file_name' to data model. + list_of_files.append(item) + else: + list_of_folders.append(item) + + self.reset_row_column_counts() + + self.icons_widget.setLayout(self.icons_layout) + self.icons_layout.setSpacing(0) # sets spacing between widgets in the layout to 0. + + for item in list_of_files_and_folders: + self.add_item_to_iconview(item, item_path) + + def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name + """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with + contents of the folder that is clicked on. + :param file_path: full path to the file or folder button that is clicked on. + :type file_path: str""" + if isfile(file_path): + self.info.populate(file_path) + else: + print("Not handled folders yet. App is still under construction.") + + def show_rightclick_menu(self, button, point): + """ Displays a menu on right mouse button click. + :param button: button user clicks on. + :type button: QPushButton + :param point: ?? + :type point: QtCore.QPoint + """ + + # create context menu + pop_menu = QtWidgets.QMenu(self) + + delete_action = QtWidgets.QAction('Delete', self) + pop_menu.addAction(delete_action) + delete_action.triggered.connect(partial(self.delete_item, button)) + + open_action = QtWidgets.QAction('Open', self) + pop_menu.addAction(open_action) + open_action.triggered.connect(partial(self.open_item, button)) + + # rename_action = QtWidgets.QAction('Rename', self) + # pop_menu.addAction(rename_action) + # rename_action.triggered.connect(partial(self.rename_item, button)) + + # show context menu + pop_menu.exec_(button.mapToGlobal(point)) + + def delete_item(self, button): + """ Deletes the selected file. + :param button: button user clicks on. + :type button: QPushButton + """ + if path.exists(self.buttons_dictionary[button]): + remove(self.buttons_dictionary[button]) + print("Deleted:", self.buttons_dictionary[button]) + + # refresh view + self.refresh_details_view() + + def refresh_details_view(self): + """ Refreshes the view. """ + pass # Todo + + def open_item(self, button): + """ Opens the selected file. + :param button: button user clicks on. + :type button: QPushButton + """ + file_path = self.buttons_dictionary[button] + if path.exists(file_path): + print("Opening", file_path + "...") + if " " in file_path: + print("Cannot process file names with spaces, yet.") + return + if path.isfile(file_path): + system("start " + file_path) + else: + # if it's a folder, clear both pages, and populate with data inside that folder + clear_grid_layout(self.icons_layout) + file_item_object = model.file.FileItem( + file_path) # to generate data, create object of model.file FileItem + self.populate(file_item_object) + + def increment_grid_position(self): + """Once a grid position is filled, this method is called, to point to the next position.""" + self.icons_column_index += 1 + if self.icons_column_index == self.NUMBER_OF_GRID_COLUMNS: # go to next row + self.icons_row_index += 1 + self.icons_column_index = 0 + + def reset_row_column_counts(self): + self.icons_column_index = 0 + self.icons_row_index = 0 + + def add_item_to_iconview(self, file, file_path): + """Creates a button, sets its style, and adds it to the Icons View page. Makes signal-slot connections. + The button displays an icon and the file name. + :param file: file name including extension. + :type file: str + :param file_path: full path to the file + :type file_path: str """ + + file_name = file.split('.')[0] # removes the extension. + button = QtWidgets.QPushButton(file_name) + set_button_style(button) + + if "." not in file_path: # Todo: use a different method. What if the file name contains a dot? + full_path = join(file_path, file) + else: + full_path = file_path + add_icon_to_button(button, full_path) + button.clicked.connect(partial(self.on_item_clicked, full_path)) + + self.icons_layout.addWidget(button, self.icons_row_index, self.icons_column_index) + self.increment_grid_position() + + +def clear_grid_layout(grid_layout): + """ Deletes all the contents of a grid layout. + :param grid_layout: The layout the needs to be cleared. + :type grid_layout: QtWidgets.QGridLayout """ + + # clear all icons_filename_buttons + while grid_layout.count(): + child = grid_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + +def set_button_style(button): + """ Sets the button style and dimensions. No background or border by default. Turns blue with a border on hover. + :param button: the buttons with the file name and icon on them. + :type button: QWidgets.QPushButton """ + button.setFixedSize(250, 25) + button.setStyleSheet(""" QPushButton {text-align:left; background-color: none; border: none; } + QPushButton:hover { background-color: #CBE1F5 } + QPushButton:pressed { border-width: 5px; background-color: #B7D9F9 } """) + + +def add_icon_to_button(button, file_path): + """Adds an icon to the button that is the windows standard icon associated with that file. + :param button: button to which the icon is to be added + :type button: QPushButton + :param file_path: full path to the file whose icon is to be added to the button + :type file_path: str""" + file_info = QtCore.QFileInfo(file_path) + icon_provider = QtWidgets.QFileIconProvider() + icon = icon_provider.icon(file_info) + button.setIcon(icon) + + + + + + + + # self.label.clear() + # for label in self.labels: + # label.clear() + # self.layout().removeWidget(label) + # # self.labels.clear() + # + # child_count = items.children() + # if child_count == 0: + # return + # # file_size = 0 # # for i in range(child_count): @@ -44,30 +247,31 @@ def populate(self, items): # file_size += meta["file_size"] # # self.label.setText("Total Size: %d" % file_size) + # + # keys = set() + # integer_values = {} + # + # # Get keys + # for i in range(child_count): + # subitem = items.get_child(i) + # meta = subitem.get_info() + # keys.update(meta.keys()) + # + # # Get cumulative key values + # for i in range(child_count): + # subitem = items.get_child(i) + # meta = subitem.get_info() + # + # for key in [x for x in keys if x in meta]: + # value = meta[key] + # if isinstance(value, int): + # if key not in integer_values: + # integer_values[key] = 0 + # integer_values[key] += value + # + # + # print(integer_values) - keys = set() - integer_values = {} - - # Get keys - for i in range(child_count): - subitem = items.get_child(i) - meta = subitem.get_info() - keys.update(meta.keys()) - - # Get cumulative key values - for i in range(child_count): - subitem = items.get_child(i) - meta = subitem.get_info() - - for key in [x for x in keys if x in meta]: - value = meta[key] - if isinstance(value, int): - if key not in integer_values: - integer_values[key] = 0 - integer_values[key] += value - - - print(integer_values) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index ed5b417..0e7e657 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -33,179 +33,107 @@ def __init__(self, parent=None): """Initializes all necessary Qt widgets and variables.""" super(InfoView, self).__init__(parent) - self.icon_image_path = "/images/folder_icon.png" - layout = QtWidgets.QVBoxLayout() - self.icons_widget = QtWidgets.QWidget() - self.icons_layout = QtWidgets.QGridLayout() - self.details_widget = QtWidgets.QTableWidget() - self.list_view_button = QtWidgets.QPushButton("Details View") - self.icons_view_button = QtWidgets.QPushButton("Icons View") - self.top_bar_layout = QtWidgets.QHBoxLayout() - self.top_bar_layout.addWidget(self.list_view_button) - self.top_bar_layout.addWidget(self.icons_view_button) - - self.list_view_button.clicked.connect(partial(self.switch_page, self.details_widget)) - self.icons_view_button.clicked.connect(partial(self.switch_page, self.icons_widget)) + # self.top_bar_layout = QtWidgets.QHBoxLayout() + # self.top_bar_layout.addWidget(self.list_view_button) + # self.top_bar_layout.addWidget(self.icons_view_button) - self.stacked_pages = QtWidgets.QStackedWidget() - self.stacked_pages.addWidget(self.details_widget) - self.stacked_pages.addWidget(self.icons_widget) - - self.stacked_pages.setCurrentWidget(self.icons_widget) + # self.list_view_button.clicked.connect(partial(self.switch_page, self.details_widget)) + # self.icons_view_button.clicked.connect(partial(self.switch_page, self.icons_widget)) layout.addSpacing(1) - layout.addLayout(self.top_bar_layout) - layout.addWidget(self.stacked_pages) + layout.addWidget(self.details_widget) layout.addSpacing(1) - self.icons_column_index = 0 - self.icons_row_index = 0 - self.NUMBER_OF_GRID_COLUMNS = 2 + self.file_path = "" + self.data = None - self.details_header_list = ["Name", "Date", "Type", "Size"] # Is this a string constant list?? + self.details_header_list = ["Name", "Created", "Modified", "Type", "Size"] # Is this a string constant list?? self.details_row_index = 0 self.details_column_index = 0 - self.data = {} - self.icons_filename_buttons = {} - self.details_filename_buttons = {} - self.buttons_dictionary = {} + # self.data = {} + + # self.details_filename_buttons = {} + # self.buttons_dictionary = {} + self.file_name_label = QtWidgets.QLabel("") self.setLayout(layout) - self.setMinimumWidth(520) + self.setMinimumWidth(200) - def populate(self, data): + def populate(self, file_path): """ Populates the Display area of the window with a stacked widget. The stacked widgets contains two pages: - the Icons View page which shows the contents of the folder as a grid of buttons, and - the Details View page which shows a table of files(buttons) and their details, eg., file size, date modified. - Todo: Get file details from meta - :param data: object that contains the full path of the folder(or file), the list of files it contains, and some - methods to help process the data. - :type data: object of file.FileItem """ - self.data = data - meta = data.get_info() - print("Meta: ", meta) - if 'full_path' not in meta: - print("Could not find file path in dictionary") - return - item_path = meta['full_path'] - + the Icons View page which shows the contents of the folder as a grid of buttons, and + the Details View page which shows a table of files(buttons) and their details, eg., file size, date modified. + Todo: Get file details from meta + :param file_path: full path of the file + :type file_path: str """ # Clear List View: - clear_table_widget(self.details_widget) - # Clear Icons View: - clear_grid_layout(self.icons_layout) - self.reset_row_column_counts() - # self.add_view_options_buttons(self.icons_layout) - - if 'type' not in meta: - print("Could not find item type (file or folder) in dictionary.") - return - - # File: - if meta['type'] == 'File': # If we remove files from the collection view, we can delete this check. - return - - # Directory: - self.icons_layout.setAlignment(QtCore.Qt.AlignTop) - files = [] - directories = [] - files_and_folders = listdir(item_path) - print(files_and_folders) - for item in files_and_folders: - if isfile(join(item_path, item)): # Request addition of 'file_name' to data model. - files.append(item) - else: - directories.append(item) + clear_table_widget(self.details_widget) + self.file_path = file_path self.reset_row_column_counts() - - self.icons_widget.setLayout(self.icons_layout) - self.icons_layout.setSpacing(0) # sets spacing between widgets in the layout to 0. - - for item in files_and_folders: - self.add_item_to_detailsview(item, item_path) - self.add_item_to_iconview(item, item_path) - - + self.add_item_to_detailsview(file_path) def reset_row_column_counts(self): """ Resets the row and column indices to zero. """ self.details_row_index = 0 self.details_column_index = 0 - self.icons_column_index = 0 - self.icons_row_index = 0 - - def add_item_to_iconview(self, file, file_path): - """Creates a button, sets its style, and adds it to the Icons View page. Makes signal-slot connections. - The button displays an icon and the file name. - :param file: file name including extension. - :type file: str - :param file_path: full path to the file - :type file_path: str """ - - file_name = file.split('.')[0] # removes the extension. - button = QtWidgets.QPushButton(file_name) - set_button_style(button) - - if "." not in file_path: # Todo: use a different method. What if the file name contains a dot? - full_path = join(file_path, file) - else: - full_path = file_path - add_icon_to_button(button, full_path) - button.clicked.connect(partial(self.on_item_clicked, full_path)) - - self.icons_layout.addWidget(button, self.icons_row_index, self.icons_column_index) - self.increment_grid_position() - def add_item_to_detailsview(self, file_name, folder_path): + def add_item_to_detailsview(self, file_path): """ Populates the details table widget with data. If it is the first time it is being called, sets up the headers. - :param file_name: name of the file to be added to the button - :type file_name: str - :param folder_path: location of the folder that contains the file - :type folder_path: str + :param file_path: path of the file to be added to the button + :type file_path: str """ - if self.details_row_index == 0: create_table_widget_header(self.details_widget, self.details_header_list) - file_path = str(join(folder_path, file_name)) self.details_widget.insertRow(self.details_widget.rowCount()) # insert a row item_data_object = model.file.FileItem(file_path) item_data = item_data_object.get_info() - - # column : filename button - button = self.create_details_filename_buttons(file_name, file_path) - self.details_widget.setCellWidget(self.details_widget.rowCount()-1, 0, button) - + self.details_column_index += 1 + # column : filename + file_name = path.split(file_path)[1] + self.file_name_label.setText(file_name) + self.details_widget.setCellWidget(0, 1, self.file_name_label) + + file_item_object = model.file.FileItem(file_path) # to access details, create object of model.file FileItem + item_data = file_item_object.get_info() # get the data dictionary and store in item_data # column : date and time created if 'created' in item_data: - date_created = datetime.datetime.fromtimestamp(float(item_data['created'])).strftime('%d/%m/%Y %H:%M') + # date_created = datetime.datetime.fromtimestamp(float(item_data['created'])).strftime('%d/%m/%Y %H:%M') # request this from data model in required format - self.details_widget.setItem(self.details_widget.rowCount()-1, 1, - QtWidgets.QTableWidgetItem(date_created)) - - # column : file type - if 'type' in item_data: - if item_data['type'] == 'File': - file_type = path.splitext(file_path)[1].strip('.').upper() + " File" # request this from data model - else: - file_type = "Folder" - self.details_widget.setItem(self.details_widget.rowCount() - 1, 2, QtWidgets.QTableWidgetItem(file_type)) + date_created = item_data['created'] + self.details_widget.setItem(1, 1, QtWidgets.QTableWidgetItem(date_created)) + + if 'modified' in item_data: + date_modified = item_data['modified'] + self.details_widget.setItem(2, 1, QtWidgets.QTableWidgetItem(date_modified)) + + # # column : file type + file_type = None + if 'file_type' in item_data: + file_type = item_data['file_type'] + elif file_name: + file_type = file_name.split('.')[-1].upper() + if file_type: + self.details_widget.setItem(3, 1, QtWidgets.QTableWidgetItem(file_type)) # column : size if 'file_size' in item_data: - file_size = convert_filesize_to_str(item_data['file_size']) # request this for folders from data model - self.details_widget.setItem(self.details_widget.rowCount() - 1, 3, QtWidgets.QTableWidgetItem(file_size)) + # file_size = convert_filesize_to_str(item_data['file_size']) # request this for folders from data model + file_size = item_data['file_size'] # request this for folders from data model + if type(file_size) is float: + file_size = convert_filesize_to_str(file_size) + + self.details_widget.setItem(4, 1, QtWidgets.QTableWidgetItem(file_size)) self.details_widget.setRowHeight(self.details_widget.rowCount() - 1, 25) - add_icon_to_button(button, file_path) - self.details_row_index += 1 self.details_table_style() @@ -223,31 +151,23 @@ def on_item_clicked(self, file_path): # Todo: Does not work if there are an system("start " + file_path) else: # if it's a folder, clear both pages, and populate with data inside that folder - clear_table_widget(self.details_widget) - clear_grid_layout(self.icons_layout) + self.clear_table_widget(self.details_widget) file_item_object = model.file.FileItem(file_path) # to generate data, create object of model.file FileItem self.populate(file_item_object) - def increment_grid_position(self): - """Once a grid position is filled, this method is called, to point to the next position.""" - self.icons_column_index += 1 - if self.icons_column_index == self.NUMBER_OF_GRID_COLUMNS: # go to next row - self.icons_row_index += 1 - self.icons_column_index = 0 - - def switch_page(self, selected_widget): - """Switches current widget in the stack over to the selected widget. - This method has been created in case I need to change the functionality such that the buttons for a view are - created if, and only when, that view is selected. - :param selected_widget: The widget from the stack that needs to be set as the current widget. - :type selected_widget: QtWidget """ - self.stacked_pages.setCurrentWidget(selected_widget) + # def switch_page(self, selected_widget): + # """Switches current widget in the stack over to the selected widget. + # This method has been created in case I need to change the functionality such that the buttons for a view are + # created if, and only when, that view is selected. + # :param selected_widget: The widget from the stack that needs to be set as the current widget. + # :type selected_widget: QtWidget """ + # self.stacked_pages.setCurrentWidget(selected_widget) def details_table_style(self): """Sets the style for the details table widget.""" self.details_widget.setShowGrid(False) - self.details_widget.verticalHeader().setVisible(False) + self.details_widget.horizontalHeader().setVisible(False) self.details_widget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) # Todo: Make filenames editable self.details_widget.resizeColumnsToContents() self.details_widget.setStyleSheet("QTableWidget {background-color: transparent; border: none}" @@ -258,78 +178,58 @@ def details_table_style(self): "QTableCornerButton::section {background-color: transparent;}") self.details_widget.horizontalHeader().setDefaultAlignment(QtCore.Qt.AlignLeft) # left align header text - def create_details_filename_buttons(self, button_name, file_path): - """ Creates a button, and makes signal-slot connections for it. - :param button_name: name of the button. - :type button_name: str - :param file_path: full path to the file that is to be listed on the button. - :type file_path: str - :return button: button with the file name on it. - :rtype button: QPushButton widget - """ - button = QtWidgets.QPushButton(button_name) - button.clicked.connect(partial(self.on_item_clicked, file_path)) - set_button_style(button) - - if not isfile(file_path): - return button - # set button context menu policy - button.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - button.customContextMenuRequested.connect(partial(self.show_rightclick_menu, button)) - self.buttons_dictionary[button] = file_path - - return button - - def show_rightclick_menu(self, button, point): - """ Displays a menu on right mouse button click. - :param button: button user clicks on. - :type button: QPushButton - :param point: ?? - :type point: QtCore.QPoint - """ - - # create context menu - pop_menu = QtWidgets.QMenu(self) - - delete_action = QtWidgets.QAction('Delete', self) - pop_menu.addAction(delete_action) - delete_action.triggered.connect(partial(self.delete_item, button)) - - open_action = QtWidgets.QAction('Open', self) - pop_menu.addAction(open_action) - open_action.triggered.connect(partial(self.open_item, button)) - - # rename_action = QtWidgets.QAction('Rename', self) - # pop_menu.addAction(rename_action) - # rename_action.triggered.connect(partial(self.rename_item, button)) - - # show context menu - pop_menu.exec_(button.mapToGlobal(point)) - - def delete_item(self, button): - """ Deletes the selected file. - :param button: button user clicks on. - :type button: QPushButton - """ - if path.exists(self.buttons_dictionary[button]): - remove(self.buttons_dictionary[button]) - print("Deleted:", self.buttons_dictionary[button]) + def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name + """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with + contents of the folder that is clicked on. + :param file_path: full path to the file or folder button that is clicked on. + :type file_path: str""" - # refresh view - self.refresh_details_view() + print("Opening", file_path + "...") + if " " in file_path: + print("Cannot process file names with spaces, yet.") + return + if path.isfile(file_path): + system("start " + file_path) + else: + # if it's a folder, clear both pages, and populate with data inside that folder + self.clear_table_widget(self.details_widget) + file_item_object = model.file.FileItem(file_path) # to generate data, create object of model.file FileItem + self.populate(file_item_object) - def refresh_details_view(self): - """ Refreshes the view. """ - pass # Todo + def clear_grid_layout(grid_layout): + """ Deletes all the contents of a grid layout. + :param grid_layout: The layout the needs to be cleared. + :type grid_layout: QtWidgets.QGridLayout """ + + # clear all icons_filename_buttons + while grid_layout.count(): + child = grid_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + # def create_details_filename_buttons(self, button_name, file_path): + # """ Creates a button, and makes signal-slot connections for it. + # :param button_name: name of the button. + # :type button_name: str + # :param file_path: full path to the file that is to be listed on the button. + # :type file_path: str + # :return button: button with the file name on it. + # :rtype button: QPushButton widget + # """ + # button = QtWidgets.QPushButton(button_name) + # button.clicked.connect(partial(self.on_item_clicked, file_path)) + # set_button_style(button) + # + # if not isfile(file_path): + # return button + # # set button context menu policy + # button.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # button.customContextMenuRequested.connect(partial(self.show_rightclick_menu, button)) + # self.buttons_dictionary[button] = file_path + # + # return button + # - def open_item(self, button): - """ Opens the selected file. - :param button: button user clicks on. - :type button: QPushButton - """ - file_path = self.buttons_dictionary[button] - if path.exists(file_path): - self.on_item_clicked(file_path) # def rename_item(self, button): # file_path = self.buttons_dictionary[button] @@ -338,7 +238,7 @@ def open_item(self, button): # if path.exists(file_path): # rename(file_path, new_file_path) - +# def create_table_widget_header(widget, header_items): """ Creates the header for a table widget. :param widget: the widget for which the header needs to be added. @@ -346,22 +246,10 @@ def create_table_widget_header(widget, header_items): :param header_items: List of headings for the table's columns :rtype header_items: list of strings """ - widget.setRowCount(0) - widget.setColumnCount(len(header_items)) - widget.setHorizontalHeaderLabels(header_items) - + widget.setColumnCount(2) # ?? Re-think this. + widget.setRowCount(len(header_items)) + widget.setVerticalHeaderLabels(header_items) -def add_icon_to_button(button, file_path): - """Adds an icon to the button that is the windows standard icon associated with that file. - :param button: button to which the icon is to be added - :type button: QPushButton - :param file_path: full path to the file whose icon is to be added to the button - :type file_path: str""" - print(type(button)) - file_info = QtCore.QFileInfo(file_path) - icon_provider = QtWidgets.QFileIconProvider() - icon = icon_provider.icon(file_info) - button.setIcon(icon) def clear_table_widget(table): @@ -370,25 +258,7 @@ def clear_table_widget(table): :type table: QtWidgets.QTableWidget """ table.clearContents() - - -def clear_grid_layout(grid_layout): - """ Deletes all the contents of a grid layout. - :param grid_layout: The layout the needs to be cleared. - :type grid_layout: QtWidgets.QGridLayout """ - - # clear all icons_filename_buttons - while grid_layout.count(): - child = grid_layout.takeAt(0) - if child.widget(): - child.widget().deleteLater() - - -# def add_view_options_buttons(grid_layout): -# details_button = QtWidgets.QPushButton('SP_FileDialogInfoView') -# icons_button = QtWidgets.QPushButton('SP_FileDialogListView') -# details_button.setIcon(self.style().standardIcon(getattr(QStyle, 'SP_FileDialogInfoView'))) -# icons_button.setIcon(self.style().standardIcon(getattr(QStyle, 'SP_FileDialogListView'))) + print("cleared table contents") def set_button_style(button): """ Sets the button style and dimensions. No background or border by default. Turns blue with a border on hover. @@ -415,42 +285,4 @@ def convert_filesize_to_str(size_long): return str(size_long / (10 ** 6)) + " MB" else: return str(size_long / (10 ** 9)) + " GB" -<<<<<<< HEAD - - -# class QDoublePushButton(QtWidgets.QPushButton): -# doubleClicked = QtCore.Signal() -# clicked = QtCore.Signal() -# -# def __init__(self, *args, **kwargs): -# QtWidgets.QPushButton.__init__(self, *args, **kwargs) -# self.timer = QtWidgets.QTimer() -# self.timer.setSingleShot(True) -# self.timer.timeout.connect(self.clicked.emit) -# super().clicked.connect(self.check_double_click) -# -# def check_double_click(self): -# if self.timer.isActive(): -# self.doubleClicked.emit() -# self.timer.stop() -# else: -# self.timer.start(250) - - - - - - - - - - - - - - - - -======= ->>>>>>> 5e265c6a0a16da617ceec3325b3f2be58c5117b2 diff --git a/browserApp/resources/MediaFiles_Metadata.docx b/browserApp/resources/MediaFiles_Metadata.docx new file mode 100644 index 0000000000000000000000000000000000000000..4de57455c849f8e4c38532805e54b80ed615d778 GIT binary patch literal 15506 zcmeHugL`Dl_IAh41QQz*b7I?`*tV@nGO=w=Y}>YNXJXsQmvb%-?tQ+0;CFXFy`Ju> z{jRFsy;jv*t7^$gfPj7mfCC@_0005tYqYhp5fA{72L=Ej10aFb1+1+c46Pir6&@_L7|BMq@ko`6Ca zr3G*Kb6VfpcUbB3i0K)J`tc`L-6tUFeW#XFET9nUoWC5SaV2=iYMHW7E-n0`_ANvjF3xE|`0&xGW(pQhEE`E{1jJ4cWi+t)6mkf6$eD(%k%Rl07j%|3UGa_~_M zrw0{zkLcIVry~T<0~j~nmax@R<)dfWBDnF(;vXWb?%i{g_8Dz{B;6b;FObCCE?x7r z#e6OVS^C~$5XkdkS?}*40NKB|CvG%m!}&*z^oRGse7L8!ouP$2EzNK8e{T7I*cbow z(@SHzKHL(P_uTi@cdAWpxeGlSaP*-GRdM0+# z%|1=oaWhWy0y9AoE~E`={!yz({khcz5bxibNAEIkvkjZNcW3lEOe97=>>H|z5;B1a z9{muMG}ei{OCfl-TL5E9NIo&EPh69eVXL@spXAAqnM^-7Zzihl4pGb{)E(r53PDepsus;=47F00p z^LHZ+B(=BwkUb0@=5qqoTJ0@stTau&>yJt5KmB7PvvB_%8UQfU1pp8~9*VQIodKbX(;`YGBH)Gwdvn{dQ7oE;n@9ArJ;9-{Jwxu{G0cF~Yo!I1s;5JO{M&h)OH z7dCyFRr2GR!JIOyCTUs_6@5D0etq9<*I{}P-rJ$uAeJYEP91L*-dYM)wm4w=amXa z)m5DdY=d>FM$oKNJeEbk#1Cpf=n{~7FpvwNFmuEg#Uc(`IO2`rmYk06$LJx>5)j*? z6MK-2VUnEoW;YCk9*0i34z*V@(jEIW!odVHVm-1Af~Mi;XqTER_BvORj&2Abl}vY#8@Eq{_3E_ZnPS%eA9*wy8d z5BTiP5t2J`abi(~Yin5euXbbEC9W}14#e1RKO0%0U8Xs>g`Rvt{1dwLGv25fJI^fjEcRUs+dTsu! zz57)16uRMom#>jcFSlUaTI?>pwpwQMQ8C zc^K)-5(#*|y|9?ip{k-EB56LEsnCc!dy|5~E(5Iw!MJjPe5=V7qIf3O0q7ms)_2~G z+O>tuUoKos43L>+5NmFPkWy>D{ie688cnDLoC6opE9Gvs@;vzt>H{)jirUxgtMR&3 zDl7!rM#mhnH{5IM5Kar_+ox=ert4=we5mJYFK(2VWS=m z_)zfJ3YyXY-+2vH>K>IFqBo8N1ZYT+iY^vMhXYweRpyH)4Tz^3CDpGRJOYH@JChdd+PBrnI=&-5ue(l6ybETK=7G*tSd@KA8 zk7s?T;Da^8!gY|MW-Tb90>g>#HZ?c~u(FEjFjyroZD9u$=0UBl4E5PN3H5&8LuelE zZlMt@(J}#*T)9tShNaRY(NB@IVDq~{r*jcSOo}-8ob&XFcU?ajQrI6N?>nMa87ML& z;($;DB#XKv?}iU3A)-x~B&^Q1QB|p{P+V~)qtb(v1V)R&5#~+WkDHSi%jA48{~QiG z`X4X_nTBmtMT~G7*KlDc85_9-6TmVy%j(?6O|L z>g}x7IyOQvA=Z`f8Tdx>eV#6cULXje5X_3!t}`J3(xI7%AMG&OcOTfnFuwQOYbT>5 zB;5)17>1}bQ%M6Oi=7Q2#B~24Z@VSJ>WI+H5kOhgwFRE;*3 zi3fTzN5^08szi2^uhG4=9OMfWxp@J`p9vVAL#Q}96V~i?9RopSNqJIG-#DZMLr|uj zAhZ(*d&o8BE!KL8(OwDiuGI{RTkE^k<@@yM!wo*K&%q5??MbN*5(aT?G6!=LRa+XI z7G3!(QE3hv=`^nP1$s^lAA5(}TvVTpxBNi!DcV*53pdM)(|`vg?D}+~T+|N8>G1i5 zpLLH0(TSi-$7jMaPyiCt-Fov4H$CS=NT{ke(py}YzZ`10E<6yHcNvbol0#WYDc{TS zx?SIO-ZyeTYp&r`?Jnv?Sh}^05@!mL+0a3{9dhmMRhXmB!wiaKo@%+SOyUOeL4cyS zlL)7v!cO)8Vlq>AZ`kQtQ@*l|u+HJ&r-ex-O+GzD%rYO4a z%6schIKg>UXaXdmC4)s*DR-KPh1VPRp^;_I=&62-w+xLj{DHZ>paJqaEk7Q#nZ!wI z6PaFq=8}U;S;WYB%mce+!PA&>MFd#K4``lqGE|Z&0`1dd%bqT163D9e4X5e>m|^-R z{4rqyOiF83J%>do;^G3YBybD zq7`39D$`AdE^o@&6W_*WDq(Qo4TbI8 zWDH3fMllQyxs$}!POvVVB62&;uuUfqv=*V69Y_jS8Ac71^TaeK#|cmtw3E>Ao);Y^ z0h-n2SZnLKOcQOWKX&#dE@H71>D{> zS#xy_kgKit-7VPpL7#;;g4uYqmebs@qd4pf@+AHC!muDgFhq0!ZOq zDPdJoOWWg{Cq)du*i|w7j6#$2bX-|87K$ZL01DgMpn@K%*rR%9m17t+qDF&=!V0~{ z%F$lyy9u1DP(_jIkQ=g={M01NuAH$3ZJ~~B3SImj#X{0bTyZE}q&3EKK6A*eZbt5l zeofY4_V_gS?nJYK=)j;*xcS;QKsYO}@7Ow~3`M6sE!RuOV)*_*9s~PLg>J2mnDg7# zqnwG0w+t++2lAX!=={lqM1q(fSS)#CNu=?D(E`efhwKT;C?A@9FUq+u9I4r;lIw7H zmi>cP2{)ylPDW%rOHBDCUo_oRk|k3kJVGszupBll$NX3 z^JuYHxYuqL(-@gb&0>#Zn3Gtt1Uip#Gjfz*RVmau&gk}WjhU{?c#`)yb;G4CKtw|Jzq? zMy-@l#^-fwZDV%q3cFqEE|p+*15F+s+yivvqJ4trBrfN(WaIS7VA(zT-F9#@TN*`)XYPv5E8TqafW^WAT&MrRy0r$s}xKNhulM{ zn5bki{0nQiiaR=!tA5&fPv7>@dV+qu3gQ%)2r*$Fb-;1sG43UsY@(rt2WW zH21N)HB%wU%~+75%>=8g(|r%>QhZBL808md_fJ|Zmyf?jcFLYW_;?&+_hw23g~f%% zxY_eb5sb~aTmL4>nUfxdfTpomE0eB3;bt+$Ao8N#rz{D1 zXS&;Ia=Bk-^?sC_I8XN!^!Uq6QK5X%KqjfMbv^1psUDE%)%u|&J#S%eihq%HE zXc}=FJJ2mUtnIZ^Nno#W}aV`VLG+8Tt^q6^V1FrG%ZR1wrLlEAC|uNR}7wh zrC1tX^bxKY1NCihq^2)ai;p5td+}RyadR`H{nqwt61zweOm|F#I+wC7PFmPv60z<; zn+F|LorZ{LTo|Gu)LA18)oLimPcJ8+^hCsnDv+yS)<||M;@y+)O;o~kVZCh3Jy4@k z=#kNWG@nNFBO>^$B}eMS^W-8B#WYCy=nLEElcNxw;N!%djq|?qZfO(xW1`EoS19NU z@aqrE6h9%sAj+O$6D-^6rqgq9ci!h6%MueyI;@7g*^h9b4Vx2Cz!=!u3A{GMsY8bq zFi{Wm^weeUKWgQ)%zq5URy*KheEO3r$1?y%%r0qpn*4Di>WhR$Y%)tVhx^o5x4F*! zn}vo8O>Ga@R`5J2YZxAE@MJjq%-Tcityis<1(sWL*<}lht<-p{4RcHvxDYb2eD#Bn zjY5c$wQ>SvVOsSq%MC1Ma|xFKiv4WLco_q^(!K-S9RYyJKA{+r2#t}8kMIu|gfD?< ztS!W0_!mZGy1)UHb;Fh40^g5z4ioOk*dyhn*>Ny~%Lw`g5Q(IXs&}-&%BcgD&kY!M z7`ZC+wK{rL`yyJ`hM;SPAb%7itO|iS8SA_5WE;3?|JfZ^bC48qqJf#f8Dam`Aa^P_ zVu-QQIC1dRBBdTQK}cGvq?#DrwO=9k1JD=$vvz^Xt+R%orz=qbCqUUod#g^wbPN+a zKGF#Ffx;%|dXiK0nEEHH?Rq^m$-38JH*=8H!-f_$V~Pg-#Ydzn zyk)r|SfTpCbo@?ufu%nt*7b-ksbrCev~nMxW?_?oG#sm#v<^+oa=&%YW!1$t66ooB z`KwtA4?nuFEKJwM=8+<8Oq%zcufUYsd%KIx2AU>6!PsA(tfwp3yQ z+R?u{L}T(`l(Eul6rq}|N!csSf81DL6xx3*jbSS>7^lE!85AirsNNRh0aoKWm{QyB zF5pJyKWx{KZKZTrBHBEO45TaMP&0XrGDYRHZ%XrqVVkxLWOxLNp7QiFs+3f$KnTe@ zl4WHe$+Vg7QCc(e8Q6f|~edT-sk~r8_%s7Oi?xTcVxUqPBxNu(yau+6JQY;HsC28Kk;n>g}%Q$}^Xo&}0vi(lX= zNnHEr;~HxaOhk;wjmQr}pkprJPkzG+K>=a|I74|^Jj6AljZq%N*`M*f{QUj&EhD1T<`-YBmRxqR&+Ql*P=)TL3>OXng`aa_a}Jr~*0-;Wx9 zqy`PRN>w!Mq(`)~9HZZA+8i%!j4n?QnR3~P!I2X*MpRoTdo;-Cc665_ZTK|iV6SZC z)s);L%XZk?aJJ$*4+nlYw{R@phLajcD zPl~=?c0^dptUv|fSAu$=(G-sDazmHVY%z?UqVm!De5j?9%O}E)Pjg3U;#o!A6{rzD zbCg4j$Olu3PEEAwY@|5%e0}F`d}E));}dwuTpcUEnLVz#6&*j{9(7hIJ+&5stfX$+`5qoUfvoO$ zO?T+{UM7w~lVtqh*MZ5CeT&vUD2RHjq=4L_Yc0&mY06f6_Db8tc=-^;#Wo-Z31X!yiXT8FV z;E7lHu5;iT@kKQTCres);4X$xE0CfugFLu8IEs+(o71Z9yN8H)SQ-gg8mg;U;qzHL zM-1Ni%hnE2jxSe2$c5V;7+O0^r(>7IH*4edh38wFBe*zW$6&IG%TW%!U60nM*M?7l z3c6)oQgA=AmB=ZL)}_s(BZP|=bpvL>&>F!jw_^z4e%f*9iE=aQso0Q2k+5Y6;)!IP zlWK|d7;6z7*XE#q{U%!Ok|!lPToUrR@p+%{7n|!!)E8RJ0CP)*2;zKMkczno$lb-o ziE5xmd*Ww2OTCH0YYxyij1?{$Rc@g)`3xw87&H~~MtosS@7yzF5!#>(J8j-GtI!1K zZjg;~Y0AhAVT5*kQRtTk<_OaPdY?gg+;8pSq^{cpuU+6n6DaolnHMW$ddh+9)t(|D z8TJFz4kC8J2G2gtFh)XdEoDuDZ2oqe*$_6$AnNIlFzU?jCrEZ$T>@|YY*^UeybXcO z0%$g$*9QTa1X_lera_YNopHfCb&tU`-UXk_8^1(y-VIKR)V6yc_ zc7aK>)=A$c|J5DYT)a0xbZFO7(}#JT8l#AUiU0_x%#xQ|@XD8KA1h2eN%}mlLGA(1 zCZK?|9YU3xs&PJT4Z9D1{As~z`pCn9LOZD{&n}cU?U+>Agrw`Jx@0zUAAf4~Q{wSL zl!TXEQ?n;Za@gz83zI=R^xlm0+eGxSYc(`l1>y0K9oyO6(rHAV5&RqV+AwD{kU3E~ z;>Z_1-|*x(qMhiGK(Hd541E)uhtESPtKL1jFk}H_nmvYWow$q=64|{-%1%-&qE_zf z;{xqw{+_po!6P?3RPS-Eh}3RTqP5e`G<=^OqjTq-{n%Q}u11CCCSznW^J#d_^SBD$ zT;Pkq$(K;=0!xX@Xv+h&Yox0cjD)RReEm!mQ>}fIMTVRTK!_OOw74q+SQb)Pkr>mn0Q%e48xwGqq@;`S)NCT_8wPD( z4CBkREI~|1YW*9kMe&ywwb4>a$U%CJ>Q+2mG|R0xG7}cy{PewPT`v`?py?R21|xWp zGA^iXd{v=Sj!$|VU+PXpZO*lA>Ug0OarXFkm~S?8>NHAUU6{gRO&P;pSTV-Yc1E&( zQVndmutVk0Jyp2po{>J!y?>DB{~THD)EqY(KZy4X1^|HcFM4g|XsKst_c6Bo&aD$P ztyAef2b`0h@(7BySd2MCsA>0nnZn8oPc1HHuRhq_U-s-f ziY6KaG{Rzwf{*n$qK1bH22p|HMP7o?QF+cz4_mM!SLdHX&SL0rSW+i zv-asxCg)esBg`z|q70R$@TN5~=t4q<${qElbocki%v26{6kRJG>LzQ$vHLbmY3EC- z)Dj1U@Uv}_#k+TzWNemt_K0bxXh<>|IEwd%W zvF#R-!LM7E&RyYhQ0>9ti>x7JloM^lu!L0ixmrGl*BI94Y+akkv`Cngz3@UP&AnM` z552VNy%E#JIwJ>n7l0XJkAG8*w%GGN8Q`-8KC0kUV*31^J;tjE+Mj+yYm)#2PjqQ7 z2JYUGVE`hAM@&EXYhjE%z%#2>JU^_$SFIqjx{+G`sKXOSc_>b;!xksxPbjf#EE*6dIg^IU z^qJ|%p=31n%tHKV2wfK>GPNnTl(v^S*OCG!i>JY30fX; z6b*FF!9#usm?K?90C^6fa&!{3Vjs5nmZAwFNXP&#bRE3ET8^HuC(tir1AhfUjxBO^ zqPH+V&ibA0)A4QU1+JpEM&cHN!(+A>PX^#aTcL4!A#B>1`!7og9kfHxYNB=#wfa0_ zq$@M0-}#*oZdeKK?WFb{6Npi6qJC+X9=@DO?XyEPh?eAE)6O{bjFbLK_9*w?+D)N3 zpfPo^p{|YjtWkO>yp}rd{HZ*^IR*ZN4OocH6m1rwq(;-mX8^)$TWUn_dD(v;cs~+& zro17WJE%mnaoN8_^Whyz^R#=8M&q~p-(^c(PSdj)c8q7LCBxGtn z1ykWw$6^`}UsgF;rnwLrU&BU6`7?=`xc1(8K7sLm^H~M$19yMXlOTK!%(=AAbbWgp zut$REa7lnk&WIp6zZV9Q5x@w(*>bqqc{^GgA{&HB5T;5X=fP>a_v4Ld?yCwJR*JCN1vD7u5VSxD^Ra*rS$BXuAo_xtBUD|lFycg4E7_fe6$XHq5|A`Gny zFv=am1?`%a(l<3PhWBy1_h{`hm ztmgaLNx$6E)UprBb9P4F71`nrez9`~|KPd0cDWYW!jblD>nvjZQKu8WuOv~nDLh0) z4PIhFU>H5@0|DlRE3IFom&_RxP(aF+5zQC_kQb_VBgB=2Nq%g1_d}0VK@=6vnbZ`uY5CF~;tHEJbe5Q%&UI}D?B>7R*`hqqua90&ESmD~f>x9`Ip zRN)K{YN;TI0rkKAq4aqDEmRNz_Q=ma8C#2KXmGm+E&9_rB^(rYqU~s~ineJ$k}li? z9ITOxnGRhK1Bfq12D%D&&QW%X@T@#jcwvtjrd)^OPHgeUZy)9uvpq{pd!A3VK373q z-WBUlzb~%2Qr$+scikoh2gVhFbzU6ia~ol0FD<=rTGa=^b(&`}1s1|Dl~_l1WO8~3Pv_{Xoo$%bmW0lcwq3o0{P z0kp{VHa&@u{i$YdlRJr-RqBmf;cMM;On00{t~6-{vTd#kW0o$nr#KlebDS6>7IOU# zIE#QGw@RQG$E>gs;xTe{3l~2)X3nEKBzTM-B35E!m-kUY!97@zoGU+Xxu&kp!tXjd zx$x+o{t;kUp2@$7GHEa(HDDFGVq0PMSLF2FDjkKL(aysDWN*1}29O*~V+b@LZK_$q zB&(3BCzt*rv5F%DywW+hOO_TT5gSE{;seqzLxdFe>Q)rRh zUq8P~D>02_m!fqc%ApQ?L&8yCk!0<+dtR>7G}@Dn=u&J9RUJ(H8sV~M61$?bJ5qC0 zAGR>ZGI!v(cpd7Zg4$)##Jy~-pYP5=o$BW?V&Uv<>A`JzW^BsPvKvhno1cAQ6mrPt zrO%Mv5cbI#%UTIZJwb$vS~q!SEg@C$Dq+`FMnYvzOhzJShI)jCOQZ6n$`K#iIsB(% z#6J4>0nHo}%T+at1zt7vdbqBga=F?s@tahzw@bhW>7FUJEBDBeE4Vp?EMP>WEO`y( zt3fp09{U}B8B6qhGjR>=Zos`BH9==6Z0}Ttj;o%>e)8|B%~rGB>2tL+2PC-Ubyeo(bS z1n=K&^@AR5^MfWg$@*uJnPW*-hin#?bm8Rs2V|Q_L6qE(eerpA<>=v zF-Bar>CQvu6WC6+%h4?{x^d4!MiU55Hp^WsFmS=xSv%^vkCtR+Zg2B`Ofq3pcUPrO zGErq!q}agvPc4{Q@oS$qL3yklkYv6b_HN=F3ukei2tPJq4#OUsd1W<2sVEEhB$fd6 zZ_FX=o|*++*)ze0eUt&u&%JdmNnO}6bw0FUX18j>$$6Fs$YadUMg4GOBG%F5vf+2q zd-*6{FApH&QSRx(`F9l@lvX)7x|gy*Ttf-L@WdRxgm4QcQe}AnXlV&S*T@`T=lr7~ zCniD1re$oQB|E0!o&+{4L?euC5`z&0v@~jgNQ&h7!`sJJosr7GD}lY?r>J{zJU_ z)}(IknrYO}9{x-Ga$=%UH?#jk+;&1O1z$RU6dXBx2)h={MC!VxzX&$X@R0=ee~Z=M zTDAPE_;>sN=!v=^AtWvklZ1*AsKZ2Pi_v4XP7j{+1JRfZ&5kxFv`Oj{*xc~t%=~c$ z<4P;9%&B%E_lyj0gOzefF=^*KZ_SQWt*2Bx^E?yO5t`2&Pb&06fq4m1H~|%fMsaob z(~@FD_L1>qxpnqenM@DO${oJCe8Np>so zmHoMy`?R}cs2S-_mzJ+{3r}^H5pU%g1KQd=o7gezsC5nvO-Y6yE3f1FFNuzp4#}y{ zZ(7R3f@5QQ#mdPPhpF?-QAnkCd>)pa!Un}F_F1pi_2;Kj(jRltX@V3ga+Jc)Zuv(X<=jWAz&uvsfQR_K2PwecRzG*|s zb;#4tUeCBi<{Wl4Ves!c1{s~P<4`wHsxiACL)a_3)vX%@9i>Sza9SfXaaB7`z71Q zo-xr!MB($lRvFHQdh&k?Ri;gA%XHHr04|)7J9?&_5Ads_{Q2}?`_c5XJ%3EEWmcV1 zw{3UIoLd6xx#+gvyuaElIMpw*rGDqf_iabW(WOATz?9fo@OSZ2htDU>D*RqVP-YzXanU!t}6Rc#|#D%xbaZ?|S7DNSgYNmhIVK&9Q7F<9TVwSh}mi2ju&G01{ zV@;EH{NRV5BDNiUU>JKykBBLY-5pxe4M;y4Z@%urL=rCSQ1pHT`7_(n5dCiQb%((? zz{j)qR}#djrh4?uou4J>^%^c5B`dw zAvH7YL?malSNq)y)_+^j@hu?=%zr#;ypO%ne+O=iKC(9yboDF@e+%5K#!OlLGjM}L zwZVceJ`EeMptc|yo3AOk2Ey9^gUNyjkCf56LtkK8R5h3B ze%iY_Klji_jxfA3{;QOj7q_+beB6)y{Oxq6>Rb`18{O;6o>$q~c^7;Y>&(gQ+_HO* zXkvV|A~q@uS~!~ddDAo7qZC2=d-IJA$P)LnIcdjXyx6cRlytf?uFvWV;)AqGCv}Y6 z^Q6*FqP5^^b3MV#t%1xc%D3atP(O_4*9K=ak{L+GDtOhPjJxJNy#tfkL&&IPRKpzZ zp;g8~43vMwN6}uR9|uS4?0ykZyk;$75K%-+>oDH~2H4^1xgLQ;u&0TJZky|u?zF&* zJ%mFpOA@kjR;^rw5ae6uzsJM+Z(+h@#3%5!k9faSrlclK+UxV>C)samim3+Z_gO8) z?7A4E?=nIKXYaxr{0I-Wh%+x&bmlCgvD@LVXf=^}^5#HE!A<04YOD;zp*%#^r*dDm zIxNin;vu+e)%L2V2>^OeJGf!<2g|}%DUL}HPsUo?d{`GCxE%*Y%FZ@Y>&+XsY5cU(BJVlbJYpT*Uu zSMzHitBeqrmiR&-08wd@p{<=KNh*oqLND%X3v96|HccXFY_gLkO;>LLf>IZ)EV`jV zDKED#FRvrDlz{q!)NgeY8hdwEZWSQFg)oIUwDe<^L%tp`PWQpH98d@3!erDS@~hDx zmdBH;kixKitLcSO=FixcKOky( zegfZXzKVKC!pM|P{tnd_04^iGs5G63)ze^87{4|DDe^1rj!rV-@}v35WR8z=;%swX z$3h*z(+fTQs$m5deKB-T@t180`t*&7Z3D(dr+Tb|@Ll@-Vb6}4_Z3uf6kUXXT?1i_ zAHur_^GNufOy^N!XGd@ixSQ|^P&&`fe)9W=CH|+KJ{_@;t$o<(`p3k8@Mk;KwXyka ztoomse%R>8l_oD`y+Q}u_G!_J&cWIM;!ruW5d9l0mxcL{##7bUL%}2}C?qV#=i8jq ze);IHik-NG-8= zJV8=7K!lj0V0kvCeRn~+L$1^A2MQ_CE71kFRq~>YXN}hCIa}+;3-H)iuBuwLE&?`i zjA**zgZ*J6AaB;w{l{9`Zsn2QS7LGkXr=s78&9cD^DZZ#KTEUk3N0+zqe8)!NJ*H@ zAjHPO@Vs3vtHi3CTM@te>9r}#ndYJnuT&#(?rh6L&YSqpYCO&z9K>2zxnO@rd-dqL zM&MJl;Oh|f6;XxGkiYC>MY5rOfD~0#XesP|(_HWERicD3FV?OfCP8><;N$#pWZWW` zzu?@t3&i-OLVO*Y&tcr>9G`i<9D<|3I81=mBm7kd3rScNt2WMk8)US{&H}X>6BsfL z0^VSf_d#Rrg5S-ei1{UOkphjw1U=8l&d#uaDWf4c+_F!cv7`tQk?|1f|*4?6K5Su}r#|Mei@A7B9B^dk%B|97nL@0$L8y6X>J*&qDzzn=B_yNbW> zjsKyd;Db2*uHw%-<$s6&eFN+dI1v6n;eXi<`#b*cf%QM|heZEs{@-Etzk~nIm;V6E z(EStqH}?E@4S%PBe`si?|EGq(kix&?|4JbKKm!1s3;@7?(22jp|2nS!8E(S-C-^@{ Yd07dt5Ay;5U_V|!9~SD)_S?7r2dy~`cmMzZ literal 0 HcmV?d00001 From 035ebeb4ed0c4054cbc4eb551b05f20970f59240 Mon Sep 17 00:00:00 2001 From: megmugur Date: Sun, 29 Nov 2020 07:47:50 -0800 Subject: [PATCH 13/15] now working for multiple items - restructuring info and collection views --- browserApp/collection_view.py | 2 +- browserApp/info_view.py | 195 ++++++++++++++++------------------ 2 files changed, 90 insertions(+), 107 deletions(-) diff --git a/browserApp/collection_view.py b/browserApp/collection_view.py index 1bd151a..a935b0c 100644 --- a/browserApp/collection_view.py +++ b/browserApp/collection_view.py @@ -88,7 +88,7 @@ def on_item_clicked(self, file_path): # Todo: Does not work if there are an :param file_path: full path to the file or folder button that is clicked on. :type file_path: str""" if isfile(file_path): - self.info.populate(file_path) + self.info.display_info(file_path) else: print("Not handled folders yet. App is still under construction.") diff --git a/browserApp/info_view.py b/browserApp/info_view.py index 0e7e657..b016b7d 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -37,13 +37,6 @@ def __init__(self, parent=None): self.details_widget = QtWidgets.QTableWidget() - # self.top_bar_layout = QtWidgets.QHBoxLayout() - # self.top_bar_layout.addWidget(self.list_view_button) - # self.top_bar_layout.addWidget(self.icons_view_button) - - # self.list_view_button.clicked.connect(partial(self.switch_page, self.details_widget)) - # self.icons_view_button.clicked.connect(partial(self.switch_page, self.icons_widget)) - layout.addSpacing(1) layout.addWidget(self.details_widget) layout.addSpacing(1) @@ -54,36 +47,32 @@ def __init__(self, parent=None): self.details_header_list = ["Name", "Created", "Modified", "Type", "Size"] # Is this a string constant list?? self.details_row_index = 0 self.details_column_index = 0 - # self.data = {} - - # self.details_filename_buttons = {} - # self.buttons_dictionary = {} - self.file_name_label = QtWidgets.QLabel("") + # self.file_name_label = QtWidgets.QLabel("") self.setLayout(layout) self.setMinimumWidth(200) - def populate(self, file_path): + def display_info(self, file_path): """ Populates the Display area of the window with a stacked widget. The stacked widgets contains two pages: the Icons View page which shows the contents of the folder as a grid of buttons, and the Details View page which shows a table of files(buttons) and their details, eg., file size, date modified. Todo: Get file details from meta :param file_path: full path of the file :type file_path: str """ - # Clear List View: - clear_table_widget(self.details_widget) self.file_path = file_path + self.populate_info_table(file_path) self.reset_row_column_counts() - self.add_item_to_detailsview(file_path) def reset_row_column_counts(self): """ Resets the row and column indices to zero. """ - self.details_row_index = 0 - self.details_column_index = 0 + self.details_row_index = 1 + self.details_column_index = 1 + self.details_widget.rowCount = 1 + print("indexes", self.details_row_index, self.details_column_index) - def add_item_to_detailsview(self, file_path): + def populate_info_table(self, file_path): """ Populates the details table widget with data. If it is the first time it is being called, sets up the headers. :param file_path: path of the file to be added to the button @@ -92,22 +81,19 @@ def add_item_to_detailsview(self, file_path): if self.details_row_index == 0: create_table_widget_header(self.details_widget, self.details_header_list) - self.details_widget.insertRow(self.details_widget.rowCount()) # insert a row - item_data_object = model.file.FileItem(file_path) item_data = item_data_object.get_info() self.details_column_index += 1 + # column : filename file_name = path.split(file_path)[1] - self.file_name_label.setText(file_name) - self.details_widget.setCellWidget(0, 1, self.file_name_label) - + # self.file_name_label.setText(file_name) + self.details_widget.setItem(0, 1, QtWidgets.QTableWidgetItem(file_name)) file_item_object = model.file.FileItem(file_path) # to access details, create object of model.file FileItem item_data = file_item_object.get_info() # get the data dictionary and store in item_data + # column : date and time created if 'created' in item_data: - # date_created = datetime.datetime.fromtimestamp(float(item_data['created'])).strftime('%d/%m/%Y %H:%M') - # request this from data model in required format date_created = item_data['created'] self.details_widget.setItem(1, 1, QtWidgets.QTableWidgetItem(date_created)) @@ -115,10 +101,10 @@ def add_item_to_detailsview(self, file_path): date_modified = item_data['modified'] self.details_widget.setItem(2, 1, QtWidgets.QTableWidgetItem(date_modified)) - # # column : file type + # column : file type file_type = None if 'file_type' in item_data: - file_type = item_data['file_type'] + file_type = item_data['file_type'] elif file_name: file_type = file_name.split('.')[-1].upper() if file_type: @@ -126,42 +112,14 @@ def add_item_to_detailsview(self, file_path): # column : size if 'file_size' in item_data: - # file_size = convert_filesize_to_str(item_data['file_size']) # request this for folders from data model file_size = item_data['file_size'] # request this for folders from data model if type(file_size) is float: file_size = convert_filesize_to_str(file_size) self.details_widget.setItem(4, 1, QtWidgets.QTableWidgetItem(file_size)) - self.details_widget.setRowHeight(self.details_widget.rowCount() - 1, 25) - self.details_table_style() - def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name - """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with - contents of the folder that is clicked on. - :param file_path: full path to the file or folder button that is clicked on. - :type file_path: str""" - - print("Opening", file_path + "...") - if " " in file_path: - print("Cannot process file names with spaces, yet.") - return - if isfile(file_path): - system("start " + file_path) - else: - # if it's a folder, clear both pages, and populate with data inside that folder - self.clear_table_widget(self.details_widget) - file_item_object = model.file.FileItem(file_path) # to generate data, create object of model.file FileItem - self.populate(file_item_object) - - # def switch_page(self, selected_widget): - # """Switches current widget in the stack over to the selected widget. - # This method has been created in case I need to change the functionality such that the buttons for a view are - # created if, and only when, that view is selected. - # :param selected_widget: The widget from the stack that needs to be set as the current widget. - # :type selected_widget: QtWidget """ - # self.stacked_pages.setCurrentWidget(selected_widget) def details_table_style(self): """Sets the style for the details table widget.""" @@ -178,24 +136,6 @@ def details_table_style(self): "QTableCornerButton::section {background-color: transparent;}") self.details_widget.horizontalHeader().setDefaultAlignment(QtCore.Qt.AlignLeft) # left align header text - def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name - """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with - contents of the folder that is clicked on. - :param file_path: full path to the file or folder button that is clicked on. - :type file_path: str""" - - print("Opening", file_path + "...") - if " " in file_path: - print("Cannot process file names with spaces, yet.") - return - if path.isfile(file_path): - system("start " + file_path) - else: - # if it's a folder, clear both pages, and populate with data inside that folder - self.clear_table_widget(self.details_widget) - file_item_object = model.file.FileItem(file_path) # to generate data, create object of model.file FileItem - self.populate(file_item_object) - def clear_grid_layout(grid_layout): """ Deletes all the contents of a grid layout. :param grid_layout: The layout the needs to be cleared. @@ -207,38 +147,7 @@ def clear_grid_layout(grid_layout): if child.widget(): child.widget().deleteLater() - # def create_details_filename_buttons(self, button_name, file_path): - # """ Creates a button, and makes signal-slot connections for it. - # :param button_name: name of the button. - # :type button_name: str - # :param file_path: full path to the file that is to be listed on the button. - # :type file_path: str - # :return button: button with the file name on it. - # :rtype button: QPushButton widget - # """ - # button = QtWidgets.QPushButton(button_name) - # button.clicked.connect(partial(self.on_item_clicked, file_path)) - # set_button_style(button) - # - # if not isfile(file_path): - # return button - # # set button context menu policy - # button.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - # button.customContextMenuRequested.connect(partial(self.show_rightclick_menu, button)) - # self.buttons_dictionary[button] = file_path - # - # return button - # - - # def rename_item(self, button): - # file_path = self.buttons_dictionary[button] - # - # new_file_path = "some new name" - # if path.exists(file_path): - # rename(file_path, new_file_path) - -# def create_table_widget_header(widget, header_items): """ Creates the header for a table widget. :param widget: the widget for which the header needs to be added. @@ -251,7 +160,6 @@ def create_table_widget_header(widget, header_items): widget.setVerticalHeaderLabels(header_items) - def clear_table_widget(table): """ Clears the contents of the table. Keeps the headers intact. :param table: table widget that needs to be cleared. @@ -260,6 +168,7 @@ def clear_table_widget(table): table.clearContents() print("cleared table contents") + def set_button_style(button): """ Sets the button style and dimensions. No background or border by default. Turns blue with a border on hover. :param button: the buttons with the file name and icon on them. @@ -286,3 +195,77 @@ def convert_filesize_to_str(size_long): else: return str(size_long / (10 ** 9)) + " GB" + # def switch_page(self, selected_widget): + # """Switches current widget in the stack over to the selected widget. + # This method has been created in case I need to change the functionality such that the buttons for a view are + # created if, and only when, that view is selected. + # :param selected_widget: The widget from the stack that needs to be set as the current widget. + # :type selected_widget: QtWidget """ + # self.stacked_pages.setCurrentWidget(selected_widget) + + # def create_details_filename_buttons(self, button_name, file_path): + # """ Creates a button, and makes signal-slot connections for it. + # :param button_name: name of the button. + # :type button_name: str + # :param file_path: full path to the file that is to be listed on the button. + # :type file_path: str + # :return button: button with the file name on it. + # :rtype button: QPushButton widget + # """ + # button = QtWidgets.QPushButton(button_name) + # button.clicked.connect(partial(self.on_item_clicked, file_path)) + # set_button_style(button) + # + # if not isfile(file_path): + # return button + # # set button context menu policy + # button.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # button.customContextMenuRequested.connect(partial(self.show_rightclick_menu, button)) + # self.buttons_dictionary[button] = file_path + # + # return button + # + + # def rename_item(self, button): + # file_path = self.buttons_dictionary[button] + # + # new_file_path = "some new name" + # if path.exists(file_path): + # rename(file_path, new_file_path) + + # def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name + # """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with + # contents of the folder that is clicked on. + # :param file_path: full path to the file or folder button that is clicked on. + # :type file_path: str""" + # + # print("Opening", file_path + "...") + # if " " in file_path: + # print("Cannot process file names with spaces, yet.") + # return + # if isfile(file_path): + # system("start " + file_path) + # else: + # # if it's a folder, clear both pages, and display_info with data inside that folder + # self.clear_table_widget(self.details_widget) + # file_item_object = model.file.FileItem(file_path) # to generate data, create object of model.file FileItem + # self.display_info(file_item_object) + # + # def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name + # """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with + # contents of the folder that is clicked on. + # :param file_path: full path to the file or folder button that is clicked on. + # :type file_path: str""" + # + # print("Opening", file_path + "...") + # if " " in file_path: + # print("Cannot process file names with spaces, yet.") + # return + # if path.isfile(file_path): + # system("start " + file_path) + # else: + # # if it's a folder, clear both pages, and display_info with data inside that folder + # self.clear_table_widget(self.details_widget) + # file_item_object = model.file.FileItem(file_path) + # # to generate data, create object of model.file FileItem + # self.display_info(file_item_object) From dcaa8ca2f2c70c6ac5bce07c75943e76046998fe Mon Sep 17 00:00:00 2001 From: megmugur Date: Wed, 2 Dec 2020 21:40:34 -0800 Subject: [PATCH 14/15] Added features: single click shows details, double click opens file --- browserApp/collection_view.py | 179 ++++++++++++++++------------------ browserApp/info_view.py | 97 ++---------------- 2 files changed, 93 insertions(+), 183 deletions(-) diff --git a/browserApp/collection_view.py b/browserApp/collection_view.py index a935b0c..ee678e9 100644 --- a/browserApp/collection_view.py +++ b/browserApp/collection_view.py @@ -8,6 +8,7 @@ from os import path, system, remove, listdir from os.path import isfile, join import model +# from os import listdir, system, path, remove, rename from functools import partial import info_view @@ -19,19 +20,21 @@ def __init__(self, parent=None): layout = QtWidgets.QHBoxLayout() self.setLayout(layout) - self.icons_widget = QtWidgets.QWidget() - self.icons_layout = QtWidgets.QGridLayout() + self.icons_table_widget = QtWidgets.QTableWidget() + self.icons_table_widget.cellClicked.connect(self.on_item_clicked) + self.icons_table_widget.cellDoubleClicked.connect(self.open_item) + self.icon_image_path = "/images/folder_icon.png" self.icons_column_index = 0 self.icons_row_index = 0 self.NUMBER_OF_GRID_COLUMNS = 1 self.data = None + self.item_paths = [] - self.info = info_view.InfoView() + self.info_obj = info_view.InfoView() - layout.addWidget(self.icons_widget) - layout.addWidget(self.info) - # self.icons_filename_buttons = {} + layout.addWidget(self.icons_table_widget) + layout.addWidget(self.info_obj) def populate(self, data): """ Populates the Display area of the window with a stacked widget. The stacked widgets contains two pages: @@ -43,14 +46,15 @@ def populate(self, data): :type data: object of file.FileItem """ self.data = data meta = data.get_info() - print("Meta: ", meta) if 'full_path' not in meta: print("Could not find file path in dictionary") return item_path = meta['full_path'] # Clear Icons View: - clear_grid_layout(self.icons_layout) + self.icons_table_widget.clearContents() + self.item_paths = [] + # clear_grid_layout(self.icons_layout) self.reset_row_column_counts() # self.add_view_options_buttons(self.icons_layout) @@ -63,11 +67,9 @@ def populate(self, data): return # Directory: - self.icons_layout.setAlignment(QtCore.Qt.AlignTop) list_of_files = [] list_of_folders = [] list_of_files_and_folders = listdir(item_path) - print(list_of_files_and_folders) for item in list_of_files_and_folders: if path.isfile(path.join(item_path, item)): # Request addition of 'file_name' to data model. list_of_files.append(item) @@ -76,19 +78,29 @@ def populate(self, data): self.reset_row_column_counts() - self.icons_widget.setLayout(self.icons_layout) - self.icons_layout.setSpacing(0) # sets spacing between widgets in the layout to 0. + self.icons_table_widget.setRowCount(0) + self.icons_table_widget.setColumnCount(0) + self.icons_table_widget.insertColumn(0) + self.icons_table_widget.horizontalHeader().setVisible(False) for item in list_of_files_and_folders: + # insert a row + row_pos = self.icons_table_widget.rowCount() + self.icons_table_widget.insertRow(row_pos) + self.add_item_to_iconview(item, item_path) + self.item_paths.append(item_path + "\\" + item) - def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name + + def on_item_clicked(self, row): # Todo: Does not work if there are any spaces in the file name """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with contents of the folder that is clicked on. :param file_path: full path to the file or folder button that is clicked on. :type file_path: str""" + file_path = self.item_paths[row] + if isfile(file_path): - self.info.display_info(file_path) + self.info_obj.display_info(file_path) else: print("Not handled folders yet. App is still under construction.") @@ -134,12 +146,13 @@ def refresh_details_view(self): """ Refreshes the view. """ pass # Todo - def open_item(self, button): + def open_item(self, clicked_row): """ Opens the selected file. - :param button: button user clicks on. - :type button: QPushButton + :param clicked_row: row index of the cell in the table widget that the user clicks on + :type clicked_row: int """ - file_path = self.buttons_dictionary[button] + file_path = self.item_paths[clicked_row] + # file_path = self.buttons_dictionary[button] if path.exists(file_path): print("Opening", file_path + "...") if " " in file_path: @@ -149,7 +162,8 @@ def open_item(self, button): system("start " + file_path) else: # if it's a folder, clear both pages, and populate with data inside that folder - clear_grid_layout(self.icons_layout) + # clear_grid_layout(self.icons_layout) + self.icons_table_widget.clearContents() file_item_object = model.file.FileItem( file_path) # to generate data, create object of model.file FileItem self.populate(file_item_object) @@ -174,18 +188,26 @@ def add_item_to_iconview(self, file, file_path): :type file_path: str """ file_name = file.split('.')[0] # removes the extension. - button = QtWidgets.QPushButton(file_name) - set_button_style(button) if "." not in file_path: # Todo: use a different method. What if the file name contains a dot? full_path = join(file_path, file) else: full_path = file_path - add_icon_to_button(button, full_path) - button.clicked.connect(partial(self.on_item_clicked, full_path)) + # add_icon_to_button(button, full_path) + # button.clicked.connect(partial(self.on_item_clicked, full_path)) - self.icons_layout.addWidget(button, self.icons_row_index, self.icons_column_index) + cell_widget = TableCellWidget() + cell_widget.create_widget(file_name, full_path) + + self.icons_table_widget.setCellWidget(self.icons_table_widget.rowCount()-1, 0, cell_widget) + # self.icons_layout.addWidget(button, self.icons_row_index, self.icons_column_index) self.increment_grid_position() + self.set_table_style() + + def set_table_style(self): + self.icons_table_widget.setColumnWidth(0, 200) + self.icons_table_widget.verticalHeader().setVisible(False) + self.icons_table_widget.setShowGrid(False) def clear_grid_layout(grid_layout): @@ -199,80 +221,47 @@ def clear_grid_layout(grid_layout): if child.widget(): child.widget().deleteLater() +# def set_button_style(button): +# """ Sets the button style and dimensions. No background or border by default. Turns blue with a border on hover. +# :param button: the buttons with the file name and icon on them. +# :type button: QWidgets.QPushButton """ +# button.setFixedSize(250, 25) +# button.setStyleSheet(""" QPushButton {text-align:left; background-color: none; border: none; } +# QPushButton:hover { background-color: #CBE1F5 } +# QPushButton:pressed { border-width: 5px; background-color: #B7D9F9 } """) -def set_button_style(button): - """ Sets the button style and dimensions. No background or border by default. Turns blue with a border on hover. - :param button: the buttons with the file name and icon on them. - :type button: QWidgets.QPushButton """ - button.setFixedSize(250, 25) - button.setStyleSheet(""" QPushButton {text-align:left; background-color: none; border: none; } - QPushButton:hover { background-color: #CBE1F5 } - QPushButton:pressed { border-width: 5px; background-color: #B7D9F9 } """) - - -def add_icon_to_button(button, file_path): - """Adds an icon to the button that is the windows standard icon associated with that file. - :param button: button to which the icon is to be added - :type button: QPushButton - :param file_path: full path to the file whose icon is to be added to the button - :type file_path: str""" - file_info = QtCore.QFileInfo(file_path) - icon_provider = QtWidgets.QFileIconProvider() - icon = icon_provider.icon(file_info) - button.setIcon(icon) - - - - - - - - # self.label.clear() - # for label in self.labels: - # label.clear() - # self.layout().removeWidget(label) - # # self.labels.clear() - # - # child_count = items.children() - # if child_count == 0: - # return - # - # file_size = 0 - # - # for i in range(child_count): - # subitem = items.get_child(i) - # meta = subitem.get_info() - # - # if "file_size" in meta: - # file_size += meta["file_size"] - # - # self.label.setText("Total Size: %d" % file_size) - # - # keys = set() - # integer_values = {} - # - # # Get keys - # for i in range(child_count): - # subitem = items.get_child(i) - # meta = subitem.get_info() - # keys.update(meta.keys()) - # - # # Get cumulative key values - # for i in range(child_count): - # subitem = items.get_child(i) - # meta = subitem.get_info() - # - # for key in [x for x in keys if x in meta]: - # value = meta[key] - # if isinstance(value, int): - # if key not in integer_values: - # integer_values[key] = 0 - # integer_values[key] += value - # - # - # print(integer_values) +class TableCellWidget(QtWidgets.QWidget): + """Class to create a layout with a label and an icon. Useful for creating file buttons in a QTableWidget + for a file browser.""" + def __init__(self, parent=None): + super(TableCellWidget, self).__init__(parent) + + self.layout = QtWidgets.QHBoxLayout() + + # adjust spacings between the icon and the text + self.layout.setContentsMargins(0, 0, 0, 0) + + self.icon_label = QtWidgets.QLabel() + self.text_label = QtWidgets.QLabel() + self.layout.setAlignment(QtCore.Qt.AlignLeft) + self.setLayout(self.layout) + def create_widget(self, file_name, file_path): + self.create_icon(file_path) + self.layout.addWidget(self.icon_label) + self.text_label.setText(file_name) + self.layout.addWidget(self.text_label) + + def create_icon(self, file_path): + """ + :param file_path: full path to the file whose icon is to be added to the button + :type file_path: str""" + file_info = QtCore.QFileInfo(file_path) + icon_provider = QtWidgets.QFileIconProvider() + icon = icon_provider.icon(file_info) + pic = icon.pixmap(10, 10) + self.icon_label.setPixmap(pic) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index b016b7d..232f3d5 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -14,18 +14,17 @@ 10. Add LMB click options. """ +from os import path +import model.file + try: from PySide2 import QtWidgets, QtCore, QtGui + create_signal = QtCore.Signal except: from PyQt5 import QtWidgets, QtCore, QtGui - create_signal = QtCore.pyqtSignal -import datetime -from os import listdir, system, path, remove, rename -from os.path import isfile, join -from functools import partial -import model.file + create_signal = QtCore.pyqtSignal class InfoView(QtWidgets.QWidget): @@ -44,11 +43,10 @@ def __init__(self, parent=None): self.file_path = "" self.data = None - self.details_header_list = ["Name", "Created", "Modified", "Type", "Size"] # Is this a string constant list?? + self.details_header_list = ["Name", "Created", "Modified", "Type", "Size"] # Is this a string constant list?? self.details_row_index = 0 self.details_column_index = 0 - # self.file_name_label = QtWidgets.QLabel("") self.setLayout(layout) self.setMinimumWidth(200) @@ -70,7 +68,7 @@ def reset_row_column_counts(self): self.details_row_index = 1 self.details_column_index = 1 self.details_widget.rowCount = 1 - print("indexes", self.details_row_index, self.details_column_index) + # print("indexes", self.details_row_index, self.details_column_index) def populate_info_table(self, file_path): """ Populates the details table widget with data. @@ -120,7 +118,6 @@ def populate_info_table(self, file_path): self.details_table_style() - def details_table_style(self): """Sets the style for the details table widget.""" @@ -134,7 +131,7 @@ def details_table_style(self): "QHeaderView {background-color: transparent;" "border-right: 1px solid gray;}" "QTableCornerButton::section {background-color: transparent;}") - self.details_widget.horizontalHeader().setDefaultAlignment(QtCore.Qt.AlignLeft) # left align header text + self.details_widget.horizontalHeader().setDefaultAlignment(QtCore.Qt.AlignLeft) # left align header text def clear_grid_layout(grid_layout): """ Deletes all the contents of a grid layout. @@ -155,7 +152,7 @@ def create_table_widget_header(widget, header_items): :param header_items: List of headings for the table's columns :rtype header_items: list of strings """ - widget.setColumnCount(2) # ?? Re-think this. + widget.setColumnCount(2) # ?? Re-think this. widget.setRowCount(len(header_items)) widget.setVerticalHeaderLabels(header_items) @@ -166,7 +163,6 @@ def clear_table_widget(table): :type table: QtWidgets.QTableWidget """ table.clearContents() - print("cleared table contents") def set_button_style(button): @@ -194,78 +190,3 @@ def convert_filesize_to_str(size_long): return str(size_long / (10 ** 6)) + " MB" else: return str(size_long / (10 ** 9)) + " GB" - - # def switch_page(self, selected_widget): - # """Switches current widget in the stack over to the selected widget. - # This method has been created in case I need to change the functionality such that the buttons for a view are - # created if, and only when, that view is selected. - # :param selected_widget: The widget from the stack that needs to be set as the current widget. - # :type selected_widget: QtWidget """ - # self.stacked_pages.setCurrentWidget(selected_widget) - - # def create_details_filename_buttons(self, button_name, file_path): - # """ Creates a button, and makes signal-slot connections for it. - # :param button_name: name of the button. - # :type button_name: str - # :param file_path: full path to the file that is to be listed on the button. - # :type file_path: str - # :return button: button with the file name on it. - # :rtype button: QPushButton widget - # """ - # button = QtWidgets.QPushButton(button_name) - # button.clicked.connect(partial(self.on_item_clicked, file_path)) - # set_button_style(button) - # - # if not isfile(file_path): - # return button - # # set button context menu policy - # button.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - # button.customContextMenuRequested.connect(partial(self.show_rightclick_menu, button)) - # self.buttons_dictionary[button] = file_path - # - # return button - # - - # def rename_item(self, button): - # file_path = self.buttons_dictionary[button] - # - # new_file_path = "some new name" - # if path.exists(file_path): - # rename(file_path, new_file_path) - - # def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name - # """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with - # contents of the folder that is clicked on. - # :param file_path: full path to the file or folder button that is clicked on. - # :type file_path: str""" - # - # print("Opening", file_path + "...") - # if " " in file_path: - # print("Cannot process file names with spaces, yet.") - # return - # if isfile(file_path): - # system("start " + file_path) - # else: - # # if it's a folder, clear both pages, and display_info with data inside that folder - # self.clear_table_widget(self.details_widget) - # file_item_object = model.file.FileItem(file_path) # to generate data, create object of model.file FileItem - # self.display_info(file_item_object) - # - # def on_item_clicked(self, file_path): # Todo: Does not work if there are any spaces in the file name - # """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with - # contents of the folder that is clicked on. - # :param file_path: full path to the file or folder button that is clicked on. - # :type file_path: str""" - # - # print("Opening", file_path + "...") - # if " " in file_path: - # print("Cannot process file names with spaces, yet.") - # return - # if path.isfile(file_path): - # system("start " + file_path) - # else: - # # if it's a folder, clear both pages, and display_info with data inside that folder - # self.clear_table_widget(self.details_widget) - # file_item_object = model.file.FileItem(file_path) - # # to generate data, create object of model.file FileItem - # self.display_info(file_item_object) From d9b249c6c50c039d1543df5cc4ac05929e3a3fdd Mon Sep 17 00:00:00 2001 From: megmugur Date: Wed, 2 Dec 2020 22:22:09 -0800 Subject: [PATCH 15/15] Changed some of the documentation due to code changes --- browserApp/collection_view.py | 73 ++++++++++------------------------- browserApp/info_view.py | 49 ++++++----------------- 2 files changed, 32 insertions(+), 90 deletions(-) diff --git a/browserApp/collection_view.py b/browserApp/collection_view.py index ee678e9..a5f8f53 100644 --- a/browserApp/collection_view.py +++ b/browserApp/collection_view.py @@ -15,18 +15,17 @@ class CollectionView(QtWidgets.QWidget): def __init__(self, parent=None): + """Initializes the Qt Widgets and class variables.""" super(CollectionView, self).__init__(parent) layout = QtWidgets.QHBoxLayout() self.setLayout(layout) self.icons_table_widget = QtWidgets.QTableWidget() - self.icons_table_widget.cellClicked.connect(self.on_item_clicked) + self.icons_table_widget.cellClicked.connect(self.display_file_info) self.icons_table_widget.cellDoubleClicked.connect(self.open_item) self.icon_image_path = "/images/folder_icon.png" - self.icons_column_index = 0 - self.icons_row_index = 0 self.NUMBER_OF_GRID_COLUMNS = 1 self.data = None self.item_paths = [] @@ -37,13 +36,10 @@ def __init__(self, parent=None): layout.addWidget(self.info_obj) def populate(self, data): - """ Populates the Display area of the window with a stacked widget. The stacked widgets contains two pages: - the Icons View page which shows the contents of the folder as a grid of buttons, and - the Details View page which shows a table of files(buttons) and their details, eg., file size, date modified. - Todo: Get file details from meta - :param data: object that contains the full path of the folder(or file), the list of files it contains, and some - methods to help process the data. - :type data: object of file.FileItem """ + """ Populates the Collection View area of the app. + :param data: object that contains the full path of the folder(or file), the list of files it contains, and some + methods to help process the data. + :type data: object of file.FileItem """ self.data = data meta = data.get_info() if 'full_path' not in meta: @@ -54,9 +50,6 @@ def populate(self, data): # Clear Icons View: self.icons_table_widget.clearContents() self.item_paths = [] - # clear_grid_layout(self.icons_layout) - self.reset_row_column_counts() - # self.add_view_options_buttons(self.icons_layout) if 'type' not in meta: print("Could not find item type (file or folder) in dictionary.") @@ -76,8 +69,6 @@ def populate(self, data): else: list_of_folders.append(item) - self.reset_row_column_counts() - self.icons_table_widget.setRowCount(0) self.icons_table_widget.setColumnCount(0) self.icons_table_widget.insertColumn(0) @@ -91,13 +82,12 @@ def populate(self, data): self.add_item_to_iconview(item, item_path) self.item_paths.append(item_path + "\\" + item) - - def on_item_clicked(self, row): # Todo: Does not work if there are any spaces in the file name - """If user clicks on a file, opens it. If user clicks on a folder, clears the display and repopulates it with + def display_file_info(self, clicked_row): # Todo: Does not work if there are any spaces in the file name + """If user single clicks on a file, creates an object of InfoView which displays details about the file. contents of the folder that is clicked on. - :param file_path: full path to the file or folder button that is clicked on. - :type file_path: str""" - file_path = self.item_paths[row] + :param clicked_row: row index of the cell that the user clicks on + :type clicked_row: int""" + file_path = self.item_paths[clicked_row] if isfile(file_path): self.info_obj.display_info(file_path) @@ -147,7 +137,7 @@ def refresh_details_view(self): pass # Todo def open_item(self, clicked_row): - """ Opens the selected file. + """ If user double-clicks on a file, opens it. :param clicked_row: row index of the cell in the table widget that the user clicks on :type clicked_row: int """ @@ -168,20 +158,9 @@ def open_item(self, clicked_row): file_path) # to generate data, create object of model.file FileItem self.populate(file_item_object) - def increment_grid_position(self): - """Once a grid position is filled, this method is called, to point to the next position.""" - self.icons_column_index += 1 - if self.icons_column_index == self.NUMBER_OF_GRID_COLUMNS: # go to next row - self.icons_row_index += 1 - self.icons_column_index = 0 - - def reset_row_column_counts(self): - self.icons_column_index = 0 - self.icons_row_index = 0 - def add_item_to_iconview(self, file, file_path): - """Creates a button, sets its style, and adds it to the Icons View page. Makes signal-slot connections. - The button displays an icon and the file name. + """Creates a TableCellWidget widget. The widget's layout contains an icon and a text(file name). + Adds this widget to the next row in icons_table_widget. :param file: file name including extension. :type file: str :param file_path: full path to the file @@ -193,34 +172,19 @@ def add_item_to_iconview(self, file, file_path): full_path = join(file_path, file) else: full_path = file_path - # add_icon_to_button(button, full_path) - # button.clicked.connect(partial(self.on_item_clicked, full_path)) cell_widget = TableCellWidget() cell_widget.create_widget(file_name, full_path) self.icons_table_widget.setCellWidget(self.icons_table_widget.rowCount()-1, 0, cell_widget) - # self.icons_layout.addWidget(button, self.icons_row_index, self.icons_column_index) - self.increment_grid_position() self.set_table_style() def set_table_style(self): + """Sets the style of the icons_table_widget QtTableWidget""" self.icons_table_widget.setColumnWidth(0, 200) self.icons_table_widget.verticalHeader().setVisible(False) self.icons_table_widget.setShowGrid(False) - -def clear_grid_layout(grid_layout): - """ Deletes all the contents of a grid layout. - :param grid_layout: The layout the needs to be cleared. - :type grid_layout: QtWidgets.QGridLayout """ - - # clear all icons_filename_buttons - while grid_layout.count(): - child = grid_layout.takeAt(0) - if child.widget(): - child.widget().deleteLater() - # def set_button_style(button): # """ Sets the button style and dimensions. No background or border by default. Turns blue with a border on hover. # :param button: the buttons with the file name and icon on them. @@ -248,13 +212,18 @@ def __init__(self, parent=None): self.setLayout(self.layout) def create_widget(self, file_name, file_path): + """Creates the TableCellWidget widget for the given file name and path. + param file_name: name of the file to be displayed + type file_name: str + param file_path: path to the file + type file_path: str""" self.create_icon(file_path) self.layout.addWidget(self.icon_label) self.text_label.setText(file_name) self.layout.addWidget(self.text_label) def create_icon(self, file_path): - """ + """ Creates icon based on the default icon for the OS. :param file_path: full path to the file whose icon is to be added to the button :type file_path: str""" file_info = QtCore.QFileInfo(file_path) diff --git a/browserApp/info_view.py b/browserApp/info_view.py index 232f3d5..0f78e41 100644 --- a/browserApp/info_view.py +++ b/browserApp/info_view.py @@ -51,12 +51,9 @@ def __init__(self, parent=None): self.setMinimumWidth(200) def display_info(self, file_path): - """ Populates the Display area of the window with a stacked widget. The stacked widgets contains two pages: - the Icons View page which shows the contents of the folder as a grid of buttons, and - the Details View page which shows a table of files(buttons) and their details, eg., file size, date modified. - Todo: Get file details from meta - :param file_path: full path of the file - :type file_path: str """ + """ Populates Info View area of the app with details such as file name, extension, size, date created, etc. + :param file_path: full path of the file + :type file_path: str """ clear_table_widget(self.details_widget) self.file_path = file_path @@ -68,29 +65,25 @@ def reset_row_column_counts(self): self.details_row_index = 1 self.details_column_index = 1 self.details_widget.rowCount = 1 - # print("indexes", self.details_row_index, self.details_column_index) def populate_info_table(self, file_path): """ Populates the details table widget with data. If it is the first time it is being called, sets up the headers. - :param file_path: path of the file to be added to the button + :param file_path: path to the selected file :type file_path: str """ if self.details_row_index == 0: create_table_widget_header(self.details_widget, self.details_header_list) - item_data_object = model.file.FileItem(file_path) - item_data = item_data_object.get_info() self.details_column_index += 1 - # column : filename + # row : filename file_name = path.split(file_path)[1] - # self.file_name_label.setText(file_name) self.details_widget.setItem(0, 1, QtWidgets.QTableWidgetItem(file_name)) file_item_object = model.file.FileItem(file_path) # to access details, create object of model.file FileItem item_data = file_item_object.get_info() # get the data dictionary and store in item_data - # column : date and time created + # row : date and time created if 'created' in item_data: date_created = item_data['created'] self.details_widget.setItem(1, 1, QtWidgets.QTableWidgetItem(date_created)) @@ -99,7 +92,7 @@ def populate_info_table(self, file_path): date_modified = item_data['modified'] self.details_widget.setItem(2, 1, QtWidgets.QTableWidgetItem(date_modified)) - # column : file type + # row : file type file_type = None if 'file_type' in item_data: file_type = item_data['file_type'] @@ -108,10 +101,11 @@ def populate_info_table(self, file_path): if file_type: self.details_widget.setItem(3, 1, QtWidgets.QTableWidgetItem(file_type)) - # column : size + # row : size if 'file_size' in item_data: file_size = item_data['file_size'] # request this for folders from data model - if type(file_size) is float: + if file_size.isdigit(): + file_size = float(file_size) file_size = convert_filesize_to_str(file_size) self.details_widget.setItem(4, 1, QtWidgets.QTableWidgetItem(file_size)) @@ -133,17 +127,6 @@ def details_table_style(self): "QTableCornerButton::section {background-color: transparent;}") self.details_widget.horizontalHeader().setDefaultAlignment(QtCore.Qt.AlignLeft) # left align header text - def clear_grid_layout(grid_layout): - """ Deletes all the contents of a grid layout. - :param grid_layout: The layout the needs to be cleared. - :type grid_layout: QtWidgets.QGridLayout """ - - # clear all icons_filename_buttons - while grid_layout.count(): - child = grid_layout.takeAt(0) - if child.widget(): - child.widget().deleteLater() - def create_table_widget_header(widget, header_items): """ Creates the header for a table widget. @@ -162,17 +145,7 @@ def clear_table_widget(table): :param table: table widget that needs to be cleared. :type table: QtWidgets.QTableWidget """ - table.clearContents() - - -def set_button_style(button): - """ Sets the button style and dimensions. No background or border by default. Turns blue with a border on hover. - :param button: the buttons with the file name and icon on them. - :type button: QWidgets.QPushButton """ - button.setFixedSize(250, 25) - button.setStyleSheet(""" QPushButton {text-align:left; background-color: none; border: none; } - QPushButton:hover { background-color: #CBE1F5 } - QPushButton:pressed { border-width: 5px; background-color: #B7D9F9 } """) + table.clearContents() # Might remove this method if not other operations are needed here. def convert_filesize_to_str(size_long):