So along with the code for a viewport with the ability to zoom an image centered on the mouse, I've heavily documented the code and put in a bunch of info/observations as well.
It doesn't cover *everything*, but a fair part, about viewports/adjustments.
Hopefully enough that people can find the last bits them self from a more informed viewpoint.
------
Info about viewports, scrollbars and ui.adjustment's
A viewport has a "child" - Usually an image to be viewed.
The size of the viewport and the child in the viewport is not the same - The difference is the scrollbar thickness
Thus the size you assign to a viewport with scrollbars is the size of the child + scrollbar thickness
An ui.adjustment is an object that is sync'ed with a scrollbar of the viewport - So you often have two (horizontal/x and vertical/y), so you have access to the values of the scrollbar and can react (via functions) to changes of the value and range values.
An ui.adjustment() has the two attributes: range and value (and more)
The (horizontal scroll) range attribute = image width - viewport child width (I.e. not the full image dimension!)
Likewise for the vertical scrollbar and image height
It is possible to assign directly to the range attribute. I.e. adjustment.range = 1234
It is not possible to directly assign to the value attribute - instead the adjustment.change() function must be used:
I.e. adj.change(1234) to set a new value or in an action Function(adj.change, 1234)
Positioning of a picture in the viewport works such that the image (child) coordinates of the top left pixel in the viewport corresponds to that of the adjustment.value values
I.e. viewport top pixel coordinate = (adjx.value, adjy.value)
It conveniently means that if adjustment.value = adjustment.range then the bottom (or right side)
of the image is aligned with the bottom (or right side) of the viewport
An ui.adjustment (sort of) have three functions - ranged, changed and change (last one mentioned above)
ranged and changed are actually attributes to which you yourself have to assign a function (but it helps thinking about them as functions - once they are assigned one)
-- ranged --
On the child dimensions (width/height) changing (i.e. assigning a new zoomed in/out image) the function (you have) assigned to the ranged (not range) attribute of the ui.adjustment is invoked
That function usefully recieves as (only) argument the ui.adjustment itself - So that both the range and value values are available in the function (via the ui.adjustment)
-- changed --
If one changes the position of the image in the viewport (ie. dragging it or the scrollbars) the method (you have) assigned to the changed (not change) attribute is invoked
This function only recives the actual new value (adjustment.value) of the scrollbar (corresponding to the position of the scrollbar thumb on the scrollbar)
But sadly not the ui.adjustment itself as the ranged function does. So the adjustment.range value is not available
-- Aside about renpy.curry, as this is a mystery to most novices (and indeed experienced) and is used in the code below --
In the invoked functions (for ranged and changed) one will often need more values than just the one supplied by renpy (see the code below)
This is where renpy.curry(<a function name - not a function call>) is a life saver
Know that renpy.curry returns a function - not a value, although it might look like you are just making a regular function call
This (curry) function (returned by renpy.curry) is then the one you assign to changed/ranged
As this new (curry) function is a function that *also* returns a function (head spinning), you call it only with the extra parameters you need
renpy.curry has taken care of the magic to make both ends meet (all the arguments being there for your defined function)
It basically "wraps" your defined function within another new one (returned by renpy.curry), such that you can supply extra parameters, over the one(s) originally available
Also notice that the argument list for the function you have defined is the full list of arguments: Your own + the renpy supplied one(s) (and those last)
Currying is a bit of a headspinner at first, but it's so damn useful, so I hope the above (layman speak) helps some out
Code: Select all
init python:
adjx = ui.adjustment()
adjy = ui.adjustment()
zoomlvl = 1.0
old_zoomlvl = 1.0
old_range = { "x" : 0, "y" : 0 }
def range_changed(org_img_coords_at_mouse, vp_coords, attr_name, adj):
global zoomlvl, old_zoomlvl, old_x_range
if (old_range[attr_name] != adj.range): #looks like a bug: The ranged function is called twice on change (renpy 7.4.4). Otherwise we don't need the old_range
old_range[attr_name] = adj.range
old_zoomlvl = zoomlvl
val = org_img_coords_at_mouse * zoomlvl - vp_coords
adj.change(val[attr_name])
image testimg = "testimg2.jpg"
screen img_screen(zoomlvl):
add "testimg":
zoom zoomlvl
xalign 0.5 # This only has an effect when the image width is smaller than the viewport
yalign 0.5 # This only has an effect when the image height is smaller than the viewport
# This screen/viewport will zoom in/out centered on where the mouse is - moving the image as needed
# This breaks a tiny bit when the image dimensions are smaller than the viewport - I've chosen to not "polute" the code for this corner case
# Point() and Size() are just some convenient utility wrappers for a pair of two values (I hate doing/seeing imgsize[0], imgsize[1] etc)
screen vp(org_img_size):
$ vp_origin = Point(200, 0) # Where on the screen is the viewport. Sadly needs to be hardcoded - Havent found a way for Renpy to give the position of the hovered element (Viewport or image)
$ vp_size = Size(1000, 1000) # Change this if you need a smaller viewport - like if you are running Renpy in one of the lower resolutions (than 1080)
$ zoomdelta = 0.05
$ zoomdeltaplus = zoomlvl + zoomdelta
$ zoomdeltaminus = max(zoomlvl - zoomdelta, zoomdelta) # Prevent zoom from becomming zero or negative (fun fact - negative zoom flips the image)
# Child dimension changes when zoom changes, which then triggers the ui.adjustment.ranged function
# Max ensures the child image is (faked) to never be smaller than the viewport viewport. The actual image can still become smaller though.
$ child_dimension = Size(max(vp_size.width, org_img_size.width * zoomlvl), max(vp_size.height, org_img_size.height * zoomlvl))
# Figure out where on the image the mouse is
$ mouse_coords = Point.from_tuple(renpy.get_mouse_pos())
$ vp_coords = mouse_coords - vp_origin # Mouse coordinates are relative to the screen, so need to adjust with where the viewport is located on the screen
$ vp_topleft_img_coords = Point(adjx.value, adjy.value) # The adjustment's value attribute corresponds to the image coordinates of the top left corner of the viewport
$ org_img_coords_at_mouse = (vp_topleft_img_coords + vp_coords) / old_zoomlvl
# Set up the ui.adjustment's for the viewport
# ui.adjustment.ranged only accepts a function taking one parameter (which will be the adjustment itself)
# So we need to curry in the rest of the needed params
$ x_range_changed_curried = renpy.curry(range_changed)
$ adjx.ranged = x_range_changed_curried(org_img_coords_at_mouse, vp_coords, "x")
$ y_range_changed_curried = renpy.curry(range_changed)
$ adjy.ranged = y_range_changed_curried(org_img_coords_at_mouse, vp_coords, "y")
viewport:
area (200, 0, vp_size.width + gui.scrollbar_size, vp_size.height + gui.scrollbar_size) #needs adjustment for scrollbar
child_size child_dimension.to_tuple() # no scrollbar adjustment - it's whats hosted *inside* the viewport, maybe overflowing the viewport
xinitial 0.5 # The initial image alignment in the viewport - as the ui.adjustables doesn't have a useful value yet
yinitial 0.5 # The initial image alignment in the viewport - as the ui.adjustables doesn't have a useful value yet
draggable True # Drag the image around with the mouse (scrollbars adjust automatically)
arrowkeys True # Use the arrowkeys to manipulate the scrollbars
scrollbars "both"
xadjustment adjx # The ui.adjustment gets connected with the viewport horizontal scrollbar, and the range/value values are kept in a two-way sync (adjustment vs scroolbar)
yadjustment adjy # The ui.adjustment gets connected with the viewport vertical scrollbar, and the range/value values are kept in a two-way sync (adjustment vs scroolbar)
use img_screen(zoomlvl)
# Mouse scroll actions up/down
key "mousedown_4" action [SetVariable("zoomlvl", zoomdeltaplus)]
key "mousedown_5" action [SetVariable("zoomlvl", zoomdeltaminus)]
label start:
call screen vp(Size.from_tuple(renpy.image_size("testimg2.jpg"))) #renpy.image_size is *extremely* slow - make sure to do it outside the screen and pass the value
# Utility classes
init -500 python:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return "({0},{1})".format(self.x, self.y,)
def __getitem__(self,key):
return getattr(self, key)
def __add__(self, other):
return self.__class__(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return self.__class__(self.x - other.x, self.y - other.y)
def __div__(self, other): # This needs to change to truediv for python 3.x
return self.__class__(self.x / other, self.y / other)
def __mul__(self, other):
return self.__class__(self.x * other, self.y * other)
@classmethod
def from_tuple(cls, tup):
return cls(tup[0], tup[1])
class Size(Point):
def __init__(self, width, height):
self.width = width
self.height = height
super(Size, self).__init__(width, height) # Python 3.x doesn't need (Size, self)
def to_tuple(self):
return (self.width, self.height)