3 # Released under the terms of the GPLv3
14 from curses
.wrapper
import wrapper
15 #from curses.textpad import Textbox
16 from TextboxPad
import TextboxPad
17 import curses
.ascii
as ascii
21 from IndexedHypertext
import IndexedHypertext
23 class QuitException(Exception):
27 def __init__(self
, scr
, config
):
29 self
.settings
= config
35 curses
.init_pair(i
, i
, curses
.COLOR_BLACK
)
42 self
.delay
= 60.0/self
.wpm
45 self
.lastSearch
= None
47 self
.indexedHypertext
= None
49 self
.cachedDocuments
= []
51 # history *ends* with most recent url
52 # unHistory *ends* with *current* url
56 self
.tricky
= [""]*self
.trickyDisplayNum
62 self
.normalWpm
= self
.wpm
64 self
.interruptible
= False
67 self
.commandHistory
= []
68 self
.searchHistory
= []
71 marksFile
= open(os
.getenv("HOME")+"/.flinksMarks", 'r')
72 self
.globalMarks
= pickle
.load(marksFile
)
76 # XXX: we use self.wpm as convenient shorthand for
77 # self.settings.options["wpm"], etc. The overriding below of __getattr__
78 # and __setattr__ just implement this.
79 def __getattr__(self
, name
):
80 if name
!= "settings" and name
in self
.settings
.optionNames
:
81 return self
.settings
.options
[name
]
84 def __setattr__(self
, name
, value
):
85 if name
!= "settings" and name
in self
.settings
.optionNames
:
86 self
.settings
.options
[name
] = value
88 self
.__dict
__[name
] = value
91 def __init__(self
, scr
, desiredLinkDisplayNum
, desiredTrickyDisplayNum
):
92 (maxy
, maxx
) = scr
.getmaxyx()
94 # effective maximums, i.e. with anything bigger scr.move(y,x)
96 self
.maxy
= max(0, maxy
-2)
97 self
.maxx
= max(0, maxx
-1)
99 # we aim for following layout, and squash as necessary
100 # desiredLinkDisplayNum lines of links
104 # desiredTrickyDisplayNum lines of trickies
108 squashed
= self
.maxy
< (desiredLinkDisplayNum
+
109 desiredTrickyDisplayNum
+ 6)
111 self
.wordline
= self
.maxy
/2
113 self
.linkDisplayNum
= min(desiredLinkDisplayNum
,
115 self
.trickyDisplayNum
= min(desiredTrickyDisplayNum
,
116 (self
.maxy
- self
.wordline
) - 2)
118 self
.linkline
= self
.wordline
- self
.linkDisplayNum
- (not squashed
)
120 self
.trickyline
= self
.wordline
+ (self
.maxy
> self
.wordline
) + (not squashed
)
121 self
.statusline
= self
.trickyline
+ self
.trickyDisplayNum
122 self
.infoline
= self
.statusline
+ (self
.maxy
> self
.statusline
)
123 self
.commandline
= self
.infoline
+ (self
.maxy
> self
.infoline
)
126 self
.geom
= self
.Geom(self
.scr
, self
.linkDisplayNum
,
127 self
.trickyDisplayNum
)
129 def blockGetKey(self
, promptLine
=None, prompt
=None):
130 if promptLine
and prompt
:
131 self
.displayCleanCentred(promptLine
, prompt
)
133 key
= self
.scr
.getkey()
135 if promptLine
and prompt
:
136 self
.clearLine(promptLine
)
137 if key
== '\x1b' or key
== '\x07':
141 def displayCentred(self
, line
, str, attr
=0):
142 self
.scr
.addstr(line
,
143 max(0,(self
.geom
.maxx
-len(str))/2),
144 str[:self
.geom
.maxx
],
146 def clearLine(self
, line
):
147 self
.scr
.move(line
,0)
149 def displayCleanCentred(self
, line
, str, attr
=0):
151 self
.displayCentred(line
, str, attr
)
153 def displayStatus(self
):
154 if self
.geom
.statusline
== self
.geom
.wordline
: return
155 def getStatusLine(abbreviated
=False):
157 text
= 'WPM: %d' % self
.wpm
159 text
+= '; word: %d' % (self
.indexedHypertext
.atWord
+1)
160 text
+= '; sentence: %d' % (self
.indexedHypertext
.atSentence
+1)
161 text
+= '; paragraph: %d' % (self
.indexedHypertext
.atPara
+1)
162 text
+= '; link: %d' % (self
.indexedHypertext
.lastLink
+1)
163 if self
.paused
: text
+= '; PAUSED'
165 text
= '%d ' % self
.wpm
167 text
+= '%d/' % (self
.indexedHypertext
.atWord
+1)
168 text
+= '%d/' % (self
.indexedHypertext
.atSentence
+1)
169 text
+= '%d/' % (self
.indexedHypertext
.atPara
+1)
170 text
+= '%d ' % (self
.indexedHypertext
.lastLink
+1)
171 if self
.paused
: text
+= 'P'
174 self
.clearLine(self
.geom
.statusline
)
176 line
= getStatusLine(self
.abbreviate
)
177 if len(line
) >= self
.geom
.maxx
and not self
.abbreviate
:
178 line
= getStatusLine(True)
179 if len(line
) >= self
.geom
.maxx
:
182 self
.displayCentred(self
.geom
.statusline
, line
)
184 def displayLinkListEntry(self
, linknum
, emph
=False):
185 if self
.geom
.linkDisplayNum
<= 0: return
186 line
= self
.geom
.linkline
+ linknum
%self
.geom
.linkDisplayNum
190 attr
= self
.attrOfLinknum(linknum
)
192 attr |
= curses
.A_BOLD
196 link
= self
.indexedHypertext
.links
[linknum
]
197 str = '%s%s "%s": %s' % (
198 emphstr
, self
.strOfLinknum(linknum
),
199 self
.abbreviateStr(self
.cleanWord(link
["word"]), 10),
200 self
.abbreviateStr(link
["url"])
203 self
.displayCentred(line
, str, attr
)
204 def displayLinkList(self
):
205 for linknum
in range(
206 self
.indexedHypertext
.lastLink
- self
.geom
.linkDisplayNum
,
207 self
.indexedHypertext
.lastLink
+1):
208 emphasize
= (self
.indexedHypertext
.lastLink
- linknum
<
209 self
.linkDisplayEmphNum
)
210 self
.displayLinkListEntry(linknum
, emphasize
)
212 def attrOfLinknum(self
, linknum
):
213 if self
.geom
.linkDisplayNum
<= 0: return 1
214 c
= (linknum
%self
.geom
.linkDisplayNum
)%5+1
215 if c
>= 4: c
+=1 # don't use blue (hard to see against black)
216 return curses
.color_pair(c
)
218 def strOfLinknum(self
, linknum
):
219 if self
.geom
.linkDisplayNum
<= 0: return
220 return '[%d]' % ((linknum
%self
.geom
.linkDisplayNum
)+1)
222 def displayTrickyList(self
):
223 for i
in range(self
.geom
.trickyDisplayNum
):
224 self
.clearLine(self
.geom
.trickyline
+ i
)
226 self
.geom
.trickyline
+ i
,
230 def addTricky(self
, word
):
231 if self
.geom
.trickyDisplayNum
<= 0:
233 for t
in self
.tricky
:
235 # already in the list; don't repeat
237 for i
in range(self
.geom
.trickyDisplayNum
-1, 0, -1):
238 self
.tricky
[i
] = self
.tricky
[i
-1]
239 self
.tricky
[0] = word
240 self
.displayTrickyList()
242 def isTricky(self
, word
):
244 bool(re
.search('\d', word
)) or bool(re
.search('\w-\w', word
))
248 def cleanWord(self
, word
):
249 return strip(word
, ' ,.;:?!(){}')
251 def abbreviateStr(self
, str, maxLen
= None):
252 if maxLen
is None: maxLen
= 2*self
.geom
.maxx
/3
253 if len(str) <= maxLen
: return str
254 return '%s...%s' % (str[:(maxLen
/2)-3], str[-((maxLen
/2)-3):])
256 def refreshMoved(self
):
257 self
.showCurrentWord()
259 self
.displayLinkList()
260 self
.displayTrickyList()
262 def applyUrlShortcuts(self
, command
, depth
=0):
263 words
= split(command
)
265 numArgs
, shortcut
= self
.settings
.urlShortcuts
[words
[0]]
266 except (IndexError, KeyError):
269 for arg
in range(numArgs
):
273 # last argument gets all remaining words
274 repl
= join(words
[i
:])
280 shortcut
= re
.sub('%%%d' % i
, repl
, shortcut
)
282 shortcut
= re
.sub('%c', self
.indexedHypertext
.url
, shortcut
)
283 shortcut
= re
.sub('\\$HOME', os
.getenv("HOME"), shortcut
)
286 return self
.applyUrlShortcuts(shortcut
, depth
+1)
290 def go(self
, command
):
291 url
= self
.applyUrlShortcuts(command
)
294 def gotoNewUrl(self
, url
):
295 if self
.indexedHypertext
:
296 self
.history
.append(self
.indexedHypertext
.url
)
298 # Turns out to be neater to have the current document in unHistory:
299 self
.unHistory
= [url
]
300 def goHistory(self
, num
=1):
302 self
.gotoUrl(self
.history
[-num
])
304 self
.unHistory
.append(self
.history
.pop())
307 def goUnHistory(self
, num
=1):
309 self
.gotoUrl(self
.unHistory
[-num
-1])
311 self
.history
.append(self
.unHistory
.pop())
315 def gotoUrl(self
, url
, noCache
=False):
318 m
= re
.match('(.*)#(.*)$', url
)
320 baseUrl
, anchor
= m
.groups()
322 baseUrl
, anchor
= url
, None
323 cached
= self
.findCachedDocument(baseUrl
)
324 if cached
and not noCache
:
325 self
.indexedHypertext
= cached
327 self
.displayCleanCentred(self
.geom
.commandline
, self
.abbreviateStr("Loading %s" % url
))
330 self
.interruptible
= True
331 self
.indexedHypertext
= IndexedHypertext(baseUrl
)
332 self
.interruptible
= False
334 if self
.indexedHypertext
.isEmpty():
335 err
= self
.indexedHypertext
.lynxErr
.strip()
336 line
= join(err
.splitlines()[0:2], '; ')
337 self
.displayCleanCentred(self
.geom
.commandline
,
338 self
.abbreviateStr("Failed: %s" % line
))
343 self
.interruptible
= True
344 n
= self
.indexedHypertext
.findAnchor(anchor
)
345 self
.interruptible
= False
348 self
.indexedHypertext
.mark('\'')
349 self
.indexedHypertext
.seekWord(n
)
351 self
.displayCleanCentred(self
.geom
.commandline
,
352 self
.abbreviateStr("No such anchor: %s" % anchor
))
355 self
.displayCleanCentred(self
.geom
.infoline
, self
.abbreviateStr("%s" % baseUrl
))
358 if not noCache
: self
.cacheDocument(self
.indexedHypertext
)
361 self
.interruptible
= False
363 self
.clearLine(self
.geom
.commandline
)
365 def cacheDocument(self
, doc
):
366 if self
.findCachedDocument(doc
.url
):
369 self
.cachedDocuments
.append((doc
.url
, doc
))
370 if len(self
.cachedDocuments
) > self
.maxCached
:
371 self
.cachedDocuments
.pop(0)
372 def findCachedDocument(self
, url
):
373 for (u
, doc
) in self
.cachedDocuments
:
374 if u
== url
: return doc
377 def showCurrentWord(self
, word
=None):
379 word
= self
.indexedHypertext
.currentWord()
381 tricky
= self
.isTricky(word
)
382 linknum
= self
.indexedHypertext
.currentLink()
385 if tricky
: attr
= curses
.A_BOLD
387 self
.displayCleanCentred(self
.geom
.wordline
, word
, attr
)
389 self
.displayCleanCentred(self
.geom
.wordline
, word
,
390 self
.attrOfLinknum(linknum
) | curses
.A_BOLD
)
392 self
.displayLinkListEntry(linknum
, True)
394 if linknum
>= self
.linkDisplayEmphNum
:
395 self
.displayLinkListEntry(linknum
-self
.linkDisplayEmphNum
,
399 self
.addTricky(self
.cleanWord(word
))
401 if self
.showPoint
: self
.displayStatus()
404 def sigHandlerQuit(sig
, frame
):
406 signal
.signal(15, sigHandlerQuit
)
408 def sigHandlerInt(sig
, frame
):
409 # Occasionally, we can safely allow ^C to interrupt a section of
411 if self
.interruptible
:
412 raise KeyboardInterrupt
413 signal
.signal(2, sigHandlerInt
)
415 def applyCount(method
, args
):
416 # update args, replacing/appending any count argument
418 countarg
= self
.bindableMethods
[method
][2]
420 if len(args
) >= countarg
:
422 currentArg
= args
[countarg
-1]
423 if (currentArg
.__class
__ == int and
425 self
.count
= -self
.count
427 args
[countarg
-1] = self
.count
429 elif len(args
)+1 == countarg
:
435 self
.counting
= False
436 self
.countWin
.erase()
437 self
.countWin
.refresh()
446 self
.go(self
.initialUrl
)
452 self
.blinkWait
= max(1,
453 ( (self
.blinkPeriod
- self
.blinkLength
) * self
.wpm
458 # handle blink timers
460 if self
.blinkNum
> 0:
463 if self
.blinkNum
> 0:
465 elif self
.blinkWait
== 0:
466 if (not self
.blinkWaitSentence
or
467 self
.indexedHypertext
.atSentenceEnd()):
469 self
.blinkNum
= max(1,
470 (self
.blinkLength
* self
.wpm
+ 30000) / 60000)
471 self
.displayCleanCentred(self
.geom
.wordline
, "{*blink*}")
472 self
.blinkWait
= max(1,
473 ( (self
.blinkPeriod
- self
.blinkLength
) *
474 self
.wpm
+ 30000) / 60000)
476 elif self
.blinkWait
> 0:
480 if self
.blankSpace
>= 0:
482 self
.displayCleanCentred(self
.geom
.wordline
, "")
485 # no special cases apply - actually show the next word:
487 word
= self
.indexedHypertext
.readWord()
488 if self
.indexedHypertext
.atParaEnd():
489 self
.blankSpace
= self
.paraBlankSpace
490 elif self
.indexedHypertext
.atSentenceEnd():
491 self
.blankSpace
= self
.sentenceBlankSpace
492 except StopIteration:
495 self
.showCurrentWord(word
)
505 if not self
.paused
and not self
.indexedHypertext
.atEnd():
510 escBound
= self
.settings
.keyBindings
.has_key(27)
527 if c
== curses
.KEY_RESIZE
:
530 elif self
.counting
and ord('0') <= c
<= ord('9'):
531 self
.count
= 10*self
.count
+ c
- ord('0')
532 self
.countWin
.addch(chr(c
))
533 self
.countWin
.refresh()
536 binding
= self
.settings
.keyBindings
.get(c
)
537 if binding
is not None:
542 applyCount(method
, args
)
544 getattr(self
, 'doKey'+method
).__call
__(*args
)
545 elif self
.counting
and c
in [27,7]:
546 # esc or ^G: cancel count
549 # sleep, with blanking if self.blankBetween
550 if (self
.blankBetween
and
552 not self
.indexedHypertext
.atEnd() and
553 not (self
.blink
and self
.blinkNum
> 0)):
555 self
.clearLine(self
.geom
.wordline
)
562 # Curses threw an error, but that might just be because we
563 # need to resize and haven't yet processed KEY_RESIZE.
564 # So we resize now, and see if we get another curses error
565 # within the next couple of goes round the main loop.
569 raise "Curses returned ERR - terminal too short?"
570 except KeyboardInterrupt:
574 # emulating vim - global marks '0'-'9' give positions on last 10 exits
575 for i
in range(9,0,-1):
576 try: self
.globalMarks
[str(i
)] = self
.globalMarks
[str(i
-1)][:]
577 except KeyError: pass
578 self
.globalMarks
['0'] = \
579 [self
.indexedHypertext
.url
, self
.indexedHypertext
.atWord
]
581 def writeState(self
):
583 marksFile
= open(os
.getenv("HOME")+"/.flinksMarks", 'w')
584 pickle
.dump(self
.globalMarks
, marksFile
)
589 # <name> : [<min args>, <max args>, [<count arg>]]
590 'ChangeSpeed' : [1,1,1],
591 'SeekSentence' : [0,1,1],
592 'SeekParagraph' : [0,1,1],
593 'SeekWord' : [0,1,1],
594 'SeekLink' : [0,1,1],
598 'FollowLink' : [1,1,1],
602 'SearchAgain' : [0,1,1],
614 def doKeyChangeSpeed(self
, amount
):
616 if self
.wpm
< 30: self
.wpm
= 30
617 self
.delay
= 60.0/self
.wpm
620 def doKeySeekSentence(self
, n
=1):
621 self
.indexedHypertext
.seekSentenceRelative(n
)
623 def doKeySeekParagraph(self
, n
=1):
624 self
.indexedHypertext
.seekParaRelative(n
)
626 def doKeySeekWord(self
, n
=1):
627 self
.indexedHypertext
.seekWordRelative(n
)
629 def doKeySeekLink(self
, n
=1):
630 self
.indexedHypertext
.seekLinkRelative(n
)
633 def doKeySeekStart(self
):
634 self
.indexedHypertext
.mark('\'')
635 self
.indexedHypertext
.seekWord(0)
637 def doKeySeekEnd(self
):
638 self
.indexedHypertext
.mark('\'')
639 self
.indexedHypertext
.seekEnd()
642 def doKeyFollowLink(self
, n
):
644 nl
= self
.indexedHypertext
.lastLink
% self
.geom
.linkDisplayNum
646 linknum
= self
.indexedHypertext
.lastLink
+ (n
-nl
)
648 linknum
= self
.indexedHypertext
.lastLink
- self
.geom
.linkDisplayNum
+ (n
-nl
)
650 url
= self
.indexedHypertext
.links
[linknum
]["url"]
655 def readLine(self
, prompt
, initial
='', shortPrompt
=None, history
=None):
656 if self
.abbreviate
or self
.geom
.maxx
<= 10:
657 if shortPrompt
is not None:
660 prompt
= prompt
[0]+":"
662 promptX
= max(1, self
.geom
.maxx
/6 - len(prompt
))
663 boxX
= promptX
+ len(prompt
)
664 boxLen
= min(self
.geom
.maxx
- boxX
- 1, 2*self
.geom
.maxx
/3)
666 self
.scr
.addstr(self
.geom
.commandline
, promptX
,
667 prompt
[:self
.geom
.maxx
-5])
672 pad
= curses
.newpad(1, 200)
674 box
= TextboxPad(pad
,
675 self
.geom
.commandline
, boxX
,
676 self
.geom
.commandline
, boxX
+ boxLen
- 1,
682 class cancelReadline(Exception):
684 def cancelValidate(ch
):
685 if ch
== ascii
.BEL
or ch
== 27:
686 # ^G and escape cancel the readLine
691 read
= box
.edit(cancelValidate
)
692 if history
is not None:
693 history
.append(strip(read
))
695 except cancelReadline
:
699 self
.clearLine(self
.geom
.commandline
)
702 command
= self
.readLine("Go: ", history
=self
.urlHistory
)
704 self
.go(strip(command
))
706 def doKeyGoCurrent(self
):
707 command
= self
.readLine("Go: ", self
.indexedHypertext
.url
, history
=self
.urlHistory
)
709 self
.go(strip(command
))
711 def charOfSearchDir(self
, dir=1):
712 if dir == 1: return '/'
714 def search(self
, pattern
, n
=1):
716 self
.displayCleanCentred(self
.geom
.commandline
,
717 self
.charOfSearchDir(dir)+pattern
)
719 for i
in range(abs(n
)):
720 ret
= self
.indexedHypertext
.search(strip(pattern
), dir)
724 self
.clearLine(self
.geom
.commandline
)
725 def doKeySearch(self
, n
=1):
727 pattern
= self
.readLine("Search: ",
728 shortPrompt
= self
.charOfSearchDir(dir),
729 history
=self
.searchHistory
)
731 self
.search(pattern
, n
)
732 self
.lastSearch
= {"pattern": pattern
, "dir": dir}
733 def doKeySearchAgain(self
, n
=1):
735 self
.search(self
.lastSearch
["pattern"],
736 self
.lastSearch
["dir"]*n
)
738 def doKeyPause(self
):
739 self
.paused
= not self
.paused
742 def doKeyHistory(self
, n
=-1):
748 def doKeyCount(self
):
752 boxX
= self
.geom
.maxx
/6
753 boxLen
= min(self
.geom
.maxx
- boxX
- 1, 2*self
.geom
.maxx
/3)
754 self
.countWin
= curses
.newwin(1, boxLen
, self
.geom
.commandline
, boxX
)
755 if not self
.abbreviate
:
759 self
.countWin
.addstr(prompt
[:self
.geom
.maxx
])
760 self
.countWin
.refresh()
763 key
= self
.blockGetKey(self
.geom
.commandline
,
764 self
.abbreviate
and "m" or "Set mark:")
765 if key
is None or len(key
) != 1:
767 if 'a' <= key
<= 'z':
769 self
.indexedHypertext
.mark(key
)
770 elif 'A' <= key
<= 'Z':
772 self
.globalMarks
[key
] =\
773 [self
.indexedHypertext
.url
, self
.indexedHypertext
.atWord
]
774 def doKeyGoMark(self
):
775 key
= self
.blockGetKey(self
.geom
.commandline
,
776 self
.abbreviate
and "'" or "Go to mark:")
777 if key
is None or len(key
) != 1:
779 if 'a' <= key
<= 'z' or key
== '\'':
781 self
.indexedHypertext
.goMark(key
)
783 elif self
.globalMarks
.has_key(key
):
785 url
, pos
= self
.globalMarks
[key
]
786 if url
!= self
.indexedHypertext
.url
:
788 self
.indexedHypertext
.seekWord(pos
)
791 def doKeyReload(self
):
792 n
= self
.indexedHypertext
.atWord
793 self
.gotoUrl(self
.indexedHypertext
.url
, True)
794 self
.indexedHypertext
.seekWord(n
)
797 def doKeyCommand(self
):
799 self
.displayCleanCentred(self
.geom
.commandline
, err
)
800 command
= self
.readLine(':', shortPrompt
=':',
801 history
=self
.commandHistory
)
803 self
.settings
.processCommand(command
, handleErr
)
808 self
.normalWpm
= self
.wpm
809 self
.wpm
= self
.skimWpm
812 self
.skimWpm
= self
.wpm
813 self
.wpm
= self
.normalWpm
815 self
.delay
= 60.0/self
.wpm
818 def doKeyRefresh(self
):