4 // Graphical User Interface support
7 // JLH = James Hammons <jlhamm@acm.org>
10 // --- ---------- ------------------------------------------------------------
11 // JLH 02/03/2006 Created this file
12 // JLH 03/13/2006 Added functions to allow shutting down GUI externally
13 // JLH 03/22/2006 Finalized basic multiple window support
17 // - Memory leak on quitting with a window active [DONE]
18 // - Multiple window handling [DONE]
22 #include "menu.h" // Element class methods are pulled in here...
26 #include "diskwindow.h"
31 //#define DEBUG_MAIN_LOOP
33 // New main screen buffering
34 // This works, but the colors are rendered incorrectly. Also, it seems that there's
35 // fullscreen blitting still going on--dragging the disk is fast at first but then
36 // gets painfully slow. Not sure what's going on there.
37 //#define USE_NEW_MAINBUFFERING
39 //#ifdef DEBUG_MAIN_LOOP
44 Work flow: Draw floppy drive.
45 If disk in drive, MO shows eject graphic, otherwise show load graphic.
46 If hit 'new blank image':
47 If disk in drive, ask if want to save if modified
49 If hit 'swap disks', swap disks.
53 GUI::GUI(SDL_Surface * surface): menuItem(new MenuItems())
55 Element::SetScreen(surface);
56 // windowList.push_back(new Menu());
58 // Create drive windows, and config windows here...
59 windowList.push_back(new Window(30, 30, 200, 100));
60 windowList.push_back(new Window(30, 140, 200, 100));
61 windowList.push_back(new Button(30, 250, "Click!"));
62 windowList.push_back(new Text(30, 20, floppyDrive.GetImageName(0)));
63 windowList.push_back(new Text(30, 130, floppyDrive.GetImageName(1)));
64 windowList.push_back(new DiskWindow(&floppyDrive, 240, 20));
70 // Clean up menuItem, if any
77 for(std::list<Element *>::iterator i=windowList.begin(); i!=windowList.end(); i++)
83 void GUI::AddMenuTitle(const char * title)
85 menuItem->title = title;
86 menuItem->item.clear();
90 void GUI::AddMenuItem(const char * item, Element * (* a)(void)/*= NULL*/, SDL_Scancode k/*= SDLK_UNKNOWN*/)
92 menuItem->item.push_back(NameAction(item, a, k));
96 void GUI::CommitItemsToMenu(void)
98 //We could just do a simple check here to see if more than one item is in the list,
99 //and if so fail. Make it so you build the menu first before allowing any other action. [DONE]
101 //Right now, we just silently fail...
102 if (windowList.size() > 1)
104 WriteLog("GUI: Can't find menu--more than one item in windowList!\n");
108 ((Menu *)(*windowList.begin()))->Add(*menuItem);
117 std::list<Element *>::iterator i;
119 // Not sure what replaces this in SDL2...
120 // SDL_EnableKeyRepeat(150, 75);
122 // Also: Need to pick up backbuffer (for those windows that have them)
125 // Initial update... [Now handled correctly in the constructor]
126 // Uh, still needed here, though... Only makes sense that it should
127 for(i=windowList.begin(); i!=windowList.end(); i++)
130 #ifndef USE_NEW_MAINBUFFERING
131 RenderScreenBuffer();
139 // if (SDL_PollEvent(&event))
140 if (SDL_WaitEvent(&event))
142 #ifdef DEBUG_MAIN_LOOP
143 WriteLog("An event was found!");
145 if (event.type == SDL_USEREVENT)
147 #ifdef DEBUG_MAIN_LOOP
148 WriteLog(" -- SDL_USEREVENT\n");
150 //Mebbe add another user event for screen refresh? Why not!
151 if (event.user.code == WINDOW_CLOSE)
153 for(i=windowList.begin(); i!=windowList.end(); i++)
155 if (*i == (Element *)event.user.data1)
163 else if (event.user.code == MENU_ITEM_CHOSEN)
165 // Confused? Let me enlighten... What we're doing here is casting
166 // data1 as a pointer to a function which returns a Element pointer and
167 // which takes no parameters (the "(Element *(*)(void))" part), then
168 // derefencing it (the "*" in front of that) in order to call the
169 // function that it points to. Clear as mud? Yeah, I hate function
170 // pointers too, but what else are you gonna do?
171 Element * window = (*(Element *(*)(void))event.user.data1)();
174 windowList.push_back(window);
176 while (SDL_PollEvent(&event)); // Flush the event queue...
178 event.type = SDL_MOUSEMOTION;
180 SDL_GetMouseState(&mx, &my);
181 event.motion.x = mx, event.motion.y = my;
182 SDL_PushEvent(&event); // & update mouse position...!
184 oldMouse.x = mouse.x, oldMouse.y = mouse.y;
185 mouse.x = mx, mouse.y = my; // This prevents "mouse flash"...
187 //There's a *small* problem with the following approach--if a window and a bunch of
188 //child widgets send this message, we'll get a bunch of unnecessary refresh events...
189 //This could be controlled by having the main window refresh itself intelligently...
191 //What we could do instead is set a variable in Element and check it after the fact
192 //to see whether or not a refresh is needed.
193 //[This is what we do now.]
195 //Dirty rectangle is also possible...
196 else if (event.user.code == SCREEN_REFRESH_NEEDED)
197 #ifndef USE_NEW_MAINBUFFERING
198 RenderScreenBuffer();
203 //Not sure what to do here for SDL2...
205 else if (event.type == SDL_ACTIVEEVENT)
207 //Need to do a screen refresh here...
208 if (event.active.state == SDL_APPMOUSEFOCUS)
209 showMouse = (event.active.gain ? true : false);
211 #ifndef USE_NEW_MAINBUFFERING
212 RenderScreenBuffer();
218 else if (event.type == SDL_KEYDOWN)
220 #ifdef DEBUG_MAIN_LOOP
221 WriteLog(" -- SDL_KEYDOWN\n");
223 if (event.key.keysym.sym == SDLK_F1)
226 //Not sure that this is the right way to handle this...
227 //Probably should only give this to the top level window...
228 // for(i=windowList.begin(); i!=windowList.end(); i++)
229 // (*i)->HandleKey(event.key.keysym.sym);
230 windowList.back()->HandleKey(event.key.keysym.scancode);
232 else if (event.type == SDL_MOUSEMOTION)
234 #ifdef DEBUG_MAIN_LOOP
235 WriteLog(" -- SDL_MOUSEMOTION\n");
237 //This is for tracking a custom mouse cursor, which we're not doing--YET.
238 oldMouse.x = mouse.x, oldMouse.y = mouse.y;
239 mouse.x = event.motion.x, mouse.y = event.motion.y;
241 //Not sure that this is the right way to handle this...
242 //Right now, we should probably only do mouseover for the last item in the list...
244 //Though, it seems to screw other things up. Maybe it IS better to pass it to all windows?
245 //Or maybe to just the ones that aren't completely obscured?
246 //Probably. Right now, a disk's close button that should be obscured by one sitting on
247 //top of it gets redrawn. Not good. !!! FIX !!!
248 for(i=windowList.begin(); i!=windowList.end(); i++)
249 (*i)->HandleMouseMove(mouse.x, mouse.y);
250 // windowList.back()->HandleMouseMove(mouse.x, mouse.y);
252 else if (event.type == SDL_MOUSEBUTTONDOWN)
254 #ifdef DEBUG_MAIN_LOOP
255 WriteLog(" -- SDL_MOUSEBUTTONDOWN\n");
257 //Not sure that this is the right way to handle this...
258 // What we should do here is ensure that whatever has been clicked on gets moved to the
259 // highest priority--in our current data schema that would be the end of the list... !!! FIX !!!
264 We could do the following:
266 - Go through list and find which window has been clicked on (if any). If more
267 than one is clicked on, take the one highest in the Z order (closer to the end
270 - If item is highest in Z order, pass click through to window and exit.
272 - Otherwise, restore backing store on each window in reverse order.
274 - Remove item clicked on from the list. Put removed item at the end of the list.
276 - Go through list and pass click through to each window in the list. Also do a
277 blit to backing store and a Draw() for each window.
279 Could also do a check (if not clicked on highest Z window) to see which windows
280 it overlaps and just do restore/redraw for those that overlap. To wit:
282 - Create new list containing only those windows that overlap the clicking on window.
284 - Go through list and do a blit to backing store and a Draw() for each window.
286 - Go through list and pass click through to each window in the list.
292 for(i=windowList.begin(); i!=windowList.end(); i++)
293 (*i)->HandleMouseButton(event.button.x, event.button.y, true);
295 // We use the 1st algorithm here, since it's simpler. If we need to, we can optimize
298 // Walk backward through the list and see if a window was hit.
299 // This will automagically return us the window with the highest Z.
301 std::list<Element *>::reverse_iterator ri;
302 std::list<Element *>::iterator hit;// = windowList.end();
304 for(ri=windowList.rbegin(); ri!=windowList.rend(); ri++)
306 if ((*ri)->Inside(event.button.x, event.button.y))
308 // Here's a bit of STL weirdness: Converting from a reverse
309 // iterator to a regular iterator requires backing the iterator
310 // up a position after grabbing it's base() OR going forward
311 // one position with the reverse iterator before grabbing base().
312 // Ugly, but it gets the job done...
314 // Put it back where we found it, so the tests following this
321 // If we hit the highest in the list, then pass the event through
322 // to the window for handling. if we hit no windows, then pass the
323 // event to all windows. Otherwise, we need to shuffle windows.
325 //NOTE: We need to pass the click to all windows regardless of whether they're topmost or not...
326 if (ri == windowList.rbegin())
328 for(i=windowList.begin(); i!=windowList.end(); i++)
329 (*i)->HandleMouseButton(event.button.x, event.button.y, true);
331 else if (ri == windowList.rend())
333 for(i=windowList.begin(); i!=windowList.end(); i++)
334 (*i)->HandleMouseButton(event.button.x, event.button.y, true);
338 // - Otherwise, restore backing store on each window in reverse order.
339 for(ri=windowList.rbegin(); ri!=windowList.rend(); ri++)
340 (*ri)->RestoreScreenFromBackstore();
341 // At this point, the screen has been restored...
343 // - Remove item clicked on from the list. Put removed item at the end of the list.
344 windowList.push_back(*hit);
345 windowList.erase(hit);
346 // - Go through list and pass click through to each window in the list. Also do a
347 // blit to backing store and a Draw() for each window.
348 for(i=windowList.begin(); i!= windowList.end(); i++)
350 // Grab bg into backstore
351 (*i)->SaveScreenToBackstore();
353 (*i)->HandleMouseButton(event.button.x, event.button.y, true);
361 A slightly different way to handle this would be to loop through all windows, compare
362 all those above it to see if they obscure it; if so then subdivide it's update rectangle
363 to eliminate drawing the parts that aren't shown. The beauty of this approach is that
364 you don't have to care what order the windows are drawn in and you don't need to worry
365 about the order of restoring the backing store.
367 You *do* still need to determine the Z-order of the windows, in order to get the subdivisions
368 correct, but that's not too terrible.
370 Also, when doing a window drag, the coverage lists for all windows have to be regenerated.
372 std::list<Element *>::reverse_iterator ri;
373 bool movedWindow = false;
375 for(ri=windowList.rbegin(); ri!=windowList.rend(); ri++)
377 if ((*ri)->Inside(event.button.x, event.button.y))
379 // Remove item clicked on from the list & put removed item at the
380 // end of the list, thus putting the window at the top of the Z
381 // order. But IFF window is not already topmost!
382 if (ri != windowList.rbegin())
384 windowList.push_back(*ri);
385 // Here's a bit of STL weirdness: Converting from a reverse
386 // iterator to a regular iterator requires backing the iterator
387 // up a position after grabbing it's base() OR going forward
388 // one position with the reverse iterator before grabbing base().
389 // Ugly, but it get the job done...
390 windowList.erase((++ri).base());
398 //Small problem here: we should only pass the *hit* to the topmost window and pass
399 //*misses* to everyone else... Otherwise, you can have overlapping draggable windows
400 //and be able to drag both by clicking on a point that intersects both...
401 //(though that may be an interesting way to handle things!)
402 //The thing is that you want to do it on purpose (like with a special grouping widget)
403 //instead of by accident. So, !!! FIX !!!
404 // Pass the click on to all windows
405 // for(i=windowList.begin(); i!=windowList.end(); i++)
406 // (*i)->HandleMouseButton(event.button.x, event.button.y, true);
407 windowList.back()->HandleMouseButton(event.button.x, event.button.y, true);
409 // // & bail if nothing changed...
413 // Check for overlap/build coverage lists [O((n^2)/2) algorithm!]
414 //One way to optimize this would be to only reset coverage lists from the point in
415 //the Z order where the previous window was.
416 for(i=windowList.begin(); i!=windowList.end(); i++)
418 //One other little quirk: Probably need to clear the backing store as well!
420 (*i)->ResetCoverageList();
422 // This looks odd, but it's just a consequence of iterator weirdness.
423 // Otherwise we could just stick a j+1 in the for loop below. :-P
424 std::list<Element *>::iterator j = i;
427 for(; j!=windowList.end(); j++)
428 (*i)->AdjustCoverageList((*j)->GetExtents());
430 // (*i)->HandleMouseButton(event.button.x, event.button.y, true);
435 else if (event.type == SDL_MOUSEBUTTONUP)
437 #ifdef DEBUG_MAIN_LOOP
438 WriteLog(" -- SDL_MOUSEBUTTONUP\n");
440 //Not sure that this is the right way to handle this...
441 for(i=windowList.begin(); i!=windowList.end(); i++)
442 (*i)->HandleMouseButton(event.button.x, event.button.y, false);
443 //I think we should only do topmost here...
445 // windowList.back()->HandleMouseButton(event.button.x, event.button.y, false);
447 #ifdef DEBUG_MAIN_LOOP
449 WriteLog(" -- Unknown event\n");
452 if (Element::ScreenNeedsRefreshing())
454 #ifndef USE_NEW_MAINBUFFERING
455 #ifdef DEBUG_MAIN_LOOP
456 WriteLog("Screen refresh called!\n");
458 RenderScreenBuffer();
459 Element::ScreenWasRefreshed();
462 Element::ScreenWasRefreshed();
466 //hm. Works, but slows things way down.
467 //Now we use WaitEvents() instead. Yay!
471 // Not sure what to do for this in SDL 2...
472 // SDL_EnableKeyRepeat(0, 0);
485 // NEW GUI STARTS HERE
489 // Okay, this is ugly but works and I can't think of any better way to handle
490 // this. So what we do when we pass the GIMP bitmaps into a function is pass
491 // them as a (void *) and then cast them as type (Bitmap *) in order to use
492 // them. Yes, it's ugly. Come up with something better!
497 unsigned int bytesPerPixel; // 3:RGB, 4:RGBA
498 unsigned char pixelData[];
502 // Icons, in GIMP "C" format
503 #include "gfx/icon-selection.c"
504 #include "gfx/disk-icon.c"
505 #include "gfx/disk-1-icon.c"
506 #include "gfx/disk-2-icon.c"
507 #include "gfx/power-off-icon.c"
508 #include "gfx/power-on-icon.c"
509 #include "gfx/disk-door-open.c"
510 #include "gfx/disk-door-closed.c"
513 const char numeralOne[(7 * 7) + 1] =
522 const char numeralTwo[(7 * 7) + 1] =
531 const char ejectIcon[(8 * 7) + 1] =
540 const char driveLight[(5 * 5) + 1] =
548 enum { SBS_SHOWING, SBS_HIDING, SBS_SHOWN, SBS_HIDDEN };
551 SDL_Texture * GUI2::overlay = NULL;
552 SDL_Rect GUI2::olDst;
553 int GUI2::sidebarState = SBS_HIDDEN;
554 int32_t GUI2::dx = 0;
555 int32_t GUI2::iconSelected = -1;
556 int32_t lastIconSelected = -1;
557 SDL_Texture * iconSelection = NULL;
558 SDL_Texture * diskIcon = NULL;
559 SDL_Texture * disk1Icon = NULL;
560 SDL_Texture * disk2Icon = NULL;
561 SDL_Texture * powerOnIcon = NULL;
562 SDL_Texture * powerOffIcon = NULL;
563 SDL_Texture * doorOpen = NULL;
564 SDL_Texture * doorClosed = NULL;
565 uint32_t texturePointer[128 * 380];
578 void GUI2::Init(SDL_Renderer * renderer)
580 overlay = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ABGR8888,
581 SDL_TEXTUREACCESS_TARGET, 128, 380);
585 WriteLog("GUI: Could not create overlay!\n");
589 if (SDL_SetTextureBlendMode(overlay, SDL_BLENDMODE_BLEND) == -1)
590 WriteLog("GUI: Could not set blend mode for overlay.\n");
592 for(uint32_t i=0; i<128*380; i++)
593 texturePointer[i] = 0xB0A000A0;
595 SDL_UpdateTexture(overlay, NULL, texturePointer, 128 * sizeof(Uint32));
597 olDst.x = VIRTUAL_SCREEN_WIDTH;
602 iconSelection = CreateTexture(renderer, &icon_selection);
603 diskIcon = CreateTexture(renderer, &disk_icon);
604 doorOpen = CreateTexture(renderer, &door_open);
605 doorClosed = CreateTexture(renderer, &door_closed);
606 disk1Icon = CreateTexture(renderer, &disk_1);
607 disk2Icon = CreateTexture(renderer, &disk_2);
608 powerOffIcon = CreateTexture(renderer, &power_off);
609 powerOnIcon = CreateTexture(renderer, &power_on);
611 // Set up drive icons in their current states
612 AssembleDriveIcon(renderer, 0);
613 AssembleDriveIcon(renderer, 1);
615 if (SDL_SetRenderTarget(renderer, overlay) < 0)
617 WriteLog("GUI: Could not set Render Target to overlay... (%s)\n", SDL_GetError());
621 DrawSidebarIcons(renderer);
622 // Set render target back to default
623 SDL_SetRenderTarget(renderer, NULL);
626 WriteLog("GUI: Successfully initialized.\n");
630 SDL_Texture * GUI2::CreateTexture(SDL_Renderer * renderer, const void * source)
632 Bitmap * bitmap = (Bitmap *)source;
633 SDL_Texture * texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ABGR8888,
634 // SDL_TEXTUREACCESS_STATIC, bitmap->width, bitmap->height);
635 SDL_TEXTUREACCESS_TARGET, bitmap->width, bitmap->height);
636 SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
637 SDL_UpdateTexture(texture, NULL, (Uint32 *)bitmap->pixelData,
638 bitmap->width * sizeof(Uint32));
644 void GUI2::MouseDown(int32_t x, int32_t y, uint32_t buttons)
649 void GUI2::MouseUp(int32_t x, int32_t y, uint32_t buttons)
654 void GUI2::MouseMove(int32_t x, int32_t y, uint32_t buttons)
656 if (sidebarState != SBS_SHOWN)
660 if (x > (VIRTUAL_SCREEN_WIDTH - 100))
662 //printf("GUI: sidebar showing (x = %i)...\n", x);
663 sidebarState = SBS_SHOWING;
668 //printf("GUI: sidebar hiding[1] (x = %i)...\n", x);
669 sidebarState = SBS_HIDING;
675 if (x < (VIRTUAL_SCREEN_WIDTH - 100))
677 iconSelected = lastIconSelected = -1;
678 HandleIconSelection(sdlRenderer);
679 //printf("GUI: sidebar hiding[2] (x = %i)...\n", x);
680 sidebarState = SBS_HIDING;
683 // We're in the right zone, and the sidebar is shown, so let's select
687 if (y < 4 || y > 383)
692 iconSelected = (y - 4) / 54;
694 if (iconSelected != lastIconSelected)
696 HandleIconSelection(sdlRenderer);
697 lastIconSelected = iconSelected;
704 void GUI2::HandleIconSelection(SDL_Renderer * renderer)
706 // Set up drive icons in their current states
707 AssembleDriveIcon(renderer, 0);
708 AssembleDriveIcon(renderer, 1);
710 // Reload the background...
711 SDL_UpdateTexture(overlay, NULL, texturePointer, 128 * sizeof(Uint32));
713 if (SDL_SetRenderTarget(renderer, overlay) < 0)
715 WriteLog("GUI: Could not set Render Target to overlay... (%s)\n", SDL_GetError());
719 // Draw the icon selector, if an icon is selected
720 if (iconSelected >= 0)
722 SDL_Rect dst;// = { 54, 54, 24 - 7, 2 };
723 dst.w = dst.h = 54, dst.x = 24 - 7, dst.y = 2 + (iconSelected * 54);
724 SDL_RenderCopy(renderer, iconSelection, NULL, &dst);
727 DrawSidebarIcons(renderer);
729 // Set render target back to default
730 SDL_SetRenderTarget(renderer, NULL);
734 void GUI2::AssembleDriveIcon(SDL_Renderer * renderer, int driveNumber)
736 SDL_Texture * drive[2] = { disk1Icon, disk2Icon };
737 const char * number[2] = { numeralOne, numeralTwo };
739 if (SDL_SetRenderTarget(renderer, drive[driveNumber]) < 0)
741 WriteLog("GUI: Could not set Render Target to overlay... (%s)\n", SDL_GetError());
745 SDL_RenderClear(renderer);
746 SDL_RenderCopy(renderer, diskIcon, NULL, NULL);
748 // Drive door @ (16, 7)
750 dst.w = 8, dst.h = 10, dst.x = 16, dst.y = 7;
751 SDL_RenderCopy(renderer, (floppyDrive.DriveIsEmpty(driveNumber) ?
752 doorOpen : doorClosed), NULL, &dst);
754 // Numeral @ (30, 20)
755 DrawCharArray(renderer, number[driveNumber], 30, 20, 7, 7, 0xD0, 0xE0, 0xF0);
756 DrawDriveLight(renderer, driveNumber);
757 DrawEjectButton(renderer, driveNumber);
759 // Set render target back to default
760 SDL_SetRenderTarget(renderer, NULL);
764 void GUI2::DrawEjectButton(SDL_Renderer * renderer, int driveNumber)
766 if (floppyDrive.DriveIsEmpty(driveNumber))
769 DrawCharArray(renderer, ejectIcon, 29, 31, 8, 7, 0x00, 0xAA, 0x00);
773 void GUI2::DrawDriveLight(SDL_Renderer * renderer, int driveNumber)
775 int lightState = floppyDrive.DriveLightStatus(driveNumber);
776 int r = 0x77, g = 0x00, b = 0x00;
778 if (lightState == DLS_READ)
779 r = 0x20, g = 0xFF, b = 0x20;
780 else if (lightState == DLS_WRITE)
781 r = 0xFF, g = 0x30, b = 0x30;
783 // Drive light @ (8, 21)
784 DrawCharArray(renderer, driveLight, 8, 21, 5, 5, r, g, b);
788 void GUI2::DrawCharArray(SDL_Renderer * renderer, const char * array, int x,
789 int y, int w, int h, int r, int g, int b)
791 SDL_SetRenderDrawColor(renderer, r, g, b, 0xFF);
793 for(int j=0; j<h; j++)
795 for(int i=0; i<w; i++)
797 if (array[(j * w) + i] != ' ')
798 SDL_RenderDrawPoint(renderer, x + i, y + j);
802 SDL_SetRenderDrawColor(renderer, 0x00, 0x00, 0x00, 0x00);
806 void GUI2::HandleGUIState(void)
810 if (olDst.x < (VIRTUAL_SCREEN_WIDTH - 100) && sidebarState == SBS_SHOWING)
812 olDst.x = VIRTUAL_SCREEN_WIDTH - 100;
813 // sidebarOut = true;
814 sidebarState = SBS_SHOWN;
817 else if (olDst.x > VIRTUAL_SCREEN_WIDTH && sidebarState == SBS_HIDING)
819 olDst.x = VIRTUAL_SCREEN_WIDTH;
820 sidebarState = SBS_HIDDEN;
826 void GUI2::DrawSidebarIcons(SDL_Renderer * renderer)
828 SDL_Texture * icons[7] = { powerOnIcon, disk1Icon, disk2Icon, powerOffIcon,
829 powerOffIcon, powerOffIcon, powerOffIcon };
832 dst.w = dst.h = 40, dst.x = 24, dst.y = 2 + 7;
834 for(int i=0; i<7; i++)
836 SDL_RenderCopy(renderer, icons[i], NULL, &dst);
842 void GUI2::Render(SDL_Renderer * renderer)
849 if (sidebarState != SBS_HIDDEN)
850 HandleIconSelection(renderer);
852 SDL_RenderCopy(renderer, overlay, NULL, &olDst);
861 cut into 7 pieces give ~54 pix per piece
862 So, let's try 40x40 icons, and see if that's good enough...
865 drive proportions: 1.62 : 1
878 | ^| <-- eject button
899 maybe state save/load