root/05/release-0.5l/board1.py

Revision 137, 24.8 kB (checked in by ug, 5 years ago)

New black.gif and white.gif images

Line 
1 # file: board1.py
2
3 ##   This file is part of Kombilo, a go database program
4 ##   It contains classes implementing an abstract go board and a go
5 ##   board displayed on the screen.
6
7 ##   Copyright (C) 2001-4 Ulrich Goertz (u@g0ertz.de)
8
9 ##   This program is free software; you can redistribute it and/or modify
10 ##   it under the terms of the GNU General Public License as published by
11 ##   the Free Software Foundation; either version 2 of the License, or
12 ##   (at your option) any later version.
13
14 ##   This program is distributed in the hope that it will be useful,
15 ##   but WITHOUT ANY WARRANTY; without even the implied warranty of
16 ##   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 ##   GNU General Public License for more details.
18
19 ##   You should have received a copy of the GNU General Public License
20 ##   along with this program (gpl.txt); if not, write to the Free Software
21 ##   Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
22 ##   The GNU GPL is also currently available at
23 ##   http://www.gnu.org/copyleft/gpl.html
24
25
26 from Tkinter import *
27 from whrandom import randint
28 import math
29 import sys
30 import os
31
32 try:   # PIL installed?
33     import GifImagePlugin
34     import Image
35     import ImageTk
36     import ImageEnhance
37 except:
38     pass
39
40
41 class abstractBoard:
42     """ This class administrates a go board.
43         It keeps track of the stones currently on the board in the dictionary self.status,
44         and of the moves played so far in self.undostack
45
46         It has methods to clear the board, play a stone, undo a move. """
47
48     def __init__(self, boardSize = 19):
49         self.status = {}
50         self.undostack = []
51         self.boardSize = boardSize
52
53     def neighbors(self,x):
54         """ Returns the coordinates of the 4 (resp. 3 resp. 2 at the side / in the corner) intersections
55             adjacent to the given one. """
56         if   x[0]== 1              :     l0 = [2]
57         elif x[0]== self.boardSize :     l0 = [self.boardSize-1]
58         else:                            l0 = [x[0]-1, x[0]+1]
59
60         if   x[1]== 1              :     l1 = [2]
61         elif x[1]== self.boardSize :     l1 = [self.boardSize-1]
62         else:                            l1 = [x[1]-1, x[1]+1]
63
64         l = []
65         for i in l0: l.append((i,x[1]))
66         for j in l1: l.append((x[0],j))
67
68         return l
69
70     def clear(self):
71         """ Clear the board """
72         self.status = {}
73         self.undostack=[]       
74
75     def play(self,pos,color):
76         """ This plays a color=black/white stone at pos, if that is a legal move
77             (disregarding ko), and deletes stones captured by that move.
78             It returns 1 if the move has been played, 0 if not. """
79
80         if self.status.has_key(pos):                # check if empty
81             return 0
82
83         l = self.legal(pos,color)
84         if l:                                       # legal move?
85             captures = l[1]
86             for x in captures: del self.status[x]   # remove captured stones, if any
87             self.undostack.append((pos,color,captures))   # remember move + captured stones for easy undo
88             return 1
89         else: return 0
90
91     def legal(self, pos, color):
92         """ Check if a play by color at pos would be a legal move. """
93         c = [] # captured stones
94         for x in self.neighbors(pos):
95             if self.status.has_key(x) and self.status[x]==self.invert(color):
96                 c = c + self.hasNoLibExcP(x, pos)       
97
98         self.status[pos]=color
99
100         if c:
101             captures = []
102             for x in c:
103                 if not x in captures: captures.append(x)
104             return (1, captures)
105
106         if self.hasNoLibExcP(pos):
107             del self.status[pos]
108             return 0
109         else: return (1, [])
110
111     def hasNoLibExcP(self, pos, exc = None):
112         """ This function checks if the string (=solidly connected) of stones containing
113             the stone at pos has a liberty (resp. has a liberty besides that at exc).
114             If no liberties are found, a list of all stones in the string is returned.
115
116             The algorithm is a non-recursive  implementation of a simple flood-filling:
117             starting from the stone at pos, the main while-loop looks at the intersections
118             directly adjacent to the stones found so far, for liberties or other stones that belong
119             to the string. Then it looks at the neighbors of those newly found stones, and so
120             on, until it finds a liberty, or until it doesn't find any new stones belonging
121             to the string, which means that there are no liberties.
122             Once a liberty is found, the function returns immediately. """
123            
124         st = []            # in the end, this list will contain all stones solidly connected to the
125                            # one at pos, if this string has no liberties
126         newlyFound = [pos] # in the while loop, we will look at the neighbors of stones in newlyFound
127         foundNew = 1
128        
129         while foundNew:
130             foundNew = 0
131             n = []         # this will contain the stones found in this iteration of the loop
132             for x in newlyFound:
133                 for y in self.neighbors(x):
134                     if not self.status.has_key(y) and y != exc:    # found a liberty
135                         return []
136                     elif self.status.has_key(y) and self.status[y]==self.status[x] \
137                          and not y in st and not y in newlyFound: # found another stone of same color
138                         n.append(y)
139                         foundNew = 1
140
141             st[:0] = newlyFound
142             newlyFound = n
143
144         return st     # no liberties found, return list of all stones connected to the original one
145
146     def undo(self, no=1):
147         """ Undo the last no moves. """
148         for i in range(no):
149             if self.undostack:
150                 pos, color, captures = self.undostack.pop()
151                 del self.status[pos]
152                 for p in captures: self.status[p] = self.invert(color)
153
154     def remove(self, pos, removeFromUndostack=0):
155         """Remove the stone at pos, if not removeFromUndostack, append this as capture to undostack.
156         Otherwise, find the placement of this stone in undostack, and remove it from there."""
157        
158         if removeFromUndostack:
159             for i in range(len(self.undostack), 0, -1):
160                 if pos == self.undostack[i-1][0]:
161                     self.undostack[i-1:i] = []
162         else:
163             self.undostack.append(((-1,-1), self.invert(self.status[pos]), [pos]))
164         del self.status[pos]
165
166
167     def invert(self,color):
168         if color == "black": return "white"
169         else:                return "black"
170
171
172 class Board(abstractBoard, Canvas):
173     """ This is a go board, displayed on the associated canvas.
174         canvasSize is a pair, the first entry is the size of the border, the second
175         entry is the distance between two go board lines, both in pixels.
176
177         The most important methods are:
178
179         - play: play a stone of some color at some position (if that is a legal move)
180         - undo: undo one (or several) moves
181         - state: activate (state("normal", f) - the function f is called when a stone
182                  is placed on the board) or disable (state("disabled")) the board;
183
184         - placeMark: place a colored label (slightly smaller than a stone) at some position
185         - delMarks: delete all these labels
186         - placeLabel: place a label (a letter, a circle, square, triangle or cross)
187     """
188
189     def __init__(self, master, boardSize = 19, canvasSize = (30,25), fuzzy=1, labelFont = None,
190                  focus=1, callOnChange=None, boardImg=None, blackImg=None, whiteImg=None):
191
192         self.focus = focus
193         self.coordinates = 0
194        
195         self.canvasSize = canvasSize
196         size = 2*canvasSize[0] + (boardSize-1)*canvasSize[1] # size of the canvas in pixel
197         Canvas.__init__(self, master, height = size, width =  size, highlightthickness = 0)
198
199         abstractBoard.__init__(self, boardSize)
200
201         self.changed = IntVar()  # this is set to 1 whenever a change occurs (placing stone, label etc.)
202         self.changed.set(0)      # this is used for Kombilo's 'back' method
203
204         if callOnChange: self.callOnChange = callOnChange
205         else: self.callOnChange = lambda: None
206         self.noChanges = 0
207
208         self.fuzzy = IntVar()   # if self.fuzzy is true, the stones are not placed precisely
209         self.fuzzy.set(fuzzy)   # on the intersections, but randomly a pixel off
210
211         if labelFont:
212             self.labelFont = labelFont
213         else:
214             self.labelFont = (StringVar(), IntVar(), StringVar())
215             self.labelFont[0].set('Helvetica')
216             self.labelFont[1].set(5)
217             self.labelFont[2].set('bold')
218            
219         self.shadedStoneVar = IntVar()  # if this is true, there is a 'mouse pointer' showing
220         self.shadedStonePos = (-1,-1)   # where the next stone would be played, given the current
221                                         # mouse position
222
223         self.currentColor = "black"     # the expected color of the next move
224
225         self.stones = {}            # references to the ovals placed on the canvas, used for removing stones
226         self.marks = {}             # references to the (colored) marks on the canvas
227         self.labels = {}
228        
229         self.boundConf = self.bind("<Configure>", self.resize)
230         self.resizable = 1
231
232         self.PILinstalled = 0
233
234         self.use3Dstones = IntVar()
235         self.use3Dstones.set(1)
236        
237         if boardImg: self.img = boardImg
238         else:        self.img = None
239
240         if blackImg and whiteImg:
241             self.PILinstalled = 1
242             self.blackStone = blackImg
243             self.whiteStone = whiteImg
244
245         self.drawBoard()
246
247
248     def drawBoard(self):
249         """ Displays the background picture, and draws the lines and hoshi points of
250             the go board.
251             If PIL is installed, this also creates the PhotImages for black, white stones. """
252
253         self.delete('non-bg')     # delete everything except for background image
254         c0, c1 = self.canvasSize
255         size = 2*c0 + (self.boardSize-1)*c1
256         self.config(height=size, width=size)
257        
258         if self.img:
259             self.delete('board')
260             for i in range(size/100 + 1):
261                 for j in range(size/100 + 1):
262                     self.create_image(100*i,100*j,image=self.img, tags='board')
263
264         color = 'black'
265
266         for i in range(self.boardSize):
267             self.create_line(c0, c0 + c1*i, c0 + (self.boardSize-1)*c1, c0 + c1*i, fill=color, tags='non-bg')
268             self.create_line(c0 + c1*i, c0, c0 + c1*i, c0+(self.boardSize-1)*c1, fill=color, tags='non-bg')
269
270         # draw hoshi's:
271
272         if c1 > 7:
273
274             if self.boardSize in [13,19]:
275                 b = (self.boardSize-7)/2
276                 for i in range(3):
277                     for j in range(3):
278                         self.create_oval(c0 + (b*i+3)*c1 - 2, c0 + (b*j+3)*c1 - 2,
279                                          c0 + (b*i+3)*c1 + 2, c0 + (b*j+3)*c1 + 2, fill = "black", tags='non-bg')
280             elif self.boardSize == 9:
281                 self.create_oval(c0 + 4*c1 - 2, c0 + 4*c1 - 2,
282                                  c0 + 4*c1 + 2, c0 + 4*c1 + 2, fill = "black", tags='non-bg')
283
284         # draw coordinates:
285
286         if self.coordinates:
287             for i in range(self.boardSize):
288                 a = 'ABCDEFGHJKLMNOPQRST'[i]
289                 self.create_text(c0 + c1*i, c1*self.boardSize+3*c0/4+4, text=a,
290                                  font = ('Helvetica', 5+c1/7, 'bold'))
291                 self.create_text(c0 + c1*i, c0/4+1, text=a, font = ('Helvetica', 5+c1/7, 'bold'))
292                 self.create_text(c0/4+1, c0+c1*i, text=`self.boardSize-i`,font = ('Helvetica', 5+c1/7, 'bold'))
293                 self.create_text(c1*self.boardSize+3*c0/4+4, c0 + c1*i, text=`self.boardSize-i`, font = ('Helvetica', 5+c1/7, 'bold'))
294                
295
296         if self.PILinstalled:
297             try:
298                 self.bStone = ImageTk.PhotoImage(self.blackStone.resize((c1+1,c1+1), Image.NEAREST))
299                 self.wStone = ImageTk.PhotoImage(self.whiteStone.resize((c1+1,c1+1), Image.NEAREST))
300             except:
301                 self.PILinstalled = 0
302
303
304
305
306     def resize(self, event = None):
307         """ This is called when the window containing the board is resized. """
308
309         if not self.resizable: return
310
311         self.noChanges = 1
312
313         if event: w, h = event.width, event.height
314         else:     w, h = int(self.cget('width')), int(self.cget('height'))
315         m = min(w,h)
316
317         self.canvasSize = (m/20 + 4, (m - 2*(m/20+4))/(self.boardSize-1))
318
319         self.drawBoard()
320            
321
322         # place a gray rectangle over the board background picture
323         # in order to make the board quadratic
324
325         self.create_rectangle(h+1, 0, h+1000, w+1000,
326                               fill ='grey88', outline='', tags='non-bg')     
327         self.create_rectangle(0, w+1, h+1000, w+1000,
328                               fill='grey88', outline='', tags='non-bg')
329
330            
331         for x in self.status.keys(): self.placeStone(x, self.status[x])
332         for x in self.marks.keys(): self.placeMark(x, self.marks[x])
333         for x in self.labels.keys(): self.placeLabel(x, '+'+self.labels[x][0], self.labels[x][1])
334
335         self.tkraise('sel') # this is for the list of previous search patterns ...
336
337         self.noChanges = 0
338
339     def play(self, pos, color=None):
340         """ Play a stone of color (default is self.currentColor) at pos. """
341
342         if color is None: color = self.currentColor
343         if abstractBoard.play(self, pos, color):                    # legal move?
344             captures = self.undostack[len(self.undostack)-1][2]     # retrieve list of captured stones
345             for x in captures:
346                 self.delete(self.stones[x])
347                 del self.stones[x]
348             self.placeStone(pos, color)
349             self.currentColor = self.invert(color)
350             self.delShadedStone()
351             return 1
352         else: return 0
353
354     def state(self, s, f=None):
355         """ s in "normal", "disabled": accepting moves or not
356             f the function to call if a move is entered
357             [More elegant solution might be to replace this by an overloaded bind method,
358             for some event "Move"?!]  """
359
360         if s == "normal":
361             self.callOnMove = f
362             self.bound1 = self.bind("<Button-1>", self.onMove) 
363             self.boundm = self.bind("<Motion>", self.shadedStone)
364             self.boundl = self.bind("<Leave>", self.delShadedStone)
365         elif s == "disabled":
366             self.delShadedStone()
367             try:
368                 self.unbind("<Button-1>", self.bound1)
369                 self.unbind("<Motion>", self.boundm)
370                 self.unbind("<Leave>", self.boundl)
371             except TclError: pass                     # if board was already disabled, unbind will fail
372             
373     def onMove(self, event):
374         # compute board coordinates from the pixel coordinates of the mouse click
375
376         if self.focus:
377             self.master.focus()
378         x,y = self.getBoardCoord((event.x, event.y), self.shadedStoneVar.get())
379         if (not x*y): return
380
381         if abstractBoard.play(self,(x,y), self.currentColor): # would this be a legal move?
382             abstractBoard.undo(self)
383             self.callOnMove((x,y))
384
385     def onChange(self):
386         if self.noChanges: return
387         self.callOnChange()
388         self.changed.set(1)
389
390
391     def getPixelCoord(self, pos, nonfuzzy = 0):
392         """ transform go board coordinates into pixel coord. on the canvas of size canvSize """
393
394         fuzzy1 = randint(-1,1) * self.fuzzy.get() * (1-nonfuzzy)
395         fuzzy2 = randint(-1,1) * self.fuzzy.get() * (1-nonfuzzy)
396         c1 = self.canvasSize[1]
397         a = self.canvasSize[0] - self.canvasSize[1] - self.canvasSize[1]/2
398         b = self.canvasSize[0] - self.canvasSize[1] + self.canvasSize[1]/2
399         return (c1*pos[0]+a+fuzzy1, c1*pos[1]+a+fuzzy2, c1*pos[0]+b+fuzzy1, c1*pos[1]+b+fuzzy2)
400
401     def getBoardCoord(self, pos, sloppy=1):
402         """ transform pixel coordinates on canvas into go board coord. in [1,..,boardSize]x[1,..,boardSize]
403             sloppy refers to how far the pixel may be from the intersection in order to
404             be accepted """
405
406         if sloppy: a, b = self.canvasSize[0]-self.canvasSize[1]/2, self.canvasSize[1]-1
407         else:      a, b = self.canvasSize[0]-self.canvasSize[1]/4, self.canvasSize[1]/2
408
409         if (pos[0]-a)%self.canvasSize[1] <= b: x = (pos[0]-a)/self.canvasSize[1] + 1
410         else:                                  x = 0
411        
412         if (pos[1]-a)%self.canvasSize[1] <= b: y = (pos[1]-a)/self.canvasSize[1] + 1
413         else:                  y = 0
414
415         if x<0 or y<0 or x>self.boardSize or y>self.boardSize: x = y = 0
416
417         return (x,y)   
418
419     def placeMark(self, pos, color, outln='', size=''):
420         """ Place colored mark at pos. """
421         x1, x2, y1, y2 = self.getPixelCoord(pos, 1)
422
423         if size == 'small':
424             tmp1 = (y1 - x1) * 1 / 5
425             tmp2 = (y2 - x2) * 1 / 5
426         else:
427             tmp1 = 2
428             tmp2 = 2
429         self.create_oval(x1+tmp1, x2+tmp2, y1-tmp1, y2-tmp2, fill = color,
430                          outline = outln, tags=('marks', 'non-bg'))
431         self.marks[pos]=color
432         self.onChange()
433
434     def delMarks(self):
435         """ Delete all marks. """
436         if self.marks: self.onChange()
437         self.marks = {}
438         self.delete('marks')
439
440     def delLabels(self):
441         """ Delete all labels. """
442         if self.labels: self.onChange()
443         self.labels={}
444         self.delete('label')
445
446     def remove(self, pos, removeFromUndostack = 0):
447         """ Remove the stone at pos, if not removeFromUndostack, append this as capture to undostack.
448         Otherwise, find the placement of this stone in undostack, and remove it from there."""
449         if self.status.has_key(pos):
450             self.onChange()
451             self.delete(self.stones[pos])
452             del self.stones[pos]
453             abstractBoard.remove(self, pos, removeFromUndostack)
454             self.update_idletasks()
455             return 1
456         else: return 0
457
458     def placeLabel(self, pos, type, text=None, color=None, override=None):
459         """ Place label of type type at pos; used to display labels
460             from SGF files. If type has the form +XX, add a label of type XX.
461             Otherwise, add or delete the label, depending on if there is no label at pos,
462             or if there is one."""
463
464         if type[0] != '+':
465
466             if self.labels.has_key(pos):
467                 if self.labels[pos][0] == type:
468                     for item in self.labels[pos][2]: self.delete(item)
469                     del self.labels[pos]
470                     return
471                 else:
472                     for item in self.labels[pos][2]: self.delete(item)
473                     del self.labels[pos]
474
475             self.onChange()
476
477         else: type = type[1:]
478
479         labelIDs = []
480
481         if override:
482             fcolor = override[0]
483             fcolor2 = override[1]
484         elif self.status.has_key(pos) and self.status[pos]=='black':
485             fcolor = 'white'
486             fcolor2 = ''
487         elif self.status.has_key(pos) and self.status[pos]=='white':
488             fcolor = 'black'
489             fcolor2 = ''
490         else:
491             fcolor = color or 'black'
492             fcolor2 = '#D8A542'
493                    
494         x1, x2, y1, y2 = self.getPixelCoord(pos, 1)
495         if type == 'LB':
496             labelIDs.append(self.create_oval(x1+3, x2+3, y1-3, y2-3, fill=fcolor2, outline='',
497                                              tags=('label', 'non-bg')))
498             labelIDs.append(self.create_text((x1+y1)/2,(x2+y2)/2, text=text, fill=fcolor,
499                                              font = (self.labelFont[0].get(), self.labelFont[1].get() + self.canvasSize[1]/5,
500                                                      self.labelFont[2].get()),
501                                              tags=('label', 'non-bg')))
502         elif type == 'SQ':
503             labelIDs.append(self.create_rectangle(x1+6, x2+6, y1-6, y2-6,
504                                                   fill='', outline = fcolor, tags=('label','non-bg')))
505         elif type == 'CR':
506             labelIDs.append(self.create_oval(x1+5, x2+5, y1-5, y2-5, fill='',
507                                              outline=fcolor, tags=('label','non-bg')))
508         elif type == 'TR':
509             labelIDs.append(self.create_polygon((x1+y1)/2, x2+5, x1+5, y2-5, y1-5, y2-5,
510                                                 fill='', outline = fcolor,
511                                                 tags = ('label', 'non-bg')))
512         elif type == 'MA':
513             labelIDs.append(self.create_oval(x1+2, x2+2, y1-2, y2-2, fill=fcolor2, outline='',
514                              tags=('label', 'non-bg')))
515             labelIDs.append(self.create_text(x1+12,x2+12, text='X', fill=fcolor,
516                                              font = (self.labelFont[0].get(), self.labelFont[1].get() + 1 + self.canvasSize[1]/5,
517                                                      self.labelFont[2].get()),
518                                              tags=('label', 'non-bg')))
519            
520         self.labels[pos] = (type, text, labelIDs)
521
522
523            
524     def placeStone(self, pos, color):
525         self.onChange()
526         p = self.getPixelCoord(pos)
527         if not self.use3Dstones.get() or not self.PILinstalled or self.canvasSize[1] <= 7:
528             self.stones[pos] = self.create_oval(p, fill=color, tags='non-bg')
529         else:
530             if color=='black': self.stones[pos] = self.create_image(((p[0]+p[2])/2, (p[1]+p[3])/2),
531                                                                     image=self.bStone, tags='non-bg')
532             elif color=='white': self.stones[pos] = self.create_image(((p[0]+p[2])/2, (p[1]+p[3])/2),
533                                                                       image=self.wStone, tags='non-bg')
534            
535     def undo(self, no=1, changeCurrentColor=1):
536         """ Undo the last no moves. """
537
538         for i in range(no):
539             if self.undostack:
540                 self.onChange()
541                 pos, color, captures = self.undostack.pop()
542                 if self.status.has_key(pos):
543                     del self.status[pos]
544                     self.delete(self.stones[pos])
545                     del self.stones[pos]
546                 for p in captures:
547                     self.placeStone(p, self.invert(color))
548                     self.status[p] = self.invert(color)
549                 # self.update_idletasks()
550                 if changeCurrentColor:
551                     self.currentColor = self.invert(self.currentColor)
552
553     def clear(self):
554         """ Clear the board. """
555         abstractBoard.clear(self)
556         for x in self.stones.keys():
557             self.delete(self.stones[x])
558         self.stones = {}
559         self.onChange()
560
561     def ptOnCircle(self, size, degree):
562         radPerDeg = math.pi/180
563         r = size/2
564         x = int(r*math.cos((degree-90)*radPerDeg) + r)
565         y = int(r*math.sin((degree-90)*radPerDeg) + r)
566         return (x,y)
567
568     def shadedStone(self, event):
569         x, y = self.getBoardCoord((event.x, event.y), 1)
570         if (x,y) == self.shadedStonePos: return     # nothing changed
571
572         self.delShadedStone()
573
574         if (x*y) and self.shadedStoneVar.get() and abstractBoard.play(self, (x,y), self.currentColor):
575             abstractBoard.undo(self)
576
577             if sys.platform[:3]=='win':     # 'stipple' is ignored under windows for
578                                             # create_oval, so we'll draw a polygon ...
579                 l = self.getPixelCoord((x,y),1)
580                 m = []
581
582                 for i in range(18):
583                     help = self.ptOnCircle(l[2]-l[0], i*360/18)
584                     m.append(help[0]+l[0])
585                     m.append(help[1]+l[1])
586                  
587                 self.create_polygon(m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7], m[8], m[9],
588                                     m[10], m[11], m[12], m[13], m[14], m[15], m[16], m[17],
589                                     m[18], m[19], m[20], m[21], m[22], m[23], m[24], m[25],
590                                     m[26], m[27], m[28], m[29], m[30], m[31], m[32], m[33],
591                                     m[34], m[35],
592                                     fill=self.currentColor, stipple='gray50',
593                                     outline='', tags=('shaded','non-bg') )
594             else:
595                 self.create_oval(self.getPixelCoord((x,y), 1), fill=self.currentColor, stipple='gray50',
596                                  outline='', tags=('shaded','non-bg'))
597
598             self.shadedStonePos = (x,y)
599    
600     def delShadedStone(self, event=None):
601         self.delete('shaded')
602         self.shadedStonePos = (-1,-1)
603
604     def fuzzyStones(self):
605         """ switch fuzzy/non-fuzzy stone placement according to self.fuzzy """
606         for p in self.status.keys():
607             self.delete(self.stones[p])
608             del self.stones[p]
609             self.placeStone(p, self.status[p])
610         self.tkraise('marks')
611         self.tkraise('label')
Note: See TracBrowser for help on using the browser.