Writing a GUI using wxPython for OSX and Linux/X, and I'm running into an issue in which, if I've got a significant number of notebook tabs, I can't get the wx.Dialog or wx.Frame or wx.Panel containing the wx.Notebook to expand to show them all (wx.EXPAND does not appear to do anything)
Under Linux/X, this isn't an issue, as it produces little arrows that let you go from tab to tab (see illustration here). But under OSX my users get screwed, because the tabs are drawn off the edge of the panel and are completely inaccessible (illustration).
Sample code used to create the above examples:
import wx
class TempFrame(wx.Frame):
def __init__(self):
wx.Frame.__init__(self, None, wx.ID_ANY, "Temp Frame")
self.notebook = wx.Notebook(self, id=wx.ID_ANY, style=wx.BK_DEFAULT)
for num in range(0, 11):
notepanel = TempPanel(self.notebook, num)
self.notebook.AddPage(notepanel, str(num))
sizer = wx.BoxSizer(wx.VERTICAL)
sizer.Add(self.notebook, 0, wx.ALL | wx.EXPAND, 5)
self.SetSizer(sizer)
self.Fit()
class TempPanel(wx.Panel):
def __init__(self, parent, num):
wx.Panel.__init__(self, parent, wx.ID_ANY)
self.title = wx.StaticText(self, wx.ID_ANY, str(num))
self.test_list = ["One", "One", "Two", "Three", "Five", "Eight"]
self.test_choice = wx.Choice(self, wx.ID_ANY, choices=self.test_list)
sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer.Add(self.title, 0, wx.ALL | wx.EXPAND, 5)
sizer.Add(self.test_choice, 0, wx.ALL | wx.EXPAND, 5)
self.SetSizer(sizer)
if __name__ == '__main__':
app = wx.PySimpleApp()
frame = TempFrame()
frame.Show(True)
app.MainLoop()
Is there any way to tell wxPython to make enough space for all of the notebook tabs?
Thanks!
Further research has revealed that this is a native-widgets limitation in OSX. To quote Robin Dunn:
The native tab control does not support it. Apparently the UI
designers at Apple feel that there should never be more than about half
a dozen tabs on any layout, and that if more pages are needed then there
is something wrong with the UI.
Fortunately, that same reply notes the existence of Listbook (of which I was not aware) and other, related controls that serve just as well. After going through a very helpful blog entry on those controls, I ended up going with Choicebook, which works just fine for my purposes on OSX.
Hope this helps anyone else with the same issue!
Related
I am a humanities teacher, trying to adapt a simple app to help teachers manage classroom interaction - it takes attendance, then allows for calling on random attending students in class, or breaking them into groups, or recording an excused absence, and so on. I've long had a working version in PHP / MySQL running locally on my laptop, but I want to make it a portable python / sqlite app, with an eye toward releasing into the wild for other teachers to use and improve.
But I'm totally new to wxPython, and not really strong on object-oriented programming generally. I have spent hours reading (or skimming through) a fair number of tutorials and introductions and StackOverflow questions, and I've played with wxFormBuilder, but I do not feel like I'm making progress - I'm still quite confused about panels and sizers and layouts, and which bits should belong to what parents.
I think if I could just get this minimal version working, that would go a long way toward my real app. The toolbar-like buttons along the top seem to work fine, but I'd like a "display" area below with a minimal (but expandable) vertical size, a centered text area, and a row of 2-3 buttons in the display area below that text. This display area should change and clear depending on the button pushed above. Here's what I've got:
#!/usr/bin/env python3
import wx
class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(-1,-1))
# self.panel = wx.Panel(self, size=(-1,300))
# buttons bar
self.top_button_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.btn_left = wx.Button(self, -1, label="left")
self.btn_right = wx.Button(self, -1, label="right")
self.btn_left.Bind(wx.EVT_BUTTON, self.OnLeft)
self.btn_right.Bind(wx.EVT_BUTTON, self.OnRight)
self.top_button_sizer.Add(self.btn_left, 1, wx.EXPAND)
self.top_button_sizer.Add(self.btn_right, 1, wx.EXPAND)
self.sizer = wx.BoxSizer(wx.VERTICAL)
self.display_sizer = wx.BoxSizer(wx.VERTICAL)
self.text_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.display_text = wx.StaticText(self, label="Push a button!")
self.display_buttons_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.text_sizer.Add(self.display_text, 0, wx.ALIGN_CENTER, 5)
self.display_sizer.Add(self.text_sizer, 1, wx.ALIGN_CENTER, 5)
self.display_sizer.Add(self.display_buttons_sizer, 0, wx.EXPAND)
self.sizer.Add(self.top_button_sizer, 0, wx.TOP | wx.EXPAND)
# self.sizer.Add(self.panel)
self.sizer.Add(self.display_sizer, 1, wx.EXPAND)
self.SetSizerAndFit(self.sizer)
self.Show()
def OnLeft(self,e):
# for child in self.display_buttons_sizer.GetChildren():
# child.Destroy()
# ^ my attempt to "clear" causes a SegFault
for child in self.display_buttons_sizer.GetChildren():
child.Destroy()
self.display_text.SetLabel("Hey lefty!")
self.btn_hey = wx.Button(self, -1, label="Hey yourself lefty.")
self.btn_whoa = wx.Button(self, -1, label="Whoa there lefty.")
self.display_buttons_sizer.Add(self.btn_hey, 1, wx.EXPAND)
self.display_buttons_sizer.Add(self.btn_whoa, 1, wx.EXPAND)
self.sizer.Layout()
def OnRight(self,e):
# for child in self.display_buttons_sizer.GetChildren():
# child.Destroy()
# ^ my attempt to "clear" causes a SegFault!
self.display_text.SetLabel("Hey righty!")
self.btn_hey = wx.Button(self, -1, label="Hey yourself righty.")
self.btn_whoa = wx.Button(self, -1, label="Whoa there righty.")
self.display_buttons_sizer.Add(self.btn_hey, 1, wx.EXPAND)
self.display_buttons_sizer.Add(self.btn_whoa, 1, wx.EXPAND)
self.sizer.Layout()
app = wx.App(False)
frame = MainWindow(None, "MWE")
app.MainLoop()
(I'm sure this is abhorrent code in lots of ways.) Should I put in a panel somewhere? Where, and how? Would it be easier to learn about "notebooks" instead of using my top buttons? How do I properly clear what's in the "display" area below for the next button push?
Bonus points: the most complicated of the top buttons on my real app is for taking attendance. It would display many lines of text (the students in that class, which I get from sqlite; I've worked that part out I think) with radio buttons for present / absent next to each line, and then record in the database. Hints about this would also be appreciated.
Thanks in advance for your patience.
You could do this sort of thing several different ways. You can swap out panels when a button is pushed or you could just use a wx.Notebook which is basically the same idea. I wrote up a tutorial on panel switching here that you might find helpful:
https://www.blog.pythonlibrary.org/2010/06/16/wxpython-how-to-switch-between-panels/
It uses a menu instead of buttons, but that wouldn't be hard to change. Here's a simple example that doesn't do panel switching, but does show how to add a multiline text control that clears itself when a button is pressed:
import wx
class MainPanel(wx.Panel):
def __init__(self, parent):
wx.Panel.__init__(self, parent)
self.main_sizer = wx.BoxSizer(wx.VERTICAL)
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
left_button = wx.Button(self, label='Left')
left_button.Bind(wx.EVT_BUTTON, self.on_left)
btn_sizer.Add(left_button, 0, wx.ALL, 5)
right_button = wx.Button(self, label='Right')
right_button.Bind(wx.EVT_BUTTON, self.on_right)
btn_sizer.Add(right_button, 0, wx.ALL, 5)
self.text_ctrl = wx.TextCtrl(self, style=wx.TE_MULTILINE)
self.main_sizer.Add(btn_sizer, 0, wx.CENTER)
self.main_sizer.Add(self.text_ctrl, 1, wx.ALL|wx.EXPAND, 5)
self.SetSizer(self.main_sizer)
def on_left(self, event):
self.text_ctrl.Clear()
self.text_ctrl.SetValue('Left')
def on_right(self, event):
self.text_ctrl.Clear()
self.text_ctrl.SetValue('Right')
class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(-1,-1))
panel = MainPanel(self)
self.Show()
app = wx.App(False)
frame = MainWindow(None, "MWE")
app.MainLoop()
This also demonstrates how to add a panel, which is recommended as panels give you the right "look" on all platforms and it also enables tabbing between controls.
You could easily add another set of buttons to the bottom by using the same concepts shown here and then just adding the second set of button's sizer to the main sizer.
If you want to do lines of text with some kind of radio or check button, I would recommend using a wx.ListCtrl or (better), ObjectListView (https://objectlistview-python-edition.readthedocs.io/en/latest/recipes.html#recipe-checkbox) (see also https://www.blog.pythonlibrary.org/2009/12/23/wxpython-using-objectlistview-instead-of-a-listctrl/)
You can tie an event to the checkbox that can then update your database appropriately.
I am writing a small app that works very well on linux, but I have some trouble on windows. Here is the code sample:
import wx
#####################################################################
class Main(wx.Frame):
def __init__(self):
wx.Frame.__init__(self, None, title="StackOverflow", pos=wx.DefaultPosition, size=(800,600))
self.SetMinSize( self.GetSize() )
p = wx.Panel(self)
nb = wx.Notebook(p)
page1 = AddToCollection(nb)
page2 = CollectionStatistics(nb)
nb.AddPage(page1, "Page 1")
nb.AddPage(page2, "Page 2")
# finally, put the notebook in a sizer for the panel to manage
# the layout
sizer = wx.BoxSizer()
sizer.Add(nb, 1, wx.EXPAND)
p.SetSizer(sizer)
#########################################################################
class CollectionStatistics(wx.Panel):
def __init__(self, parent):
wx.Panel.__init__(self, parent)
#########################################################################
class AddToCollection(wx.Panel):
def __init__(self, parent):
wx.Panel.__init__(self, parent)
self.v1_qty_list = [str(x) for x in range(9)]
self.v2_qty_list = [str(x) for x in range(9)]
self.sizername = wx.GridBagSizer(5, 5)
self.sizername.AddGrowableCol(0,0)
self.name_txt = wx.StaticText(self, label="Enter Name :")
self.sizername.Add(self.name_txt,(2,0),(1,1),wx.EXPAND)
self.name = wx.TextCtrl(self,style=wx.TE_PROCESS_ENTER,value=u"")
self.sizername.Add(self.name,(3,0),(1,1),wx.EXPAND)
self.Bind(wx.EVT_TEXT_ENTER, self.OnPressEnter, self.name)
self.SetSizerAndFit(self.sizername)
self.SetSizeHints(-1,self.GetSize().y,-1,self.GetSize().y )
##########################################################################
def OnPressEnter(self,event):
self.selected_name = self.name.GetValue()
self.AddToCol()
##########################################################################
def AddToCol(self):
self.sizerAdd = wx.GridBagSizer(5, 5)
self.sizerAdd.AddGrowableCol(0, 0)
self.name.Enable(False)
### Expansion
self.expansion = wx.Choice(self, -1, choices=['test 1', 'test 2'])
self.expansion.SetSelection(0)
self.sizerAdd.Add(self.expansion,(5,0),(1,6),wx.EXPAND)
### Quantities txt
self.v1_txt = wx.StaticText(self, label="V1 Quantity :")
self.sizerAdd.Add(self.v1_txt,(7,0),(1,1),wx.EXPAND)
self.v2_txt = wx.StaticText(self, label="V2 Quantity :")
self.sizerAdd.Add(self.v2_txt,(8,0),(1,1),wx.EXPAND)
### Quantities choices
self.v1_qty = wx.Choice(self, -1, choices=self.v1_qty_list)
self.v1_qty.SetSelection(0)
self.sizerAdd.Add(self.v1_qty,(7,5),(1,1),wx.EXPAND)
self.v2_qty = wx.Choice(self, -1, choices=self.v1_qty_list)
self.v2_qty.SetSelection(0)
self.sizerAdd.Add(self.v2_qty,(8,5),(1,1),wx.EXPAND)
### Ok Button
self.Add_btn = wx.Button(self, -1, "Add")
self.Add_btn.Bind(wx.EVT_BUTTON, self.OnAdd)
self.sizerAdd.Add(self.Add_btn,(9,5),(1,1),wx.EXPAND)
### Reset Button
self.Reset_btn = wx.Button(self, -1, "Reset")
self.Reset_btn.Bind(wx.EVT_BUTTON, self.OnResetPanel)
self.sizerAdd.Add(self.Reset_btn,(9,4),(1,1),wx.EXPAND)
self.SetSizerAndFit(self.sizerAdd)
self.SetSizeHints(-1,self.GetSize().y,-1,self.GetSize().y )
######################################################################
def OnResetPanel(self,event):
### Kill all children
self.expansion.Destroy()
self.v1_txt.Destroy()
self.v1_qty.Destroy()
self.v2_txt.Destroy()
self.v2_qty.Destroy()
self.Add_btn.Destroy()
self.Reset_btn.Destroy()
### Reinitialise sizer
self.name.Enable(True)
self.name.SetValue("")
######################################################################
def OnAdd(self,event):
print 'Add'
self.OnResetPanel(self)
######################################################################
######################################################################
if __name__ == "__main__":
app = wx.App()
Main().Show()
app.MainLoop()
Basically, I have a TextCtrl in a first sizer which is waiting for an entry. Once the user hits enter, several objects appear in a second sizer.
The issue on windows seems to come from the use of the two gridbagsizers (sizername and sizerAdd). After pressing enter (waited event in the __init__), the objects defined within the sizerAdd do not appear. When I extend the window where the script is running, these objects appear magically !
Any idea ?
EDIT : The code is now runnable
I think the problem in your code is these two lines at the end of your AddToCol method:
self.SetSizerAndFit(self.sizerAdd)
self.SetSizeHints(-1,self.GetSize().y,-1,self.GetSize().y )
At this point, you're changing the sizer of the AddToCollection panel from self.sizername to self.sizerAdd. The Enter Name: label and the textbox however are still within the self.sizername sizer. However, this sizer isn't the sizer for any window, nor has it been added to any other sizer.
Generally, in wxPython, every sizer should be set as the sizer for a window, or be added to another sizer. This other sizer would then be the sizer for a window, or be contained within another sizer, and so on. In your case, your self.sizername sizer ends up being neither, and in this situation I would expect unpredictable behaviour. If your code works on Linux then I would say that it happens to work by accident.
I can think of a few things you could do here:
Add self.sizerAdd as a child of self.sizername. This can be done by replacing the two lines above with
self.sizername.Add(self.sizerAdd,(4,0),(1,1),wx.EXPAND)
self.sizername.Layout()
In AddToCol, add the widgets directly to the self.sizername sizer instead of adding them to self.sizerAdd.
Create a wx.BoxSizer() with vertical orientation, set that to be the sizer for the AddToCollection panel, and add the self.sizername and self.sizerAdd sizers to your BoxSizer.
In all three cases, after creating the new widgets you will need to call the Layout() method on the top-level sizer, be it either self.sizername or the top-level BoxSizer. The code snippet under option 1 includes this line already.
Additionally, you may need to modify your OnResetPanel() method. If you chose options 1 or 3, you will need to remove the self.sizerAdd sizer from whichever sizer you added it to. For example, in option 1, you would add the line
self.sizername.Remove(self.sizerAdd)
Another approach would be for your AddToCol method to create all the widgets within a Panel and add that to the main panel at the end. Your AddToCol method would then need to create a child panel, add the extra controls as children of this panel instead of the main panel (self), set the sizer of the child panel to self.sizerAdd and finally add this panel to the self.sizername sizer.
def AddToCol(self):
self.sizerAdd = wx.GridBagSizer(5, 5)
self.sizerAdd.AddGrowableCol(0, 0)
self.name.Enable(False)
self.child_panel = wx.Panel(self)
### Expansion
self.expansion = wx.Choice(self.child_panel, -1, choices=['test 1', 'test 2'])
self.expansion.SetSelection(0)
self.sizerAdd.Add(self.expansion,(5,0),(1,6),wx.EXPAND)
# Create other widgets as before but with the parent set to self.child_panel
# instead of self.
self.child_panel.SetSizer(self.sizerAdd)
self.sizername.Add(self.child_panel,(4,0),(1,1),wx.EXPAND)
self.sizername.Layout()
You would then also need to replace the line
self.sizername.Remove(self.sizerAdd)
in OnResetPanel() with the two lines:
self.sizername.Remove(self.child_panel)
self.child_panel.Destroy()
One thing which bugged me about my approach 1 above was that I saw the widgets briefly appear in the top-left corner before appearing in the correct place. This adaptation fixes this problem and so makes the GUI behave itself a bit better. I couldn't reproduce your black area issue you mention in your comment, but hopefully this approach fixes your problem as well.
I uses a WrapSizer in order to have an automatic layout (as thumbnail gallery) like this (see screenshot on the left) :
I would like that if there are two many elements, a (vertical only)-ScrollBar is added on the panel (see right screenshot). How to add such a vertical scrollbar to a panel using a WrapSizer?
I tried by mixing WrapSizer and ScrolledPanel, but I cannot get the desired layout.
class MyPanel(scrolled.ScrolledPanel):
def __init__(self, parent):
scrolled.ScrolledPanel.__init__(self, parent)
self.SetBackgroundColour('#f8f8f8')
sizer = wx.WrapSizer()
self.SetupScrolling()
# add some widgets btn1, btn2, etc. in the WrapSizer
sizer.Add(btn1, 0, wx.ALL, 10)
sizer.Add(btn2, 0, wx.ALL, 10)
Solution:
reset the width of the scroll panel virtual size to the displayable size.
import wx
import wx.lib.scrolledpanel as scrolled
class MyPanel(scrolled.ScrolledPanel):
def __init__(self, parent):
scrolled.ScrolledPanel.__init__(self, parent, style=wx.VSCROLL)
self.SetBackgroundColour('#f8f8f8')
self.sizer = wx.WrapSizer()
self.SetupScrolling(scroll_x = False)
self.parent = parent
self.addButton(self.sizer , 10)
self.SetSizer(self.sizer )
self.Bind(wx.EVT_SIZE, self.onSize)
def onSize(self, evt):
size = self.GetSize()
vsize = self.GetVirtualSize()
self.SetVirtualSize((size[0], vsize[1]))
evt.Skip()
def addButton(self, sizer, num):
for i in range(1, num):
btn =wx.Button( self, wx.ID_ANY, "btn"+str(i), wx.DefaultPosition, wx.DefaultSize, 0 )
sizer.Add(btn, 0, wx.ALL, 10)
if __name__=='__main__':
app = wx.App(redirect=False)
frame = wx.Frame(None)
MyPanel(frame)
frame.Show()
app.MainLoop()
It looks like you just forgot to include
self.SetSizer(sizer)
Since the WrapSizer takes the whole frame, I think that will work. Also, instead of SetupScrolling, you can use
self.SetScrollRate(horiz, vert)
to specify the increment (in pixels, i think) of the scroll, and that should work.
I can't test it here right now though, and WrapSizers are a little weird - they sometimes have trouble figuring out their proper size. You may need to wrap it in a BoxSizer going the other direction.
I have created a pop up window, but the TextCtrl is not fully expanded to fill up the window. It works great if I use StaticText instead, (but if content too large then I would need the scroll bar, that is why I am using TextCtrl now). Please provide some guidance.
self.description = WindowPopup(self, wx.SIMPLE_BORDER, content)
btn = event.GetEventObject()
dw = wx.DisplaySize()[0]
width = self.description.GetSize()[0]
y = btn.ClientToScreen((0,0))[1]
height = btn.GetSize()[1]
x = dw - width - 20 - 10
self.description.Position((x, y), (0, height))
self.description.Show(True)
class WindowPopup(wx.PopupWindow):
""" Pops up a window to provide description for the selection """
def __init__(self, parent, style, content):
wx.PopupWindow.__init__(self, parent, style)
self.SetSize((700, 287))
panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)
st = wx.TextCtrl(self, -1, style = wx.TE_MULTILINE | wx.TE_READONLY)
st.SetValue(content)
sizer.Add(st, 0, wx.EXPAND)
panel.SetSizer(sizer)
I suspect your problem is that the panel is not as big as the popupwindow ... so even though the textfield is expanding to fill its sizer area it is not filling the popup its self.
try using something like
def __init__(...):
...
self.SetMinSize((700,287))
sizer2 = wx.BoxSizer()
sizer2.Add(panel)
self.SetSizer(sizer2)
also make sure that you are calling layout on it at some point (note this is totally untested... so it may need some tweeks, or even worse just be wrong...)
The actual answer is:
sizer = wx.BoxSizer(wx.VERTICAL)
st = wx.TextCtrl(self, -1, style = wx.TE_MULTILINE | wx.TE_READONLY, size = (500, 174))
st.SetValue(content)
self.SetSize((500, 174))
sizer.Add(st, 0, wx.EXPAND)
self.SetSizer(sizer)
self.Layout()
self.Show(True)
Credits to Joran for noticing Layout().
PopupWindow does not require an additional panel, because the window itself can have sizer set to it. This has been realized by using the wxPython Widget Inspection Tool.
Make sure TextCtrl and PopupWindow have the same size.
Okay, so I want to display a series of windows within windows and have the whole lot scrollable. I've been hunting through the wxWidgets documentation and a load of examples from various sources on t'internet. Most of those seem to imply that a wx.ScrolledWindow should work if I just pass it a nested group of sizers(?):
The most automatic and newest way is to simply let sizers determine the scrolling area.This is now the default when you set an interior sizer into a wxScrolledWindow with wxWindow::SetSizer. The scrolling area will be set to the size requested by the sizer and the scrollbars will be assigned for each orientation according to the need for them and the scrolling increment set by wxScrolledWindow::SetScrollRate.
...but all the example's I've seen seem to use the older methods listed as ways to achieve scrolling. I've got something basic working, but as soon as you start scrolling you lose the child windows:
import wx
class MyCustomWindow(wx.Window):
def __init__(self, parent):
wx.Window.__init__(self, parent)
self.Bind(wx.EVT_PAINT, self.OnPaint)
self.SetSize((50,50))
def OnPaint(self, event):
dc = wx.BufferedPaintDC(self)
dc.SetPen(wx.Pen('blue', 2))
dc.SetBrush(wx.Brush('blue'))
(width, height)=self.GetSizeTuple()
dc.DrawRoundedRectangle(0, 0,width, height, 8)
class TestFrame(wx.Frame):
def __init__(self):
wx.Frame.__init__(self, None, -1)
self.Bind(wx.EVT_SIZE, self.OnSize)
self.scrolling_window = wx.ScrolledWindow( self )
self.scrolling_window.SetScrollRate(1,1)
self.scrolling_window.EnableScrolling(True,True)
self.sizer_container = wx.BoxSizer( wx.VERTICAL )
self.sizer = wx.BoxSizer( wx.HORIZONTAL )
self.sizer_container.Add(self.sizer,1,wx.CENTER,wx.EXPAND)
self.child_windows = []
for i in range(0,50):
wind = MyCustomWindow(self.scrolling_window)
self.sizer.Add(wind, 0, wx.CENTER|wx.ALL, 5)
self.child_windows.append(wind)
self.scrolling_window.SetSizer(self.sizer_container)
def OnSize(self, event):
self.scrolling_window.SetSize(self.GetClientSize())
if __name__=='__main__':
app = wx.PySimpleApp()
f = TestFrame()
f.Show()
app.MainLoop()
Oops.. turns out I was creating my child windows badly:
wind = MyCustomWindow(self)
should be:
wind = MyCustomWindow(self.scrolling_window)
..which meant the child windows were waiting for the top-level window (the frame) to be re-drawn instead of listening to the scroll window. Changing that makes it all work wonderfully :)