/** * Topaz's Pizza Timer * Built for a future video * https://theindustriousrabbit.video * * Copyright 2022 John Bintz * MIT License */ #include // basic amiga stuff #include #include // allow us to load and scale fonts #include // handle images and sound #include #include #include #include // this is the manual for the original intuition stuff. // not much here is different: https://ia801905.us.archive.org/33/items/Amiga_Intuition_Reference_Manaual_1985_Adn-Wesley_Publishing_Company/Amiga_Intuition_Reference_Manaual_1985_Addison-Wesley_Publishing_Company.pdf // gadtools gives a proper set of components for building UIs. // http://amigadev.elowar.com/read/ADCD_2.1/Libraries_Manual_guide/node0278.html #include #include #include // drawin' stuff // This is where we get font handling #include // the main app window #define WINDOW_WIDTH (240) #define WINDOW_HEIGHT (80) #define WINDOW_TITLE "Topaz's Pizza Timer" #define WINDOW_CHROME_WIDTH (4) struct Screen *screen; struct Window *window; struct Gadget *windowGadgets; #define ABOUT_WINDOW_WIDTH (350) #define ABOUT_WINDOW_HEIGHT (75) // Fonts! struct TextAttr Topaz80 = { "topaz.font", 8, 0, 0 }; // There's no native 16 pixel Topaz font so we'll use // diskfont.library to make one. struct TextAttr Topaz160 = { "topaz.font", 16, 0, 0 }; struct TextFont *topaz80Font; struct TextFont *topaz160Font; // Gadtools stuff #define START_STOP_BUTTON_ID 0 #define RESET_BUTTON_ID 1 #define HOURS_SLIDER_ID 2 #define MINUTES_SLIDER_ID 3 #define SECONDS_SLIDER_ID 5 void *visualInfo; struct Gadget *timerGadget, *hourSlider, *minuteSlider, *secondSlider; // gadtools menus // https://wiki.amigaos.net/wiki/GadTools_Menus // Why the special struct, constants and struct instantiation? // Because this way, when we get a menu message from Intuition, // we can look up the menu item selected by ID, rather than // looking it up by position in the menu tree structure. struct MenuData { int id; }; // Start this at 1, otherwise not selecting a menu item is the same as // opening the menu and picking the item with ID 0 #define MENU_ABOUT_ID 1 #define MENU_QUIT_ID 2 struct MenuData MENU_ABOUT = { MENU_ABOUT_ID }; struct MenuData MENU_QUIT = { MENU_QUIT_ID }; struct NewMenu appMenu[] = { { NM_TITLE, "Pizza Timer", 0, 0, 0, 0, }, { NM_ITEM, "About...", 0, 0, 0, &MENU_ABOUT }, { NM_ITEM, "Quit", "Q", 0, 0, &MENU_QUIT }, { NM_END, NULL, 0, 0, 0, 0, }, }; struct Menu *menu; // timer stuff #define TIMER_INTERVAL 200000 struct timerequest *TimerIO; struct timeval currentSystemTime; struct MsgPort *timerPort; // sound stuff // get that bell #define BELL_FILENAME "bell.8svx" APTR bellSound = NULL; struct Library *DataTypesBase; BYTE waitForSoundSignalNumber; // our business logic // for how long should I cook this pizza? unsigned int uiHours = 0; unsigned int uiMinutes = 12; unsigned int uiSeconds = 0; unsigned int priorHours, originalUiHours; unsigned int priorMinutes, originalUiMinutes; unsigned int priorSeconds, originalUiSeconds; char timerText[9]; ULONG timerStartTime = 0, timerDistance, timerBuild; BOOL timerIsRunning = FALSE; BOOL timerStarted = FALSE; BOOL terminated = FALSE; BOOL openAboutWindowNext = FALSE; /** * Initialize system stuff. */ // This function, despite taking no parameters, still needs void as a // parameter. // http://www.hipooniosamigasite.org/amigadocs/files/LOON-DOCS-DISKS/LOON5/LatticeC.pt3 int setup(void) { // make sure the font exists on the computer // http://amigadev.elowar.com/read/ADCD_2.1/Includes_and_Autodocs_3._guide/node0308.html if (NULL == (topaz80Font = OpenFont(&Topaz80))) { return 0; } // load this via disk so it can be scaled up // http://amigadev.elowar.com/read/ADCD_2.1/Includes_and_Autodocs_3._guide/node0137.html if (NULL == (topaz160Font = OpenDiskFont(&Topaz160))) { return 0; } // get a handle on the Workbench screen if (NULL == (screen = LockPubScreen(NULL))) { return 0; } // Get...visual info. GadTools needs this, and it's all private. // secrets. secrets between gadtools and the amiga. // don't snoop. they wouldn't like it. if (NULL == (visualInfo = GetVisualInfo(screen, TAG_END))) { return 0; } // set up the async timer message port // we create this port, but it ends up in the timerequest structure when // we're done with the device open, so we don't have to worry about // hanging onto a pointer to it. if (NULL == (timerPort = CreatePort(0, 0))) { return 0; } // create an IO object we can get responses to on our timer port if (NULL == (TimerIO = (struct timerequest *)CreateExtIO(timerPort, sizeof(struct timerequest)))) { return 0; } if (NULL == (DataTypesBase = OpenLibrary("datatypes.library", 40))) { // this is ok, we just won't play sound printf("no sound!"); } else { waitForSoundSignalNumber = AllocSignal(-1); } return 1; } /** * Tear down system stuff. */ void teardown(void) { // http://amigadev.elowar.com/read/ADCD_2.1/Devices_Manual_guide/node0196.html if (DataTypesBase) { CloseLibrary(DataTypesBase); FreeSignal(waitForSoundSignalNumber); } if (TimerIO) { // if the timer's still running, wait for it to be done if (!CheckIO((struct IORequest *)TimerIO)) { WaitIO((struct IORequest *)TimerIO); } DeleteExtIO((struct IORequest *)TimerIO); } if (timerPort) { DeletePort(timerPort); } // all right, then. keep your secrets. if (visualInfo) FreeVisualInfo(visualInfo); if (screen) UnlockPubScreen(NULL, screen); if (topaz80Font) CloseFont(topaz80Font); if (topaz160Font) CloseFont(topaz160Font); } /** * Build the GadTools gadgets for this UI. * Normally I'd move this to a separate piece of code, * but for example purposes I want everything in this one file * for easier searching. */ struct Gadget *buildUI(void) { struct NewGadget ng; struct Gadget *currentGadget; struct Gadget *glist; // Add an empty gadget for GadTools to store extra data. // http://amigadev.elowar.com/read/ADCD_2.1/Includes_and_Autodocs_3._guide/node0274.html currentGadget = CreateContext(&glist); ng.ng_LeftEdge = window->BorderLeft; ng.ng_TopEdge = window->BorderTop; ng.ng_Height = 12; ng.ng_Width = 0; ng.ng_GadgetText = NULL; ng.ng_TextAttr = &Topaz80; ng.ng_VisualInfo = visualInfo; ng.ng_GadgetID = NULL; ng.ng_Flags = PLACETEXT_IN; // Timer display ng.ng_Width = window->Width - window->BorderLeft - window->BorderRight; ng.ng_TextAttr = &Topaz160; ng.ng_Height = 20; timerGadget = currentGadget = CreateGadget( TEXT_KIND, currentGadget, &ng, GTTX_Text, &timerText, GTTX_Justification, GTJ_CENTER, TAG_END ); ng.ng_TextAttr = &Topaz80; ng.ng_Height = 12; // start/stop button ng.ng_Width = (window->Width - window->BorderLeft - window->BorderRight) / 2; ng.ng_TopEdge += 18; if (timerIsRunning) { ng.ng_GadgetText = "_Stop"; } else { ng.ng_GadgetText = "_Start"; } ng.ng_GadgetID = START_STOP_BUTTON_ID; currentGadget = CreateGadget( BUTTON_KIND, currentGadget, &ng, GT_Underscore, '_', TAG_END ); // reset button ng.ng_LeftEdge += ng.ng_Width; // lol reuse it ng.ng_GadgetText = "_Reset"; ng.ng_GadgetID = RESET_BUTTON_ID; currentGadget = CreateGadget( BUTTON_KIND, currentGadget, &ng, GT_Underscore, '_', GA_Disabled, timerIsRunning || !timerStarted, TAG_END ); // hours slider ng.ng_LeftEdge = 85; ng.ng_Width = window->Width - window->BorderLeft - window->BorderRight - ng.ng_LeftEdge + 4; ng.ng_TopEdge += 12; // The level is also displayed to the left of the slider by default. // We need to leave space for its rendering, which is done separately // from label rendering. ng.ng_GadgetText = "Hours: "; ng.ng_Flags = PLACETEXT_LEFT; ng.ng_GadgetID = HOURS_SLIDER_ID; hourSlider = currentGadget = CreateGadget( SLIDER_KIND, currentGadget, &ng, GTSL_Min, 0, GTSL_Max, 23, GTSL_Level, uiHours, // You need both MaxLevelLen and LevelFormat for the label to display. GTSL_MaxLevelLen, 2, // The level is also a long, so use long number formattting. GTSL_LevelFormat, "%2ld", GA_Disabled, timerIsRunning, TAG_END ); // minutes slider ng.ng_TopEdge += 12; ng.ng_GadgetText = "Mins: "; ng.ng_GadgetID = MINUTES_SLIDER_ID; minuteSlider = currentGadget = CreateGadget( SLIDER_KIND, currentGadget, &ng, GTSL_Min, 0, GTSL_Max, 59, GTSL_Level, uiMinutes, GTSL_MaxLevelLen, 2, GTSL_LevelFormat, "%2ld", GA_Disabled, timerIsRunning, TAG_END ); // seconds slider ng.ng_TopEdge += 12; ng.ng_GadgetText = "Secs: "; ng.ng_GadgetID = SECONDS_SLIDER_ID; secondSlider = currentGadget = CreateGadget( SLIDER_KIND, currentGadget, &ng, GTSL_Min, 0, GTSL_Max, 59, GTSL_Level, uiSeconds, GTSL_MaxLevelLen, 2, GTSL_LevelFormat, "%2ld", GA_Disabled, timerIsRunning, TAG_END ); return glist; } /** * Set the text of the timer and redraw the necessary gadgets. */ void setTimerText(void) { // Only change the timer widget text if the time is different // from the last render. This prevents unnecessary renders and potential // flashes of UI redraw. if ( (priorHours != uiHours) || (priorMinutes != uiMinutes) || (priorSeconds != uiSeconds) ) { // _Technically_ this will change the label's contents all on its own, // but the newly-rendered label stomps on the old one without clearing // it out, so we need to force the full label redraw with GT_SetGadgetAttrs. sprintf(timerText, "%02d:%02d:%02d", uiHours, uiMinutes, uiSeconds); GT_SetGadgetAttrs( timerGadget, window, NULL, GTTX_Text, &timerText, TAG_DONE ); priorHours = uiHours; priorMinutes = uiMinutes; priorSeconds = uiSeconds; } } /** * Ask the timer to send a message to our message port * after a defined number of milliseconds. */ int startTimer(void) { // TODO: handle timer.device not being available if (0 != (OpenDevice(TIMERNAME, UNIT_MICROHZ, (struct IORequest *)TimerIO, 0L))) { printf("Unable to open timer!"); return 1; } TimerIO->tr_node.io_Command = TR_ADDREQUEST; TimerIO->tr_time.tv_secs = 0; TimerIO->tr_time.tv_micro = TIMER_INTERVAL; // SendIO is async, it doesn't wait for the IO to complete before continuing SendIO((struct IORequest *)TimerIO); CloseDevice((struct IORequest *)TimerIO); } /** * Render the UI. */ void renderUI(void) { windowGadgets = buildUI(); // Use -1 for working with all gadgets. // You need to add, refresh, and refresh window when adding // GadTools gadgets. AddGList(window, windowGadgets, -1, -1, NULL); RefreshGList(windowGadgets, window, NULL, -1); setTimerText(); GT_RefreshWindow(window, NULL); } /** * Clear the current UI. */ void clearUI(void) { RemoveGList(window, windowGadgets, -1); FreeGadgets(windowGadgets); } /** * Get the current system time. */ int getSysTime(void) { struct Device* TimerBase; if (0 != (OpenDevice(TIMERNAME, UNIT_MICROHZ, (struct IORequest *)TimerIO, 0L))) { printf("Unable to open timer!"); return 1; } TimerBase = TimerIO->tr_node.io_Device; GetSysTime(¤tSystemTime); CloseDevice((struct IORequest *)TimerIO); return 0; } /** * Toggle the timer between started and stopped. */ void handleToggleTimer(void) { timerIsRunning = !timerIsRunning; if (timerIsRunning) { // TODO: handle timer.device not being available getSysTime(); // timers are microsecond resolution timerStartTime = currentSystemTime.tv_secs * 1000000 + currentSystemTime.tv_micro; priorHours = NULL; priorMinutes = NULL; priorSeconds = NULL; originalUiHours = uiHours; originalUiMinutes = uiMinutes; originalUiSeconds = uiSeconds; timerStarted = TRUE; // start the timer startTimer(); } else { // put the sliders back. // it causes a lot of UI flashing to update these as the timer runs, // so only do it once we stop the timer. GT_SetGadgetAttrs( hourSlider, window, NULL, GTSL_Level, uiHours, TAG_DONE ); GT_SetGadgetAttrs( minuteSlider, window, NULL, GTSL_Level, uiMinutes, TAG_DONE ); GT_SetGadgetAttrs( secondSlider, window, NULL, GTSL_Level, uiSeconds, TAG_DONE ); } clearUI(); renderUI(); } /** * Reset the timer. */ void handleResetTimer(void) { uiHours = originalUiHours; uiMinutes = originalUiMinutes; uiSeconds = originalUiSeconds; timerStarted = FALSE; setTimerText(); clearUI(); renderUI(); } // // To keep this simple, the about window will block int openAboutWindow(void) { struct Window *aboutWindow; struct NewGadget ng; struct Gadget *currentGadget; struct Gadget *glist; struct IntuiMessage *iMessage; ULONG windowSignal; BOOL closeAbout = FALSE; // Something is causing a small memory leak when I do this and I don't know // what it is. CodeWatcher reports a bunch of 40 byte blocks, most likely // Intuition messages. // // nope, false positive, checked with avail before and after running and // the used/free counts are exactly the same: // https://eab.abime.net/showthread.php?t=104360 aboutWindow = OpenWindowTags(NULL, WA_Left, 40, WA_Top, 40, WA_Width, ABOUT_WINDOW_WIDTH, WA_Height, ABOUT_WINDOW_HEIGHT, WA_DetailPen, 0, WA_BlockPen, 1, WA_IDCMP, IDCMP_CLOSEWINDOW, WA_DragBar, TRUE, WA_DepthGadget, TRUE, WA_CloseGadget, TRUE, WA_Activate, TRUE, WA_Title, "About Pizza Timer", TAG_END ); if (!aboutWindow) { return 1; } currentGadget = CreateContext(&glist); ng.ng_LeftEdge = WINDOW_CHROME_WIDTH + 4; ng.ng_TopEdge = 15; ng.ng_Height = 10; ng.ng_Width = 0; ng.ng_GadgetText = NULL; ng.ng_TextAttr = &Topaz80; ng.ng_VisualInfo = visualInfo; ng.ng_GadgetID = NULL; ng.ng_Flags = PLACETEXT_IN; currentGadget = CreateGadget( TEXT_KIND, currentGadget, &ng, GTTX_Text, "Topaz's Pizza Timer", GTTX_CopyText, TRUE, TAG_DONE ); ng.ng_TopEdge += 10; currentGadget = CreateGadget( TEXT_KIND, currentGadget, &ng, GTTX_Text, "By John Bintz", GTTX_CopyText, TRUE, TAG_DONE ); ng.ng_TopEdge += 10; currentGadget = CreateGadget( TEXT_KIND, currentGadget, &ng, GTTX_Text, "theindustriousrabbit.com", GTTX_CopyText, TRUE, TAG_DONE ); ng.ng_TopEdge += 15; currentGadget = CreateGadget( TEXT_KIND, currentGadget, &ng, GTTX_Text, "Sound: Reception bell by cdrk (CC-BY 4.0)", GTTX_CopyText, TRUE, TAG_DONE ); ng.ng_TopEdge += 10; currentGadget = CreateGadget( TEXT_KIND, currentGadget, &ng, GTTX_Text, "freesound.org/people/cdrk/sounds/264594/", GTTX_CopyText, TRUE, TAG_DONE ); AddGList(aboutWindow, glist, -1, -1, NULL); RefreshGList(glist, aboutWindow, NULL, -1); GT_RefreshWindow(aboutWindow, NULL); windowSignal = 1L << aboutWindow->UserPort->mp_SigBit; while (!closeAbout) { Wait(windowSignal); while (iMessage = GT_GetIMsg(aboutWindow->UserPort)) { if (!closeAbout) { switch (iMessage->Class) { case IDCMP_CLOSEWINDOW: closeAbout = TRUE; break; case IDCMP_REFRESHWINDOW: GT_BeginRefresh(window); GT_EndRefresh(window, TRUE); break; } } // ensure messages are garbage collected GT_ReplyIMsg(iMessage); } } RemoveGList(aboutWindow, glist, -1); FreeGadgets(glist); CloseWindow(aboutWindow); return 0; } /** * Process a single Intuition message. */ void handleIntuitionMessage(struct IntuiMessage *iMessage) { struct Gadget *targetGadget; struct Menu *targetMenu; struct MenuData *menuData; BOOL rerenderTimer = FALSE; switch (iMessage->Class) { // We've released a button. case IDCMP_GADGETUP: targetGadget = (struct Gadget *)iMessage->IAddress; switch (targetGadget->GadgetID) { case START_STOP_BUTTON_ID: handleToggleTimer(); break; case RESET_BUTTON_ID: handleResetTimer(); rerenderTimer = TRUE; break; } break; // We picked a menu item. case IDCMP_MENUPICK: // https://en.wikibooks.org/wiki/Aros/Developer/Docs/Libraries/GadTools targetMenu = (struct Menu *)ItemAddress(menu, iMessage->Code); menuData = GTMENUITEM_USERDATA(targetMenu); switch (menuData->id) { // I originally tried to use MENU_QUIT.id here // https://stackoverflow.com/questions/14069737/switch-case-error-case-label-does-not-reduce-to-an-integer-constant // This is why there's a bunch more overhead for building // the menu userdata. case MENU_QUIT_ID: terminated = TRUE; break; case MENU_ABOUT_ID: openAboutWindowNext = TRUE; break; } break; // We've moved the mouse. In slider gadget talk, we've changed // the value of the slider. case IDCMP_MOUSEMOVE: targetGadget = (struct Gadget *)iMessage->IAddress; switch (targetGadget->GadgetID) { case HOURS_SLIDER_ID: uiHours = iMessage->Code; rerenderTimer = TRUE; break; case MINUTES_SLIDER_ID: uiMinutes = iMessage->Code; rerenderTimer = TRUE; break; case SECONDS_SLIDER_ID: uiSeconds = iMessage->Code; rerenderTimer = TRUE; break; } if (rerenderTimer) setTimerText(); break; case IDCMP_CLOSEWINDOW: // bye bye terminated = TRUE; break; // this is required if your window is refreshable and you're using gadtools. // http://amigadev.elowar.com/read/ADCD_2.1/Libraries_Manual_guide/node026F.html case IDCMP_REFRESHWINDOW: GT_BeginRefresh(window); GT_EndRefresh(window, TRUE); break; } } struct dtTrigger myTrigger; /** * Start playing a bell sound, then wait for it to finish. */ void startBellSound(void) { ULONG soundPlayResult; if (!DataTypesBase) return; // https://amigaworld.net/modules/newbb/viewtopic.php?forum=15&topic_id=39094&post_id=735120&viewmode=thread&order=0#735120 // TODO: something in NewDTObject is causing a lock to be created that // CodeWatcher declares was not freed. Commenting this down to just // the GroupID tag does not make that warning go away. Each time this // is called, a new lock is created. // // CodeWatcher also seems to think this is being kept around in memory, // but running avail before and after getting the bell to ring indicate // otherwise. if (bellSound = NewDTObject( BELL_FILENAME, DTA_GroupID, GID_SOUND, // ourselves SDTA_SignalTask, (ULONG)FindTask(NULL), // ideally we should use this, but my headers aren't new enough? // SDTA_SignalBitNumber, waitForSoundSignalNumber, // so we'll do it this way SDTA_SignalBit, 1 << waitForSoundSignalNumber, TAG_END)) { myTrigger.MethodID = DTM_TRIGGER; myTrigger.dtt_GInfo = NULL; myTrigger.dtt_Function = STM_PLAY; myTrigger.dtt_Data = NULL; // https://github.com/khval/AmosKittens/blob/79f00ba3b81805b54fd4e437f667ea2eecab740d/OS/AmigaOS/animation.cpp#L128 soundPlayResult = DoDTMethodA(bellSound, NULL, NULL, (Msg) &myTrigger); } } /** * Tear down the bell once the timer runs out. */ void waitForBellToFinish(void) { if (bellSound) { Wait(1 << waitForSoundSignalNumber); DisposeDTObject(bellSound); } } /** * Handle the timer running out of time. */ void endTimer(void) { startBellSound(); DisplayBeep(screen); uiHours = uiMinutes = uiSeconds = 0; setTimerText(); waitForBellToFinish(); timerIsRunning = FALSE; timerStarted = FALSE; clearUI(); renderUI(); } void handleTimerMessage(void) { // TODO: handle timer.device not being available getSysTime(); timerDistance = ((currentSystemTime.tv_secs * 1000000 + currentSystemTime.tv_micro) - timerStartTime) / 1000000; timerBuild = (originalUiHours * 3600 + originalUiMinutes * 60 + originalUiSeconds) - timerDistance; if (timerBuild <= 0) { endTimer(); } else { uiHours = timerBuild / 3600; uiMinutes = timerBuild / 60 % 60; uiSeconds = timerBuild % 60; setTimerText(); // this is like a setTimeout loop in JavaScript. keep re-running // the same timer until we don't need it anymore. startTimer(); } } // https://wiki.amigaos.net/wiki/GadTools_Menus void buildMenu(void) { menu = CreateMenus( appMenu, TAG_END ); LayoutMenus(menu, visualInfo, TAG_END); SetMenuStrip(window, menu); } void clearMenu(void) { ClearMenuStrip(window); FreeMenus(menu); } int main() { struct IntuiMessage *iMessage; struct MsgPort *TimerPort; ULONG windowSignal, timerSignal, foundSignals; if (0 == setup()) { teardown(); return 1; } // get our timer message port so we can await on timer events TimerPort = TimerIO->tr_node.io_Message.mn_ReplyPort; // business logic setup timerIsRunning = FALSE; // open the empty window window = OpenWindowTags(NULL, WA_Left, 20, WA_Top, 20, WA_Width, WINDOW_WIDTH, WA_Height, WINDOW_HEIGHT, WA_DetailPen, 0, WA_BlockPen, 1, WA_IDCMP, IDCMP_REFRESHWINDOW | IDCMP_CLOSEWINDOW | BUTTONIDCMP | SLIDERIDCMP | IDCMP_MENUPICK, // IDCMP flags WA_SmartRefresh, TRUE, WA_DragBar, TRUE, WA_DepthGadget, TRUE, WA_CloseGadget, TRUE, WA_Activate, TRUE, WA_Title, WINDOW_TITLE, TAG_END ); if (!window) { teardown(); return 1; } // http://amigadev.elowar.com/read/ADCD_2.1/Includes_and_Autodocs_3._guide/node03F8.html SetWindowTitles(window, (UBYTE *) ~0, "Topaz's Pizza Timer - theindustriousrabbit.com"); buildMenu(); // these create the bit mask for Wait() to listen to events on windowSignal = 1L << window->UserPort->mp_SigBit; timerSignal = 1L << TimerPort->mp_SigBit; renderUI(); while (!terminated) { // http://amigadev.elowar.com/read/ADCD_2.1/Libraries_Manual_guide/node02EB.html // http://www.amigadev.elowar.com/read/ADCD_2.1/Includes_and_Autodocs_2._guide/node038A.html foundSignals = Wait(windowSignal | timerSignal); if (foundSignals & windowSignal) { // drain the window event queue while (iMessage = GT_GetIMsg(window->UserPort)) { if (!terminated) { handleIntuitionMessage(iMessage); } // if we don't reply, the event won't be garbage collected. // we still have to drain any other messages that show up GT_ReplyIMsg(iMessage); } } if ((foundSignals & timerSignal) && timerIsRunning) { handleTimerMessage(); } // open up the about menu outside of event handling if (openAboutWindowNext) { if (openAboutWindow()) terminated = TRUE; openAboutWindowNext = FALSE; } } clearUI(); clearMenu(); CloseWindow(window); teardown(); return 0; }