#!/usr/bin/env python2 # -*- coding: utf-8 -*- # # sticknot.py # # A hierarchical note-taking application for GTK # # (C) 2014 inkeso # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # # import os, gtk, pango, gobject, re ####################### # Configfile template # ####################### CONFIGFILE = os.path.expanduser("~/.config/sticknot.conf") # config-file will be written, when program is closed. (see SticKnot.on_close()) TEMPLATE = """# SticKnot-config # Notes are stored in this folder (and subfolders). Textfiles with a little bit markup. PATH = "%s" # Window-Size: (width, height) or None for minimal size SIZE = %s # Window-Position: (x, y) or None for letting your WM decide POSITION = %s # Set to True to deactivate Windowmanager-decoration. BORDERLESS = %s # If set to True, SticKnot won't show up in your taskbar. SKIPTASKBAR = %s # Value between 1.0 (fully visible) and 0.0 (completly transparent) OPACITY = %f # Saved on Exit to reopen last note on next start CURRENTFILE = "%s" # Remember scrollbar-position in current note and cursor-position SCROLLPOSITION = %s """ # DEFAULTS PATH = "~/.sticknot" #SIZE = (640, 480) #POSITION = None BORDERLESS = False SKIPTASKBAR = False OPACITY = 1.0 # try to load config try: cfg = open(CONFIGFILE, "r") exec cfg.read() cfg.close() print "config loaded:", CONFIGFILE except: pass print "notes are stored at", PATH # increase handle-size (may not be neccessary with a different theme) gtk.rc_parse_string("style 'ihs' { GtkPaned::handle-size = 6 } widget '*' style 'ihs'") ################################################################################ class RichEditor(gtk.VBox): # Format-tags Tags = [ ["Reset", {}], ["Bold", {'weight': pango.WEIGHT_BOLD }], ["Italic", {'style': pango.STYLE_ITALIC }], ["Mono", {'family': "monospace", 'wrap_mode': gtk.WRAP_NONE }], ["Red", {'background': "#fbb", 'foreground': "#800" }], ["Green", {'background': "#bfb", 'foreground': "#080" }], ["Blue", {'background': "#bbf", 'foreground': "#008" }], ["Yellow", {'background': "#ffb", 'foreground': "#220" }], ["H1", {'size': 14 * pango.SCALE, 'weight': pango.WEIGHT_BOLD, 'rise': 16 * pango.SCALE }], ["H2", {'size': 12 * pango.SCALE, 'weight': pango.WEIGHT_BOLD, 'rise': 14 * pango.SCALE }], ["H3", {'size': 10 * pango.SCALE, 'weight': pango.WEIGHT_BOLD, 'rise': 12 * pango.SCALE }], ["A", {'underline': pango.UNDERLINE_SINGLE, 'foreground': "#FFAF00" }] ] __gsignals__ = { 'urlclicked': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)) } def __init__(self): TagEvents = { 'A': self.on_urlclick } self.currentfile = None # currently opened filename (full path) # scrollable editor self.scrolledit = gtk.ScrolledWindow() self.scrolledit.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) self.tb = gtk.TextBuffer() self.te = gtk.TextView(self.tb) self.te.set_wrap_mode(gtk.WRAP_WORD) self.te.set_size_request(200, 200) self.scrolledit.add(self.te) self.dform = self.tb.register_deserialize_format("text/sticknotml", self.deserialize) self.sform = self.tb.register_serialize_format("text/sticknotml", self.serialize) # create some tags for formating and add them to a toolbar self.formatbar = gtk.Toolbar() self.formatbar.set_style(gtk.TOOLBAR_TEXT) for tag, form in self.Tags: tagobj = self.tb.create_tag(tag, **form) if tag in TagEvents.keys(): # Add events (if any) tagobj.connect("event", TagEvents[tag]) mi = gtk.ToolButton(label=tag) mi.connect("clicked", self.on_applyformat, tag) self.formatbar.insert(mi, -1) # reuse last toolbutton to create large pixmaps for dir and error self.pbdir = mi.render_icon(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_DIALOG) self.pberr = mi.render_icon(gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_DIALOG) #self.widget = gtk.VBox() #self.widget.pack_start(self.formatbar, expand=False) #self.widget.pack_start(self.scrolledit, expand=True) gtk.VBox.__init__(self) self.pack_start(self.formatbar, expand=False) self.pack_start(self.scrolledit, expand=True) self.te.connect("motion-notify-event", self.hover) self.standard_cursor = gtk.gdk.Cursor(gtk.gdk.XTERM) self.link_cursor = gtk.gdk.Cursor(gtk.gdk.HAND2) def hover(self, win, ev): itr = win.get_iter_at_location(int(ev.x), int(ev.y)) if itr.has_tag(self.tb.tag_table.lookup("A")): win.get_window(gtk.TEXT_WINDOW_TEXT).set_cursor(self.link_cursor) else: win.get_window(gtk.TEXT_WINDOW_TEXT).set_cursor(self.standard_cursor) def on_urlclick(self, texttag, widget, event, itr): if event.type == gtk.gdk.BUTTON_RELEASE and event.button == 1: # get iter at mouseposition bx, by = self.te.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT, int(event.x), int(event.y)) off = self.te.get_iter_at_location(bx, by).get_offset() aqui = self.tb.get_iter_at_offset(off) # look for start of this tag if not aqui.begins_tag(texttag): aqui.backward_to_tag_toggle(texttag) # recort start & search for end start = aqui.copy() aqui.forward_to_tag_toggle(texttag) # aquire link-text and emit event with it link = self.tb.get_text(start, aqui) self.emit("urlclicked", link) def on_applyformat(self, widget, tag): # get selection, apply selected format-tag if self.tb.get_has_selection(): selc = self.tb.get_selection_bounds() if tag == "Reset": self.tb.remove_all_tags(*selc) else: self.tb.apply_tag_by_name(tag, *selc) self.tb.set_modified(True) def serialize(self, rb, cb, start, end): """ rb is the textbuffer that the format is registered with cb is the textbuffer containing the text to be serialized start and end are textiters bounding the text to be serialized function should return the serialized data """ # insert start-tags (if any) targ = "".join(["<%s>" % p.get_property("name") for p in start.get_toggled_tags(True)]) while start.get_offset() != end.get_offset(): # find next tag nxt = start.copy() nxt.forward_to_tag_toggle(None) # append text from start to next (and replace markup-chars) targ += start.get_text(nxt).replace("<","<").replace(">",">") # insert off-tag(s) targ += "".join(["" % p.get_property("name") for p in nxt.get_toggled_tags(False)]) # insert on-tag(s) targ += "".join(["<%s>" % p.get_property("name") for p in nxt.get_toggled_tags(True)]) # jump start = nxt return targ def deserialize(self, rb, cb, itr, data, creatag): """ rb is the textbuffer that the format is registered with cb is the textbuffer that data will be deserialized into data is the string to be deserialized itr is a textiter indicating the start of the deserialized data in cb creatag is a boolean indicating if tags should be created during deserialization function should return True if the data was successfully deserialized """ # get a list of valid tags valid = [] cb.get_tag_table().foreach(lambda x,y: y.append("<%s>" % x.get_property("name")), valid) # tagstack is a dict of lists: each tag is a key, the list represents the stack. # each list-item is a charcount (startpoint of this tag). # the last opened tag will be applied, once it's closed. tagstack = {} for t in valid: tagstack[t] = [] foundtags = filter(lambda t: t.replace("/","") in valid, re.findall("<.+?>", data)) if len(foundtags) == 0: # no tags? insert all, replacing markup-chars cb.insert(itr, data.replace("<","<").replace(">",">")) return True # some tags found. should be an even number assert len(foundtags) % 2 != 1, "Odd number of tags in textfile. Loading failed." for tag in foundtags: ti = data.find(tag) # insert text from current index up to the first tag, replacing markup-chars cb.insert(itr, data[:ti].replace("<","<").replace(">",">")) # truncate data data = data[ti + len(tag):] # TODO: avoid get_end_iter, because it may fail, if we are inserting into # a nonempty buffer. Should never happen, ATM, so it's less urgent itr = cb.get_end_iter() if tag[1] != "/": # open-tag; record to tagstack tagstack[tag].append(cb.get_char_count()) else: # close-tag; pop last iter, if any, and apply TextTag tag = tag.replace("/","") assert len(tagstack[tag]) > 0, "Nonmatching close-tag %s found. Loading Failed." % tag cb.apply_tag_by_name(tag[1:-1], cb.get_iter_at_offset(tagstack[tag].pop()), itr) # tagstack empty? for k, v in tagstack.items(): assert len(v) == 0, "Tag %s left open. Loading failed." % k # append last part (from last tag to EOF) cb.insert(itr, data.replace("<","<").replace(">",">")) return True def clear(self): self.tb.set_text("") self.tb.set_modified(False) self.currentfile = None return self.tb.get_start_iter() def load(self, path): # reset current file & clear buffer ti = self.clear() if os.path.isdir(path): # if path is a directory: show brief info self.tb.insert_pixbuf(ti, self.pbdir) self.tb.insert_with_tags_by_name(ti, " Folder \n\n", "H1") self.tb.insert_with_tags_by_name(ti, path, "Mono") else: # if it's a file: load into editor try: fin = open(path, "r") cont = fin.read() if cont: self.tb.deserialize(self.tb, self.dform, ti, cont) fin.close() self.currentfile = path print "loaded:", path except Exception as e: self.tb.insert_pixbuf(ti, self.pberr) self.tb.insert_with_tags_by_name(ti, " Error \n\n", "H1") self.tb.insert_with_tags_by_name(ti, str(e), "Mono") self.tb.set_modified(False) def save(self): # save current file, if modified if self.currentfile and self.tb.get_modified(): text = self.tb.serialize(self.tb, self.sform, *self.tb.get_bounds()) try: fout = open(self.currentfile, "w") fout.write(text) fout.close() print "saved:", self.currentfile self.tb.set_modified(False) except: self.dlg.message("Could not save file " + self.currentfile) return False return True def get_pos(self): # get scrollbarpositions. return a tuple of x and y offset and cursor-position return (self.scrolledit.get_hadjustment().get_value(), self.scrolledit.get_vadjustment().get_value(), self.tb.get_iter_at_mark(self.tb.get_insert()).get_offset()) def set_pos(self, pos): # get scrollbarpositions. return a tuple of x and y offset self.scrolledit.get_hadjustment().set_value(pos[0]) self.scrolledit.get_vadjustment().set_value(pos[1]) self.tb.place_cursor(self.tb.get_iter_at_offset(pos[2])) def focus(self): self.te.grab_focus() class Dialogs: ''' show a message. ''' def message(self, message): dialog = gtk.MessageDialog(None, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_INFO, gtk.BUTTONS_OK, message) dialog.set_border_width(10) dialog.run() dialog.destroy() ''' show a confirmation-dialog (OK/Cancel) return True if OK. ''' def confirm(self, message): label = gtk.Label("\n"+message+"\n") dialog = gtk.Dialog("Confirm", None, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, (gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_OK, gtk.RESPONSE_ACCEPT)) dialog.set_border_width(10) dialog.vbox.pack_start(label) label.show() response = dialog.run() dialog.destroy() return (response == gtk.RESPONSE_ACCEPT) ''' show a simple input-dialog. return input-text or None ''' def input(self, message, eTxt=""): label = gtk.Label("\n"+message+"\n") dialog = gtk.Dialog("Input", None, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, (gtk.STOCK_OK, gtk.RESPONSE_ACCEPT, gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT)) dialog.set_border_width(10) dialog.vbox.pack_start(label) label.show() entry = gtk.Entry() entry.set_text(eTxt) dialog.vbox.pack_end(entry) entry.show() entry.grab_focus() entry.connect("activate", lambda w: dialog.response(gtk.RESPONSE_ACCEPT)) response = dialog.run() rtxt = entry.get_text() dialog.destroy() if response == gtk.RESPONSE_ACCEPT and len(rtxt) > 0: return rtxt else: return None class SticKnot: # Toolbar-buttons Buttons = [ ["New Note", gtk.STOCK_NEW, "on_newnote"], ["New Folder", gtk.STOCK_DIRECTORY, "on_newfolder"], ["Rename", gtk.STOCK_SAVE_AS, "on_rename"], ["Delete", gtk.STOCK_DELETE, "on_delete"], ["Refresh", gtk.STOCK_REFRESH, "on_refresh"] ] def __init__(self): self.notes = os.path.expanduser(PATH) # actual path self.files = [] # contains a recursive list of files. See self.on_refresh() # Filetree (in a scrolled view) self.scrolltree = gtk.ScrolledWindow() self.scrolltree.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) self.treestore = gtk.TreeStore(str, gtk.gdk.Pixbuf) self.treeview = gtk.TreeView(self.treestore) # TreeViewColumn containing icon & filename one = gtk.TreeViewColumn("Notes") crpb = gtk.CellRendererPixbuf() crtx = gtk.CellRendererText() one.pack_start(crpb, expand=False) one.pack_start(crtx, expand=True) one.add_attribute(crpb, 'pixbuf', 1) one.add_attribute(crtx, 'text', 0) self.treeview.append_column(one) self.treeview.set_headers_visible(False) self.treeview.set_search_column(0) self.scrolltree.add(self.treeview) self.treeview.connect("cursor_changed", self.on_filechange) # Allow enable drag and drop of rows self.treeview.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [('TREEROW', gtk.TARGET_SAME_WIDGET, 0)], gtk.gdk.ACTION_DEFAULT|gtk.gdk.ACTION_MOVE) self.treeview.enable_model_drag_dest([('TREEROW', gtk.TARGET_SAME_WIDGET, 0)], gtk.gdk.ACTION_DEFAULT) self.treeview.connect("drag_data_get", self.drag_get_data) self.treeview.connect("drag_data_received", self.drag_rec_data) # Buttons self.butts = gtk.Toolbar() self.butts.set_style(gtk.TOOLBAR_ICONS) self.butts.set_tooltips(True) self.butts.set_size_request(170, -1) for (n, s, e) in self.Buttons: img = gtk.Image() img.set_from_stock(s, gtk.ICON_SIZE_BUTTON) mi = gtk.ToolButton(img, n) mi.set_tooltip_text(n) mi.connect("clicked", eval("self."+e)) self.butts.insert(mi, -1) # reuse the last (temporary) Image-Object for creating two pixmaps for # files & folders self.pbdir = mi.render_icon(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU) self.pbfile = mi.render_icon(gtk.STOCK_FILE, gtk.ICON_SIZE_MENU) # combine tree and buttons self.leftpane = gtk.VBox() self.leftpane.pack_start(self.scrolltree, expand=True, fill=True) self.leftpane.pack_start(self.butts, expand=False, fill=True) # editor self.editor = RichEditor() self.editor.connect("urlclicked", self.urlopen) # Tree and editor in a HPane self.hpane = gtk.HPaned() self.hpane.set_border_width(6) self.hpane.pack1(self.leftpane, shrink=False) self.hpane.pack2(self.editor, shrink=False) # Main Window self.wind = gtk.Window(gtk.WINDOW_TOPLEVEL) self.wind.set_title("SticKnot") self.wind.set_icon(mi.render_icon(gtk.STOCK_EDIT, gtk.ICON_SIZE_MENU)) self.wind.connect("delete_event", self.on_close) self.wind.connect("event", self.on_generic) self.wind.add(self.hpane) self.wind.set_opacity(OPACITY) self.wind.set_decorated(not BORDERLESS) self.wind.set_skip_taskbar_hint(SKIPTASKBAR) try: self.wind.resize(*SIZE) except: pass try: self.wind.move(*POSITION) except: pass self.dlg = Dialogs() def main(self): # Check for notes-dir (create if neccessary) if not os.path.exists(self.notes): mkdir = self.dlg.confirm("Folder »%s« doesn't exists. Create?" % self.notes) if (mkdir): try: os.makedirs(self.notes) except: self.dlg.message("Creation of directory failed.") else: exit(1) try: # setting currentfile from config if CURRENTFILE != "None": self.editor.currentfile = CURRENTFILE except: pass self.on_refresh(None) # load filetree # Ready to go: paint window self.wind.show_all() while gtk.events_pending(): gtk.main_iteration() try: # setting scrollposition and cursor in textview self.editor.set_pos(SCROLLPOSITION) self.editor.focus() except: pass gtk.main() def get_tree(self, sDir=None): if sDir == None: sDir = self.notes tree = { "path": sDir, "name": os.path.basename(sDir), "files": [], "dirs": [] # contains a list of subtrees of same format } for fnam in os.listdir(sDir): if fnam[0] == '.': continue # ignore hidden files if fnam == 'lost+found': continue # ignore journal nKey = os.path.join(sDir, fnam) if os.path.isdir(nKey): tree["dirs"].append(self.get_tree(nKey)) else: tree["files"].append(nKey) # sort tree["files"].sort(key=str.lower) tree["dirs"].sort(key=lambda x: x["name"].lower()) return tree def add_treeview(self, tree, root=None): # add subdirs for subtree in tree["dirs"]: piter = self.treestore.append(root, [subtree["name"], self.pbdir]) self.add_treeview(subtree, piter) # add files for leaf in tree["files"]: self.treestore.append(root, [os.path.basename(leaf), self.pbfile]) def on_refresh(self, widget, data=None): # repopulate Treeview self.files = self.get_tree() self.treestore.clear() self.add_treeview(self.files) self.treeview.expand_all() # jump to currentfile, if any if self.editor.currentfile: # find path in tree & set cursor to it def func(model, path, itr): if self.editor.currentfile == self.path_from_iter(itr): self.tpath = path self.treestore.foreach(func) self.treeview.set_cursor(self.tpath) def drag_get_data(self, treeview, context, selection, target_id, etime): itr = treeview.get_selection().get_selected()[1] selection.set(selection.target, 8, self.path_from_iter(itr)) def drag_rec_data(self, tv, context, x, y, sel, info, etime): drop_info = tv.get_dest_row_at_pos(x, y) targpath = self.notes if drop_info: targpath = self.path_from_iter(tv.get_model().get_iter(drop_info[0])) if os.path.isfile(targpath): targpath = os.path.split(targpath)[0] targpath = os.path.join(targpath, os.path.basename(sel.data)) print "move:", sel.data, "-->", targpath os.rename(sel.data, targpath) self.editor.currentfile = targpath self.on_refresh(None) def on_newnote(self, widget, data=None): # create new file in current folder (or top if none) curp = self.path_from_cursor() if curp is None: curp = self.notes if os.path.isfile(curp): curp = os.path.split(curp)[0] path = self.dlg.input("Create new note in »%s«" % curp) if path is not None: path = os.path.join(curp, path) open(path, "w").close() print "new file:", path self.editor.load(path) self.on_refresh(None) def on_newfolder(self, widget, data=None): # create new subfolder in current folder (or top if none) curp = self.path_from_cursor() if curp is None: curp = self.notes if os.path.isfile(curp): curp = os.path.split(curp)[0] path = self.dlg.input("Create new folder in »%s«" % curp) if path: path = os.path.join(curp, path) os.makedirs(path) print "new dir: ", path self.editor.currentfile = path self.on_refresh(None) def on_rename(self, widget, data=None): # rename (and reopen) current file curfile = self.editor.currentfile or self.path_from_cursor() curf = os.path.split(curfile) newname = self.dlg.input("Rename", curf[1]) if newname: newf = os.path.join(curf[0], newname) os.rename(curfile, newf) print "rename:", curfile, "-->", newf self.editor.currentfile = newf self.on_refresh(None) def on_delete(self, widget, data=None): # delete current file delfile = self.editor.currentfile or self.path_from_cursor() def delnode(arg): if os.path.isfile(arg): os.remove(arg) print "deleted file:", arg else: for f in os.listdir(arg): delnode(os.path.join(arg, f)) os.rmdir(arg) print "deleted dir: ", arg if self.dlg.confirm("Delete %s »%s« ?" % ("Folder" if os.path.isdir(delfile) else "Note", delfile)): delnode(delfile) self.editor.currentfile = None self.editor.clear() self.on_refresh(None) def path_from_cursor(self): # return the path of the currently selected file or folder try: itr = self.treestore.get_iter(self.treeview.get_cursor()[0]) return self.path_from_iter(itr) except: return None def path_from_iter(self, itr): # get filepath from a given treeiter cfile = [self.treestore.get_value(itr, 0)] # prepend parents for o in range(self.treestore.iter_depth(itr)): itr = self.treestore.iter_parent(itr) cfile.insert(0, self.treestore.get_value(itr, 0)) # prepend notes-dir and join everything to full absolute path cfile.insert(0, self.notes) return os.path.join(*cfile) def on_filechange(self, widget, data=None): # eventhandler for selecting a different row in the treeview. # save the current one and load the new one if not self.editor.save(): return self.editor.load(self.path_from_cursor()) def on_close(self, widget, data=None): if self.editor.save(): # export settings try: cfg = open(CONFIGFILE, "w") cfg.write(TEMPLATE % ( PATH, self.wind.get_size(), self.wind.get_position(), BORDERLESS, SKIPTASKBAR, OPACITY, self.editor.currentfile, self.editor.get_pos() )) cfg.close() print "config written to:", CONFIGFILE except: print "Couldn't save config" gtk.main_quit() return False else: return True def on_generic(self, widget, data=None): # all events pass through this function, so we can close-on-Esc if data.type == gtk.gdk.KEY_PRESS and data.keyval == gtk.keysyms.Escape: self.on_close(None) return False def urlopen(self, event, url): print url os.system("firefox \""+url+"\"") sk = SticKnot() sk.main()